功能语言中的对象

2020-12-01 04:45:20

这可能是一个棘手的问题,所以我会先解决这个问题:我发现某某事物是“真正”功能还是“真正”命令式还是“真正”面向对象的争论在大多数情况下是令人沮丧和无用的。这些论点中很多都归结为变相的价值判断(我过去在谈到编程中对数学的强调修辞时曾谈到过这一点),而其他许多则基于非正式定义,其中“ “功能性”是指“就像我最喜欢的语言或第一种功能性语言”。 1我宁愿从必要的标准和强硬的角度来考虑这些概念,而从影响力,方法和重点方面来考虑。

威廉·库克(William Cook)重访的“了解数据抽象”一文(本身是Luca Cardelli和Peter Wegner的“关于理解数据类型,数据抽象和多态性的文章”)对常用的“抽象数据类型”概念进行了简单定义, “目的”。当然,他对“对象”的定义并不是唯一的定义,但我认为这是一个有用的定义。重要的是,库克的定义基于抽象标准,即使该语言本身没有明确的“对象”概念,该标准也可以适用。为了强调这一点,我将在此博客文章中使用Haskell来演示“面向对象编程” 2的纯函数变体。

库克的公式不仅是有用的定义,而且还是一种有价值的设计模式,在设计程序时既有优点也有缺点。因此,此博客文章的目标是双重的:首先,说明“对象”如何(在某种定义下)存在于Haskell之类的语言中,而该语言没有内置的“面向对象程序设计”概念;其次,说明以这种方式编写的代码的优缺点。

[…]具有公用名称,隐藏的表示形式以及创建,组合和观察抽象值的操作。

为了使用他的示例的简化版本,这是一种在Haskell中将“整数集”定义为抽象数据类型的方法(效率不高)。在这种情况下,我们有一个IntSet类型,该类型的表示可以很容易地隐藏,因为该库的使用者关心的操作已经根据该抽象表示进行了定义。

-我们将导出“ IntSet”,而不是构造函数。data IntSet = SetEmpty | SetInsert Int IntSetempty :: IntSetempty = SetEmptyisEmpty :: IntSet-> BoolisEmpty SetEmpty = TrueisEmpty _ = Falseinsert :: Int-> IntSet-> IntSetinsert x set |包含集合x =集合|否则= SetInsert x setcontains :: IntSet-> Int-> Boolcontains SetEmpty x = Falsecontains(SetInsert和rest)x | x == y =真|否则=包含剩余x

这可能不是一个有争议的定义或设计:对于大多数功能语言来说,这是非常典型的! …嗯,由于它具有糟糕的算法性能,因此使用该方法可能会引起争议,但至少作为一种教学工具,它是毫无争议的。

用户在查看此模块的文档时会看到什么?由于我们没有导出IntSet的构造函数,因此它看起来像这样:

数据IntSetempty :: IntSetisEmpty :: IntSet-> Boolinsert :: Int-> IntSet-> IntSetcontains :: Int-> IntSet-> Bool

隐藏IntSet定义的能力使它成为抽象数据类型。库的用户不在乎-理想情况下也不需要在乎-构造函数隐藏在该IntSet的后面。

然后,库克继续描述“对象”。这是IntSet的另一个(同样效率不高的)实现,我将其定义为OIntSet,这样我就可以轻松地引用这两者:

数据OIntSet = OIntSet {oIsEmpty :: Bool,oContains :: Int-> Bool} oEmpty :: OIntSetoEmpty = OIntSet {oIsEmpty = True,oContains = \ _-> False} oInsert :: Int-> OIntSet-> OIntSetoInsert x set o包含set x = set |否则= OIntSet {oIsEmpty = False,oContains = \ i-> i == x || o包含i}

我们可能会仔细选择导出列表,以便OIntSet的此实现可以显示与上一个完全相同的一组操作。但是,这里有一个主要区别:OIntSet实际上并不隐藏特定类型。取而代之的是,它只是将一组相关的操作捆绑在功能记录中,功能记录的作用类似于接口类型。在基于ADT的方法和基于“对象”的方法中,用户都不知道OIntSet的内部表示,但是在ADT方法中,这是因为存在一个非公开的单一表示,而在在基于“对象”的方法中,可能存在无法区分的多个独立实现。

