“许多函数基于少量抽象”原则与面向对象编程(OOP)

16

Clojure语言的创造者声称,“在一个开放且小型的可扩展抽象集上运行大量开放的函数是实现算法重用和库互操作性的关键”。这显然与典型的面向对象编程方法相矛盾,后者需要创建大量抽象(类)和一组相对较小的操作它们的函数。请建议一本书、一章书、一篇文章或您个人经验中详细阐述以下主题:

  1. 激励性的问题示例,说明面向对象编程中出现的问题,以及如何使用“多个函数操作少量抽象”来解决这些问题
  2. 如何有效地进行MFUFA*设计
  3. 如何将面向对象编程代码重构为MFUFA
  4. 面向对象编程语言的语法如何妨碍MFUFA

*MFUFA:“多个函数操作少量抽象”


“表达式问题”(Expression Problem)似乎是你想要的。 - Matt Fenwick
一个关于编程主题的有趣视频:http://www.infoq.com/presentations/Clojure-The-Art-of-Abstraction - mikera
我在文章中找不到这个引用。 - Daniel Kaplan
4个回答

18

在编程中,“抽象”有两个主要概念:

  1. 参数化(“多态”,泛型)。
  2. 封装(数据隐藏)。

[编辑:这两个概念是对偶的。第一个是客户端抽象,第二个是实现者抽象(如果您关心这些内容,则在形式逻辑或类型论方面,它们分别对应于普遍量化和存在量化)。]

在面向对象编程中,类是实现两种抽象的厨房水槽功能。

对于(1),几乎每个“模式”都需要定义一个自定义类(或几个类)。另一方面,在函数式编程中,通常有更轻量级和直接的方法来实现相同的目标,特别是函数和元组。经常指出,GoF的大多数“设计模式”在FP中是多余的。

对于(2),如果你没有到处都需要检查的可变状态,那么封装就不是那么必要了。在FP中,仍然构建ADT,但它们往往更简单、更通用,因此需要的数量更少。


2
这个区别是根本的,每个人都应该阅读Scott Meyers关于封装的文章 - Daimrod

9
当你以面向对象的方式编写程序时,你强调用数据类型来表达领域区域。乍一看这似乎是个好主意 - 如果我们与用户一起工作,为什么不有一个User类?如果用户买卖汽车,为什么不有一个Car类呢?这样我们就可以轻松地维护数据和控制流程 - 它只反映了现实世界中事件的顺序。虽然这对于领域对象来说非常方便,但对于许多内部对象(即不反映现实世界任何东西,但仅出现在程序逻辑中的对象)来说并不是很好。也许最好的例子是Java中的许多集合类型。在Java(和许多其他OOP语言)中,既有数组,也有List。在JDBC中有ResultSet,它也是一种集合,但没有实现Collection接口。对于输入,您经常会使用提供顺序访问数据接口的InputStream - 就像链表!但是它也没有实现任何类型的集合接口。因此,如果您的代码使用数据库并使用ResultSet,则将更难将其重构为文本文件和InputStream
MFUFA原则教我们要少关注类型定义,更多地关注常见抽象。因此,Clojure为所有提到的类型引入了单个抽象 - 序列。任何可迭代的内容都会自动强制转换为序列,流只是惰性列表,结果集可以轻松转换为之前提到的任一类型之一。
另一个例子是使用PersistentMap接口来处理结构体和记录。有了这样的共同接口,创建可重用的子程序变得非常容易,并且不需要花费大量时间进行重构。
总而言之,回答您的问题:
  1. 在面向对象编程中经常遇到的一个简单问题是:从许多不同的来源(例如数据库,文件,网络等)读取数据并以相同的方式进行处理。
  2. 为了设计良好的MFUFA,请尽可能使抽象化变得普遍,并避免临时实现。例如,避免像“UserList”这样的类型-在大多数情况下,“List<User>”已经足够好了。
  3. 遵循第2点建议。此外,请尽可能向您的数据类型(类)添加接口。例如,如果您确实需要拥有“UserList”(例如,当它应具有许多附加功能时),则将“List”和“Iterable”接口同时添加到其定义中。
  4. 在Java和C#中,OOP(至少)不太适合这个原则,因为它们试图在初始设计期间封装整个对象的行为,所以很难向它们添加更多功能。在大多数情况下,您可以扩展所涉及的类并将需要的方法放入新对象中,但是1)如果其他人实现自己的派生类,则它将与您的不兼容;2)有时类是“final”的或所有字段都被设置为“private”,因此派生类无法访问它们(例如,要向类“String”添加新的功能,应该实现附加类“StringUtils”)。尽管如此,我上面描述的规则使在OOP代码中使用MFUFA变得更加容易。最好的例子是Clojure本身,它优雅地以OO风格实现,但仍遵循MFUFA原则。

