为什么Haskell代数数据类型是“封闭”的?

58

如果我说错了,请纠正我,但似乎在Haskell中,代数数据类型在许多情况下都可以替代面向对象语言中的类和继承。但有一个重大区别:一旦声明了代数数据类型,它就无法在其他地方扩展。它是“封闭”的。而在面向对象编程中,您可以扩展已定义的类。例如:

data Maybe a = Nothing | Just a

我没有办法在不修改这个声明的情况下添加另一个选项。那么这个系统有什么好处呢?似乎面向对象的方式更加可扩展。


3
你应该将Haskell的数据类型看作是结构体和枚举的超级版本(在C语言意义上,不确定其他语言如何使用它们)。它们只是简单的数据。真正的面向对象编程中的对象和类具有相当多的控制和业务逻辑,在Haskell中你可以使用高阶函数、函数记录、类型类等方式来实现。 - glaebhoerl
2
我想知道这是否是一个好的比喻:用Java术语重新表述Haskell(选择一种流行的面向对象编程语言),数据类型就像final类,而typeclasses则像JDK8接口(包括默认方法,使接口非常接近于直接多重继承!)。构造函数支持组合。因此,通过这个比喻,你可以看到,通过“放弃”实现继承,Haskell 实际上并没有“失去”传统OOP中的任何功能——如果有什么区别的话,它只是削减了一种有些危险且容易被更安全的替代方案所取代的工具? - Domingo Ignacio
我相信你可以通过创建一个包含旧数据类型的新数据类型来扩展它们。 - lucidbrot
8个回答

83

答案与代码易于扩展的方式有关,这是类和代数数据类型之间的紧张关系,Phil Wadler称之为“表达式问题”:

  • 使用代数数据类型,

    • 添加一个新的东西操作非常便宜:只需定义一个新函数。所有旧的对那些东西的函数仍然可以正常工作。

    • 添加一种新型的东西非常昂贵:您必须向现有数据类型添加新的构造函数,并且必须编辑并重新编译使用该类型的每个函数

  • 使用类,

    • 添加一种新型的东西非常便宜:只需添加一个新子类,并根据需要在该类中为所有现有操作定义专用方法。超类和所有其他子类继续正常工作。

    • 添加一个新的东西操作非常昂贵:您必须向超类添加新的方法声明,并可能向每个现有的子类添加方法定义。在实践中,负担因方法而异。

因此,代数数据类型是封闭的,因为封闭类型可以很好地支持某些程序演变。例如,如果您的数据类型定义了一种语言,则可以轻松添加新的编译器通道,而不会使旧通道失效或更改数据。

虽然有可能具备“开放式”的数据类型,但除非在精心控制的情况下,否则类型检查会变得困难。 Todd Millstein对一种语言设计进行了一些非常优美的工作,该设计支持开放代数类型和可扩展函数,所有这些都带有模块化类型检查器。我发现他的论文非常有趣。


69

ADT是封闭的事实使得编写总函数变得更加容易。总函数指的是对于其类型的所有可能值,始终会产生结果的函数,例如:

maybeToList :: Maybe a -> [a]
maybeToList Nothing  = []
maybeToList (Just x) = [x]

如果{{Maybe}}是开放的,有人可以添加额外的构造函数,{{maybeToList}}函数会突然失效。
在面向对象编程中,使用继承来扩展类型时,这不是问题,因为当您调用没有特定重载的函数时,它可以使用超类的实现。也就是说,如果{{Student}}是{{Person}}的子类,则可以使用{{printPerson(Person p)}}来很好地调用{{Student}}对象。
在Haskell中,通常在需要扩展类型时使用封装和类型类。例如:
class Eq a where
   (==) :: a -> a -> Bool

instance Eq Bool where
  False == False = True
  False == True  = False
  True  == False = False
  True  == True  = True

instance Eq a => Eq [a] where
  []     == []     = True
  (x:xs) == (y:ys) = x == y && xs == ys
  _      == _      = False

现在,==函数完全开放,您可以通过将其实例化为Eq类的一个实例来添加自己的类型。
请注意,可扩展数据类型的想法已经得到了研究,但这绝对不是Haskell的一部分。

16

如果你编写了一个函数,例如:

maybeToList Nothing = []
maybeToList (Just x) = [x]

如果你了解,只要涵盖了所有情况就永远不会产生运行时错误。但是,一旦Maybe类型是可扩展的,这个说法就不再成立。在需要可扩展类型的情况下(它们比你想象的要少见),Haskell的规范解决方案是使用类型类。


Maybe类型并不是一个很好的例子,但我经常发现我想要扩展这个类型。 - Zifre

12
请查看“开放数据类型和开放函数” http://lambda-the-ultimate.org/node/1453 在面向对象的语言中,通过定义新类轻松扩展数据,但添加新函数却很困难。在函数式语言中,情况则相反:添加新函数没有问题,但是扩展数据(添加新数据构造函数)需要修改现有代码。支持这两个方向上的可扩展性问题被称为表达式问题。我们在Haskell语言中提出了开放数据类型和开放函数作为表达式问题的一种轻量级解决方案。思想是开放数据类型的构造函数和开放函数的方程可以分散在程序中。特别是,它们可能驻留在不同的模块中。预期的语义如下:程序应该表现得好像数据类型和函数是封闭的,定义在一个地方。函数方程的顺序由最佳匹配模式确定,其中特定模式优先于不具体的模式。我们展示我们的解决方案适用于表达式问题、泛型编程和异常处理。我们概述了两种实现方式。一种简单的,源自语义,另一种基于互递归模块,允许分别进行编译。