类似于“对象”的表示形式具有极大的灵活性。因为OIntSet的使用者可以使用任何值,只要该值提供了相关“方法”的实现,我们就可以轻松方便地定义具有完全不同的内部表示形式的OIntSet的新实例。例如,我们可以定义使用简单数值计算来定义其oContains方法的无限集,例如所有偶数的集合:

oEvenNumbers :: OIntSetoEvenNumbers = OIntSet {oIsEmpty = False,oContains = \ i-> i`mod` 2 == 0}

或者我们可以构造OIntSet值,该值使用其他数据表示形式(例如列表)来存储集合的成员:

oFromList :: [Int]-> OIntSetoFromList list = OIntSet {oIsEmpty =空列表,oContains = \ i-> i`elem`列表}

但是,即使这些OIntSet定义使用不同的基础数据表示形式,它们也暴露了相同的接口,因此我们可以使用相同的操作来操作它们。例如,我们可以定义一个oUnion操作,该操作计算两个OIntSet对象的并集,因为该操作很容易用oIsEmpty和oContains表示:

oUnion :: OIntSet-> OIntSet-> OIntSetoUnion set set'= OIntSet {oIsEmpty = oIsEmpty set && oIsEmpty set',oContains = \ i-> oContains set i || o包含设置的“ i”

我们的oUnion操作(实际上是我们定义的任何操作)都可以在任何两个OIntSet上使用,即使它们的内部表示形式完全不同。我们甚至可以使用它来将所有先前定义的OIntSet构造函数组合到一个表达式中,以创建使用Haskell列表,数字谓词和闭包的组合来表示一个集合的集合:

这是构建某些抽象的非常方便的方法。通过围绕外部接口构建,您可以包括易于协同工作的各种数据表示形式。

该示例显然是人为设计的,因此可能值得给出一些其他使用该设计方法的“实际”示例。通常认为面向对象编程非常适合特定样式的用户界面编程,因为它允许您定义暴露共同界面但具有不同内部表示形式的“窗口小部件”类。您可以通过将小部件定义为具有公共界面的“对象”,来构建这种风格的Haskell GUI库,如下所示:

数据Widget = Widget {drawWidget :: Ctx-> Position-> IO(),handleEvent :: Event-> IO()}

这类似于Brick TUI库采用的方法,该库具有自己的Widget记录。

这种数据表示方式的主要关注点是性能和优化。考虑我们对IntSet的原始ADT表示形式:效率低下,是的,但是我们可以通过多种方式提高效率。例如,我们可以对其进行修改,以使与其始终在“最前面”插入新元素,而不必总是以最低到最高的顺序对集合进行内部插入。这意味着我们可能不再需要遍历整个列表来检查元素成员身份。更好的是,我们可以将列表式表示形式换成二叉树表示形式,在某些情况下可能需要重新平衡。

通常没有办法将这些优化应用于OIntSet风格的程序。您可以定义一个OIntSet,它位于一棵平衡树的前面,因此具有更快的查找和插入速度,但是一旦它位于接口的后面,您就无法再访问这些内部组件。例如,您不能编写一个oUnion操作来重新平衡它所操作的两个集合后面的二叉树:它甚至不知道两个集合是否都受树支持!

实际上,“对象”样式设计的主要卖点也是主要缺点:您无法保证数据的特定表示形式,这意味着您的程序可以轻松混合并匹配不同的表示形式,但是这也意味着您的程序无法以有利的方式利用特定于表示的知识。

还有另一个主要问题,那就是“对象”表示的特定选择在可以支持和不能支持的操作方面会产生很大的不同。回顾一下OIntSet,我能够定义oUnion,但是oIntersection呢?事实证明,使用我选择的特定表示形式实际上是不可能的:

oIntersection :: OIntSet-> OIntSet-> OIntSetoIntersection集set'= OIntSet {oIsEmpty = {-??? -},oIntersection = \ i-> o包含集合i && o包含集合'i}

如何实现oIsEmpty?我可能天真地尝试写oUnion的逆并将其定义为oIsEmpty set ||。 oIsEmpty集”,但这根本不是我想要的:偶数集和奇数集的交集是一个空集,但是偶数和奇数集都不为空,因此这会错误地计算出它们的交集为非空。

这是为集合选择的特定接口的构件。我可以修改接口并能够重新捕获此行为,但是我所做的几乎任何选择都会产生不同的影响:例如,我可以添加一种方法来枚举集合中包含的所有值,此时我有了一种方便的方法来找出两个集合的交集是否确实是空的……但是现在,我使无限集的定义变得更加困难!