更新:我记得另一个描述面向对象和函数式样式之间差异的方法,也许更好地总结了我上面所说的一切:以面向对象方式设计程序是以数据类型为中心思考(名词),而以函数式方式设计程序是以操作为中心思考(动词)。您可能会忘记某些名词是相似的(例如,忘记继承),但您应该始终记住,在实践中,许多动词执行相同的操作(例如,具有相同或类似的接口)。


+1 我认为正确使用面向对象编程涉及到关注动词 - 在其核心,OO实际上是一种在特定数据包上定义多态函数的方法。 - Marcin
1
我认为相反的是正确的:面向对象编程(OO)关注动词(命令性方法,用于“做”某些事情),而函数式编程(FP)关注名词(声明性函数,表示某些东西)。例如,在OO中,您经常会看到形如x.getSomething()的方法,指示从x“获取”某些内容。在FP中,您会看到一个函数something(x),表示“x”的“某些内容”。 - Andreas Rossberg
1
@Marcin,@AndreasRossberg:我同意你们两个的观点。此外,GoF认为对象应该根据它们可能执行的操作而不是它们的内部结构来定义(就像接口一样)。然而,在比较FP和OOP时,在OOP中,您应该首先考虑域区域中拥有的实体(用户、汽车、合同),而在FP中,您应该首先考虑域中执行的操作(销售、购买)。有时会导致不同的设计。例如,在FP中,您可能会最终得到2个模块用于销售和购买,并将所有实体表示为哈希映射。 - ffriend

6

这句话的早期版本:

"列表的简单结构和自然适用性反映在函数中是非常不拘一格的。在Pascal中,可声明的数据结构过多,导致函数内专业化,阻碍和惩罚了随意的合作。有100个函数操作一个数据结构比有10个函数操作10个数据结构要好。"

......来自著名的《计算机程序的构造和解释》(SICP)书的前言。我相信这本书在这个主题上有很多适用的材料。


0

我认为你没有理解库和程序之间的区别。

良好运作的面向对象(OO)库通常会生成少量抽象,程序使用这些抽象来构建其领域的抽象。较大的OO库(和程序)使用继承来创建不同版本的方法并引入新方法。

因此,是的,相同的原则适用于OO库。


2
不同意,客户端代码中的差异也是真实存在的。在Clojure中,我使用仅限于maps和seqs的数据模型,而在Java中,我被迫为每个用例不断发明新的类型安全容器。应该注意的是,主要区别不在于OOP和FP之间,而在于静态和动态类型之间。 - Marko Topolnik
2
Marko,1. 我同意你的观点,差异也在客户端代码中。2. 我不认为这是静态与动态类型的问题。目前我正在使用Ruby进行编程。与Java相比,代码变小了3倍,但大规模结构仍然相同:我们有很多类和一些与这些类耦合的方法。很少重用。我不喜欢这个。 - Alexey
@Alexey 是的,当我思考我的话语并将Ruby / Rails纳入考虑时,我得出了同样的结论。Ruby可能不会强制您使用类,但它肯定会将您推向这个方向,远离数据结构和代码的清晰分离。在Clojure中情况恰恰相反--你仍然有类(好吧,记录或其他),但第一个选择总是map/vector/seq。 - Marko Topolnik
@MarkoTopolnik 这是因为在Java中,您希望使用静态类型安全的容器,其类型系统相当差。这并不真正反映抽象数量(您正在一次又一次地实现相同类型的抽象)。 - Marcin
@Alexey 就算你的代码很少被重复使用,并不意味着无法实现重复使用,甚至也不表示实现它有特别困难。 - Marcin
2
不,我特别 不想 静态安全容器,但如果没有它们,由于所有下转换,我的代码会变得更加混乱。在Java中,你需要这些类型来调用函数。你曾经尝试过使用 Map<Object,Object> 编写Java代码吗?关于抽象数量的观点实际上是指你编写该抽象的次数,而不是某种描述层面上不同抽象的数量。 - Marko Topolnik

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接