8

这个问题(虽然有点老)上有一些出色的答案,但我觉得我必须发表我的意见。

没有办法在不修改声明的情况下以后增加另一个选项。那么这种系统的好处是什么?看起来OO的方式会更加灵活。

我认为,对于开放联合提供的可扩展性,并不总是一个优点,相应地,OO强制你这样做反而是一个弱点。

封闭联合的优势在于其详尽程度:如果您在编译时已经固定了所有备选方案,则可以确定将没有未预料到的情况导致您的代码无法处理。在许多问题领域中,这是一个宝贵的属性,例如,在语言的抽象语法树中。如果您正在编写编译器,语言的表达式将落入一个预定义的、封闭的子用例集中——您不希望用户能够在运行时添加新的子用例,而您的编译器无法理解!

实际上,编译器AST是访问者模式的经典Gang of Four激励示例,这是封闭和详尽模式匹配的OOP对应。反思一下OO程序员最终发明了一个模式来恢复封闭和详尽。

同样,过程式和函数式程序员发明了模式以获得总和效果。最简单的是“函数记录”编码,对应于OO接口。函数记录实际上是一个分派表。 (请注意,C程序员使用这种技术已经有很长时间了!)技巧在于通常有许多可能的给定类型的函数——通常是无限多。因此,如果您有一个记录类型,其字段是函数,则可以轻松支持天文数字或无限个备选方案。而且,由于记录是在运行时创建的,并且可以根据运行时条件灵活地完成,因此备选方案是晚绑定的。

我最后要说的是,在我看来,OO使太多人相信可扩展性与晚绑定是同义词(例如,能够在运行时添加新的子用例到类型中),但这并不一般正确。晚绑定是扩展性的一种技术。另一种技术是组合——使用固定的构建块词汇表和组装它们的规则构建复杂对象。该词汇表和规则理想上非常小,但设计得具有丰富的交互作用,允许您构建非常复杂的事物。

函数式编程——尤其是ML/Haskell静态类型的口味——一直强调组合而非晚绑定。但实际上,两种技术在两种范式中都存在,并且应该是好程序员工具包中的工具。

值得注意的是,编程语言本身就是组合的基本示例。一个编程语言具有有限的、希望是简单的语法,允许您组合它的元素来编写任何可能的程序。(实际上这回到了上面的编译器/访问者模式的例子并激励它。)

7
首先,与Charlie的回答相反,这并不是函数式编程固有的特征。OCaml具有开放式联合或多态变量的概念,基本上可以做到你想要的。
至于为什么选择Haskell,我认为是因为:
  • 这使得类型可预测-每个类型只有有限数量的构造器
  • 定义自己的类型很容易。
  • 许多Haskell函数是多态的,类让您扩展自定义类型以适应函数参数(类似于Java的接口)。
所以,如果你宁愿有一个data Color r b g = Red r | Blue b | Green g类型,这很容易实现,并且你可以轻松地将其作为单子或函子或其他函数所需的方式来使用。

3
那个颜色的例子看起来很奇怪。这不是应该变成红、绿或蓝吗?你是不是想要一个“乘积”类型而不是一个“和”类型呢? - Zifre
1
我承认这是一个奇怪的例子。也许 Something a b c = Nada | JustA a | CoupleOf b c 会更好一些。 - rampion
3
你还可以创建一个类并声明一个已有的类型是它的实例,这样你就可以通过这种方式来扩展它。 - Paul Johnson

2
另一种更直观的理解数据类型、类型类与面向对象类的方式如下:
面向对象语言中的类Foo既代表具体类型Foo,也代表所有Foo类型所属的类:那些直接或间接派生自Foo的类型。
在面向对象语言中,你通常隐式地针对Foo类型的类进行编程,这使得你可以“扩展”Foo。

1

好的,这里的“开放”指的是“可以派生自”,而不是像Ruby和Smalltalk那样可以在运行时用新方法扩展类的意义,对吧?

无论如何,请注意两点:首先,在大多数主要基于继承的面向对象语言中,都有一种声明类以限制其继承能力的方法。Java有“final”,在C++中也有一些技巧。因此,在其他面向对象语言中将其作为默认选项只是一个选项。

其次,您仍然可以创建一个使用封闭ADT并添加其他方法或不同实现的新类型。所以你并没有受到那种方式的限制。再次强调,它们似乎在形式上具有相同的强度;在其中一个中可以表达的内容也可以在另一个中表达。

真正的问题在于函数式编程确实是一种不同的范式(“模式”)。如果您期望它应该像面向对象的语言一样,那么您会经常感到惊讶。


不,他的意思是在“Ruby”的意义上打开——以后可以添加新的数据类型变量。 - Don Stewart

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