这是性能问题的另一面:所选的特定接口不仅会对有效的操作或无效的操作产生影响,而且对可能编写的操作也将产生深远的影响。

在“面向对象编程”的意义上,许多“对象”的定义都可以追溯到艾伦·凯。 Kay是SmallTalk编程语言背后的主要力量,他曾经对OOP进行了以下定义:

对我而言,OOP意味着仅消息传递,本地保留,状态过程的保护和隐藏以及所有事物的极端后期绑定。可以在Smalltalk和LISP中完成。可能还有其他系统可以做到这一点,但我不知道它们。

这里我们对“对象”的处理不符合该定义,但是大多数通常称为“面向对象”的语言也不适用。特别地,这里的“后期绑定”意味着直到运行时才查找方法:在适当的类似SmallTalk的系统中,这将通过名称来完成,这意味着即使使用C ++或Java之类的虚拟调度也不会计算在内。您可以使用符合该定义的方式来使用Ruby或Python之类的语言,但通常不会以这种方式使用它们。在保护本地信息方面,许多面向对象的语言也有些松懈:Python在这里是一个大罪犯,因为它的实例变量通常是通过约定而不是一种语言机制来私有的!当然,几乎没有现代的OOP语言是严格围绕消息传递构建的。

但是,这些语言中有许多被认为是“面向对象的”,因为它们试图抓住这些功能的优势而又不严格遵守这些功能。后期绑定和所有您可以发送的消息系统需要一些复杂的机制才能有效实施,因为否则方法的普遍查找可能会导致速度变慢,因此许多系统都使用虚拟调度而不是极端的后期绑定。类似地,许多系统不遵守严格的信息隐藏,但为了各种便利而允许一些公共信息(例如,C ++或Java中的非私有变量)。从许多方面来看,这些都是有助于沙袋克服上述“对象”问题的设计决策。如果我们对Alan Kay的定义持保留态度,我们可以将这些语言称为“面向对象的语言”。

Cook的定义是对这些“面向对象的”语言所使用的若干属性的提炼。它对虚拟调度进行编码:这里的所有操作都是高阶函数,因此,除非您传递了要调用的函数,否则您无法知道正在调用的对象。它以比Java或C ++更严格的方式对本地信息的隐藏进行编码:此代码的外部使用者只能依赖提供的功能,而不能依赖任何东西,因此任何“本地”信息都必须被隐藏。如果使用得当,它可以对状态过程的保护进行编码:上面的示例是纯净的,但是如果我们的“方法”具有类似IO()的类型,则我们可以包括用于响应时更新内部状态(可能包含在IORef中)的方法。进行其他操作。

艾伦·凯(Alan Kay)的定义很有价值,不仅因为它描述了一种强大的编程方法,该方法以诸如SmallTalk和IO之类的面向对象语言实现,而且还因为它描述了许多非编程语言的系统的行为,同时还捕获了使这些系统变得强大:例如,从某些角度来看,Microsoft的Component Object Model,Plan 9的9P文件系统协议,甚至可以说HTTP本身都是基于进行极晚绑定消息传递的系统。但是我认为Cook的定义也很有价值,因为它描述了广泛使用的两种语言以及其他系统中的数据建模模式。

我曾经读过一篇博客文章,抱怨拉链是对某些数据结构的纯功能接口,甚至没有嵌入到monad或其他副作用抽象中,它们声称它们是“命令性的”。废话!显然,这是一个价值判断伪装成某种技术真理的例子,而与此同时还很肤浅。我会对使用拉链的优缺点进行分析,但“我认为这种抽象是必须的,因此很糟糕,”不是那样!

对于这些示例,Cook的论文使用类似于ML的伪代码,并且还包括更多详细信息:我已经对它们进行了简化。

实际上,如果您仔细观察一下我的表示形式,那是有可能的,只是不切实际:在Haskell中,Int是具有有限数量值的机器整数,因此我们可以枚举每个可能的Int值并检查它是否在两个集合中都存在。这意味着我可以将该操作实现为oIsEmpty =或[o包含集合i && o包含集合'i | i <-[minBound..maxBound]],仅需检查大多数现代处理器体系结构上2 ^ 64个可能的值!