摘要:
我的导师Luca Cardelli和Peter Wegner曾经于1985年在ACM Computing Surveys上发表过一篇名为“理解类型、数据抽象和多态”的文章。这篇论文引发了对于面向对象编程中语意和类型理论的诸多研究,且一直持续到今天。尽管经过了长达25年的研究,但是对于数据抽象的两种形式:抽象数据类型和对象,仍然存在着普遍的混淆。本文试图解释它们的差别,以及这些差别的重要性。
1、介绍
对象和抽象数据类型之间有什么关系?在最近的20年间,我就该问题询问过众多的计算机科学家。我通常是在宴会或者酒会上问这个问题的。比较有代表性的回答是:“对象是一种抽象数据类型”。
这个回答和绝大多数编程语言课本中的一致。Tucker和Noonan[57]中写到“类本身就是抽象数据类型”。Pratt和Zelkowitz [51] 中则把关于Ada、C++、Java和Smalltalk的讨论混杂在一起,好像它们都是同一思想的稍微不同的变种一样。Sebesta [54]中写到“在面向对象语言中,抽象数据类型被称为类”。他认为“抽象数据类型”和“数据抽象”是同义词。Scott [53]中对对象进行了详细的描述,但是除了对opaque type进行了还不错的讨论外,没有提及任何关于抽象数据类型的内容。
我为何要问这个问题呢?每个人都知道答案。课本中也都讲过。答案是有些模糊,不过大家都认为这不是什么大问题。如果不是要把这个话题付之刊物的话,大家都会互相点头应和并转向更重要的话题。虽然我没明说,不过大家都知道我确实要发表文章,他们知道我有这个计划。
我认为,课本上说的是错误的!对象和抽象数据类型根本不是一回事,它们之间根本不是所谓的变种关系。它们具有根本的不同,却又在很多方面互补,其中一个的强项正好是另外一个的弱项。之所以会被混淆,是因为绝大多数现代编程语言都同时支持对象和抽象数据类型,并把它们混合到一个语法形式之中。但是,语法形式的混合并没有抹去它们在语义上的根本区别,而这又影响到程序的灵活性、扩展性、安全性以及性能。因此,为了有效地使用现代编程语言,就应该理解对象和抽象数据类型间的本质区别。
虽然对象和ADTs间具有根本的不同,但是它们却都属于数据抽象。一般来说,数据抽象是指任何能够隐藏数据实现细节的机制。数据抽象这个概念的出现要远早于“数据抽象”这个名词的出现。在数学上,数据的抽象表述具有很长的一段历史。举个简单的例子,如何来表示一个整数集合。有两种标准的方法来抽象地描述集合:代数方法以及特征函数方法。一个代数集合是一组抽象值以及处理这些值的操作。集合的特征函数描述则是把一个值域中的值映射到一个布尔值,用以指示该值是否属于集合之中。数学中的这两种传统方法和编程中数据抽象的两种形式非常接近:代数方法接近于抽象数据类型,而特征函数方法则接近于对象。
下面,我将对这个例子进行详细的说明以解释对象和ADTs之间的区别。例子中将仅仅使用非可变实体,因为这足以阐明我们的主要观点。为了关注于数据抽象的基础概念,包括继承、反射在内的其他主题也不在讨论之列。
当我在酒会上挑起关于这个主题的讨论时,我并没有事先完整地介绍前因后果。在讨论中不断地提出问题会更加有趣一些。讨论非常活跃,因为大多数概念,文献中都有记录,所有的基本事实大家都是知道的。有趣的是,从这些事实中得出的结论却少有人知道。很多讨论组最终得出了对象和ADTs间的不同,不过我能明显看出他们离开时都显得有些不安,就像原来很熟悉的路标现在却指向了不同的方向。感到不安的一个原因是在现实的编程语言中,这种根本的不同被模糊化了,但并没有被消除。这个主题很复杂并且涉及多方面的问题。已有众多文献介绍了对象和ADTs之间的关系,本文仅仅是对此的一个引介。
在关于对象和ADTs的交谈中,我接着把讨论引向了关于更准确地理解数据抽象这个问题之上。什么是抽象数据类型?什么是对象?对于抽象数据类型来说,大家存在着共识。
2、抽象数据类型
抽象数据类型有一个公开的名字,一个隐藏的表示实现,并且具有用于创建、组合以及观察该抽象的值的操作。大多数语言中常见的内置类型(比如Algol、Pascal、ML、Java以及Haskell中的int和bool数据类型)都是抽象数据类型。
除了内置的抽象数据类型,有些语言还支持用户定义的抽象数据类型。在20世纪70年代,CLU[37,36]和Alphard[61]中首先实现了和内置数据类型相似的用户定义抽象数据类型。它们和数据类型的代数规范说明之间具有很强的联系[24,7]。CLU中所引入的核心概念被ML[42],Ada[49]以及Modula-2[60]所采用。在图一中,我们根据CLU的参考手册[37]定义了整数集合的抽象。
表示实现的类型为一个整数列表。虽然在有关CLU的讨论中,这些值被称作“对象”或者“数据对象”,但是它们无需和面向对象语言中的对象相同。
CLU使用显式的语法和操作来进行表示实现的隐藏。cvt类型用来作为表示类型的外部展现,up和down函数用来在类型的公开和私有表示之间进行转换。无需对CLU的细节进行进一步的阐述,就可以容易地在ML中定义出同样的抽象,如图二所示,其中简化了隐藏机制,并且类型推导系统也简化了类型定义。
图三给出了所得到的抽象数据类型的签名。签名定义了类型的名字(不是其表示)以及操作的种类。可以通过整数集合完整的行为规范说明对签名进行扩充。抽象数据类型对规范化和验证技术具有强有力的支持,其中包括等式理论[20,3,7],公理化规范[26, 40, 17]等。这些规范可以很好地应用其中,它们直观、优雅、完备。
用户可以声明集合类型的值,并能使用操作来处理这些值。
不过,用户不能看到具体的表示实现。这也是需要isEmpty函数的原因,因为我们是不能在抽象体之外编写下面程序的:
fun test(a : set) = (a == EMPTY);
test函数试图破坏数据抽象的封装来窥探其内部表示。同时,对于整数集也没有预定义的相等的概念。如果希望进行相等性操作,那么就必须显式地在ADT接口中定义、实现。
2.1 表示独立性
set这个名字是抽象的,因为它有一个公开的名字,但其实现细节却是隐藏的。这是抽象的一个本质特征:表面上有些东西可见,细节却被隐藏。对于类型抽象来说,类型名是公开的,其表示实现是被隐藏的。过程抽象则是过程接口(名字和参数)是公开的,操作细节被掩藏起来。类型抽象是一种可以用来实现数据抽象的技术手段。
数据抽象的实际好处之一为:它允许在不影响抽象使用者的情况下更改内部实现细节。比如,我们可以更改set的代码,用哈希表或者平衡二叉树来表示整数集合。图四给出了一个基于排序链表方式的set的可选实现。
2.2 优化
允许不同的实现为我们提供了优化某些操作的可能性。例如,图二中所示的union操作的运算成本就很高。如果使用排序列表的实现方法,union操作可以在线性时间内完成。插入在某些情况下可以很快,但是却需要拷贝很多节点。基于相关算法上的权衡来决定采用哪种实现方法是一种公认的软件工程活动。
这些优化非常依赖于抽象数据类型的一个重要特性:同时可以窥探多个抽象值的表示实现。Union操作中就检查了多个表示。同时检查多个表示是很正常的,因为这是类型系统以及类型set的所有值都隶属于创建它们的抽象数据类型实现这个事实共同作用的自然结果。我们将看到,能够检查多个表示实现的能力确实具有一些重要的影响。
2.3 唯一实现
对于ML的abstypes、CLU的clusters、Ada的packages以及Modula-2的modules来说所,在一个程序中都只允许存在抽象数据类型的一种实现。抽象数据类型的实现是一种用来管理隶属于该类型的值的工具。一个给定实现的所有值共享同一个表示类型,不过却可以存在隶属于该类型的多个不同表示变种。这通常通过把表示类型定义为一个带有标签化的和集形式来达成。类型名set是指向单个隐藏表示的全局绑定名称。类型系统确保实现可以安全地检查任何set的值。
仅仅允许数据抽象具有一种实现的做法是很有局限性的。在图二和图四的定义中,已经存在了名字冲突的情况。即使它们其实是同一个抽象的不同版本,其中之一必须得改为一个不同的名字set2。必须得对客户程序进行修改以选择其中一种实现。
在C编程中,也经常会用到ADTs[32],可以把头文件当成一种简单的模块系统。类型的签名以结构的前向引用的方式放在头文件中,仅在实现文件中进行定义。图五中给出了整数集的一个头文件示例。这个方法是可行的,因为C编译器不必知道表示类型的格式,只需知道该表示指针的大小即可。
2.4 模块系统
只允许唯一实现的问题可以通过把抽象数据类型放入模块中解决。ML[39]具有一个模块系统,允许针对给定签名有多个实现存在。抽象的签名可以只定义一次,把该签名的多个实现分别放在不同的模块中。可以针对该签名参数化客户程序,这样在模块绑定期就能够选取某个特定实现。在软件库中可以存在多种实现,但是在一个给定的程序中却只能使用一种实现。
能够允许多个实现是一件好事,不过在灵活性方面仍然无法满足要求。考虑这样一种情况:程序的一个部分想使用整数集的排序列表表示,而另外一个部分却想使用二叉树表示。在ML、Ada以及Module-2中,一个抽象是可以有两种不同的实现的。然而,程序中的这两个不同部分是不能互操作的,且它们之间不能交换整数集。因此,如下程序时非法的:
fun f(a : set, b : set2) = union(a, b)
不存在能够结合set和set2的union操作。根据我们定义的签名,甚至无法写出这样的操作来。
ML的模块系统也允许在一个模块中定义多个相关的抽象类型。比如,一个人事管理应用就需要定义出具有把雇员和部门关联起来的操作的Employee和Department抽象。
2.5 规范模型
抽象数据类型的规范模型建立在existential类型[44]的基础之上。其中,ADT实现是具有existential类型的first class值,如定义如图六所示。
类型SetImp的值不是一个set,而是set抽象的实现。这个两层的结构是抽象数据类型的本质所在:第一层是一个公开了抽象类型名字以及操作的实现。在该实现中,也就是第二个层次,是表示该命名抽象类型(set)要素的值。
该existential类型几乎和图三中的签名完全一样。可以直观地理解为,它断言“存在一个被局部标识为rep类型,遵循所定义的操作……”。
大多数实际的语言都不支持first-class ADT实现的全部特性。因此,existential值及其应用对于大多数程序员来说都显得陌生。对existential类型机制的介绍超出了本文的范围。Cardelli and Wegner的论文[10]中对此有所介绍,Pierce的书《Types and Programming Languages》[50]对其进行了详细彻底的讲解。
要想使用一个existential值,必须将其“打开”来为该表示类型声明一个名字以及访问其操作。每当打开一个existential值时,就创建一个全新的类型名。因此,如果一个ADT实现被打开了两次,那么其中一个实例的值不能和另外一个实例的值混用。事实上,通常会在程序的全局范围内打开所有的ADTs。ML的模块系统具有更加成熟复杂的共享机制,以允许多个实现的共存,同时允许同一个抽象的多个使用间的互操作。即使在这种情况下,两个不同实现的值也不能混用。
2.6 小结
抽象数据类型是一个结构,它定义了一个新的类型,同时隐藏了该类型的表示细节,并提供了处理该类型值的操作。下面给出抽象数据类型为何从本质上是正确的一些原因:
它们看上去就像是内置类型。它们提供了有效的验证技术。ADTs可以被高效地实现,即使对于需要检查多个抽象值的复杂操作来说也是如此。从类型理论的视角来看,抽象数据类型具有基于existential类型的基础模型。Existential类型和universial类型是一对互补体,它们是参数化多态(在Java和C#中称为范型)的基础。Universial类型和existential类型是非常基础的,基本上不存在其他的替代类型。还能有啥呢?和数学之间有非常强的联系。ADT和抽象代数具有相同的形式:一个用来代表抽象值的集合的类型名以及对这些值的操作。操作可以是单元、二元、多元或者零元(也就是构造器),并且它们都被统一对待。
所有这些观察得出一个一般性的结论:抽象数据类型是定义数据抽象的方式。这个认识是如此根深蒂固、如此显而易见的正确,以至于再也无法想到其他的替代方式。很多人把“抽象数据类型”和“数据抽象”看作是同义词。
但是,抽象数据类型并不是定义数据抽象的唯一方式。还存在另外一种和其具有本质不同的定义数据抽象的方式。
3. 对象
面向对象编程起源于Simula 67语言[16]。在与Liskov合作关注于ADTs之前,Zilles曾经发表过一篇描述对象形式的论文[62]。同时,Smalltalk [55,28]、Actors [25]以及Scheme [56, 1]都开始在无类型的环境中探索对象的概念。值得一提的是,Smalltalk把这些想法设计成了一个哲学性和实效性兼具的引人入胜的面向对象编程语言和环境。因为这些语言都是动态类型的,因此并没有对当时以ADTs呈现的静态类型数据抽象工作提供直接的贡献。
对于面向对象编程来说,并没有一个公认的模型。我在此处给出的模型虽然得到业内专家的认可,但是当然还有其他有效的模型存在。特别地,我将以denotional方式来说明对象,我认为这可以直观地展现出对象的核心概念。我觉得optional方式会模糊掉一些基本的洞见。
在本小节中,我将讨论一种面向对象编程的纯粹形式:使用接口[9, 8]。对于流行语言中的一些现实问题,将在第5小节中进行讨论。
作为开始,我们首先重新考虑一下整数集合的概念。一种替代的描述整数集合的方法是采用特征函数,如下:
type ISet = Int ---> Boolean
类型Int ---> Boolean 是从整数到布尔值映射的函数类型。显然,这是一种和前面小节中介绍的抽象数据类型不同的看待整数集合的方法。考虑该类型的如下值:
表达式 是一个函数,具有一个名为i的参数以及作为返回值的表达式e。空集合就是一个永远返回false的函数。向一个集合中插入值n则创建了一个函数,其检测参数和n的相等性或者参数和函数式集合s的成员关系。有了这些定义,就可以容易地创建和操作集合了:
从哪种意义上才能把Iset理解成其定义了一种关于整数集合的抽象呢?毕竟,我们都习惯于以表示和操作来思考问题。不过,这些概念不适合于目前这种情况。有人也许会说,这种方法把集合表示成从整数映射到布尔值的函数。但是,这里的“表示”看起来像是一个接口,而不是一个具体的表示。
请注意,这里没有“contains”操作,因为集合本身就是contains操作。虽然其看起来并不像,但是特征函数本身就是一种定义整数集合的纯粹的面向对象方法。你也许无法立即接受这一点,那是因为我还没有讨论到任何有关类、方法或者继承这些被认为是对象的特征的东西。
3.1 对象接口
Iset是整数集合数据抽象的面向对象接口。函数是对集合的一种观察,集合就是用这种可以作用于其上的函数所“表示”的。该接口存在一个问题:无法知晓集合是不是为空。图七中给出了一个更为完整的接口定义。它是一个记录类型,具有四个方法成员。记录的名称以大写字母开始,以和其他同名的使用进行区分。这是一个非可变整数集合对象的标准面向对象接口。
关于对象接口非常本质的一点是,它没有使用类型抽象:不存在任何名字公开而其表示隐藏的类型。类型ISet被定义成一个包括从已知类型到已知类型映射的函数的记录类型。不同的是,对象使用过程抽象来隐藏行为。这个不同会极大地影响数据抽象的两种方式的应用。
对象接口在本质上是高阶类型,这里的高级和把函数作为值传递的高阶具有相同的含义。在面向对象编程中,每当把对象当作值进行传递或者返回时,其实都是在把函数作为值传递和返回。把函数收集到记录中并称之为方法的做法是无关紧要的。因此,在典型的面向对象程序中,所使用的高阶值要远多于函数式程序。
ADT中的empty操作并不是面向对象的ISet接口的一部分。因为它并不是对于集合的观察,而是集合的构造器。
3.2 类
图八中定义了ISet接口的几种实现。其中contains方法和前面给出的简单函数完全一样。在重新定义了ISet后,这些定义都具有相同的类型。
特殊符号 用来定义递归值[50]。语法 定义了一个递归值,其中名字x可以出现在表达式f中。 意味着f的值,其中x的出现表示在f内部对自身的递归引用。对象几乎总是自引用的值,因此所有对象的定义都使用 。遵照惯例,我们把this当作名字x使用,不过实际上可以使用任何名字。绑定名x对应于Smalltalk中的self、C++中的this。
这些定义中的每一个都对应于面向对象语言中的一个类。在这种方式中,类只能用来构造对象。类作为类型使用的情况会在稍后介绍。
类状态(或者成员变量)的定义和Java[21]中有所不同。在这种方式中,成员变量是作为类的参数罗列的,和Scala[47]中类似。
在这些定义中,有几个方法体是重复的。insert方法只是通过调用Insert类来创建一个多了一个成员的新ISet对象。可以使用继承来重用一个方法的定义。大家常常把继承看做是面向对象编程的本质特征之一。不过,我们在本小节中不会使用继承,因为对于面向对象编程来说,继承既不必要,也非其专有[13]。
这些类的客户程序看起来就像是一个Java程序,具有熟悉的方法调用风格:
从包含有函数值的记录中选择一个函数进行调用通常被称作动态绑定。这个术语对于在本质上是调用了一个高阶函数的活动来说,不是一个直观地描述。
和整数集的ADT版本具有两个层次(集合实现和集合值)一样,面向对象版本也具有两个层次:接口和类。类是一个过程,其返回满足接口的值。虽然Java类构造器被多个定义重载,但是可以清楚的看出,构造对象仍然是类的主要用途之一。
3.3 自知性(Autognosis)
仔细检查图七中对象接口中的union操作,会发现参数的类型都是接口形式的。这就意味着set对象中union方法无法知道被联合在一起的另外一个集合的表示实现。不过,union操作也无需知道其他集合的表示实现,它只需能够对成员关系进行测试即可。图八中的Union类构造出了一个把set1和set2联合在一起的对象。
对我来说,禁止窥视其他对象的表示实现是面向对象编程的基本特征之一。我称之为自知性原则:
对象只能通过公开接口访问其他对象。
自知性意味着“自我了解”。一个自知性的对象仅仅知晓自己的内部细节。其他所有对象都是抽象的。
反过来理解会非常有用:任何允许同时窥探多个抽象的表示的编程模型都不是面向对象的。
在已定义的编程模型中,组件对象接口(COM)[5,22]是最为纯粹的面向对象编程模型之一。COM要求所有这些原则必须被强制遵守。因此,COM编程非常的灵活和强大。其中没有内置的相等性的概念,也无法检测对象是否为某个类的实例。
自知性对系统的软件工程属性具有深远的影响。特别地,一个自知性的系统非常的灵活。不过同时,也非常难以对其操作进行优化。更重要的是,在类的公开接口和实现其行为的能力之间有些微妙的关联,我们会在3.5小节对此进行探讨。
3.4 灵活性
对象接口没有对值的表示给出具体的规定,实现了所要求方法的任何值都是可以接受的。因此,在应对新表示实现方面,对象很灵活,也有好的扩展性。对象接口的灵活性可以很容易地通过定义几种新的集合来说明。比如:可以容易地定义偶数集合和全整数集合:
Full集合把自己作为insert和union操作的返回值。这个例子同时也说明了对象可以容易地表示无限集合。
这些新定义的集合可以和前面定义的集合混用。其他包括素数以及区间之类的特殊集合也可以被定义出来。
在抽象数据类型中,不具备如此的灵活性。这里的差别是根本性的:抽象数据类型具有私有的、被保护的表示类型,阻止了篡改和扩展。对象则具有在任何时候都允许定义新实现的行为接口。
对象的扩展性不依赖于继承,而是对象接口的一个固有属性。
3.5 关于接口的权衡
对于对象接口的选择会影响到操作的效率以及操作是否能够实现。
例如,对于整数集合接口,我们就无法为其增加一个交集操作,因为我们无法在不遍历集合的情况下确定交集是否为空。在集合类(就像本文中定义的)中加入迭代方法是很常见的做法。但是迭代方法无法很好地应用到无限集合中。虽然在进行接口设计时,必须要做出一些重要的软件工程决策,但是在编程语言教科书中却很少提及这方面的内容。
关于对象接口,存在一个问题:常常会出于效率方面的考虑而使得实现层面的问题影响到接口的设计。增加用于窥视所隐藏的表示实现的公开方法会极大地提升效率。但同时也会使得接口的灵活性和扩展性受到限制。
3.6 优化
在面向对象实现中,是无法在不修改接口的情况下对union方法进行基于排序列表的优化的。如果接口中包含有以排序的方式遍历集合内容的方法,那么该优化就是可行的。在对象接口中增加更多的公开方法会极大地提升性能,但同时也会降低灵活性。如果采用更复杂的方法实现集合,那么要想进行优化的话,就可能需要在公开接口中暴露更多的表示细节。
可以对图八中的对象实现进行一些优化。首先,对于空集的union方法其实就是恒等函数。其次,insert类不必每次构建一个新值。仅当要插入的数不在集合中时,才创建新值。
不必把insert和union作为方法定义在对象接口中,因为可以把它们定义为能够操作任何集合的类。对union方法关于空集的优化就是一个为何把创建操作内置在对象接口之中能带来好处的原因。
3.7 模拟
面向对象编程是由模拟编程语言Simula[16, 4]首先实现的。其最初的想法是想模拟现实世界中的系统,不过我觉得它也允许一个对象去模拟或者假装成另外一个对象。
比如,集合区间(2, 5)模拟了一个把从2到5的整数插入其中的集合。根据自知性原则,系统中的任何其他部分都无法区分出一个对象到底是一个区间集合还是被插入整数的集合。有很多操作会违反这个原则,包括指针相等性以及instanceof测试。
模拟还可以作为面向对象程序验证的基础。如果两个对象互相模拟,形成一个双向的模拟关系,那么它们就是相等的[41]。模拟和双向模拟的概念是非常强大的用于行为分析的数学概念。
3.8 规范和验证
在程序验证方面[34, 45, 2],面向对象编程曾经导致过严重的问题。如果你明白面向对象编程其实是一种高阶过程编程;对象其实是一种能够作为参数和返回值任意传递的first-class过程值的话,就不会对此感到惊讶。结合了first-class高阶函数和imperative状态的程序是很难验证的。
有一个很常见的抱怨,那就是在调用一个方法时无法确定执行了哪段代码。这是first-class函数应用的一个常见问题。如果认为这确实是个问题话,那么在ML和Haskell中,这个问题会更加严重,因为(一般来说)当调用一个函数型值时,无法确定会执行哪段代码。
更严重的是,可以容易地创建出错误的对象来。例如,下面的对象并不符合整数集合的规范:
该对象在一半的时间内是空集,并且基于时间的不同而包含随机的整数值。可以给对象接口规定行为规范,从而可以被验证以防止错误对象的产生。
一个更加微妙的问题是:对象并不是非得要很好地封装其状态[27]。当对象的状态本身就是一个对象集合时,就会出现这个问题。此时,内部的对象就很可能会泄露到外面成为外部对象,此时抽象边界就被破坏了。这个问题激发了目前关于所有权类型的研究工作[6]。
还有一个特别困难的问题:对象的方法在其被执行时是可以重入的[46]。这会阻碍采用标准Hoare风格方法对程序进行验证。在该方法中,类会要求一个不变性,每个过程(方法)也被规定了一个前置条件和一个后置条件。问题是在方法体内调用任何其他方法时,都可能会反过来调用正在被验证的对象的其他方法。而这些调用很可能是在对象处于一个不一致的状态下被调用的。同时,调用还会更改对象状态,从而导致在验证原始方法时所作出的假设无效。
抽象数据类型基本上不会具有此类问题,因为它是基于层构建起来的,每个层会调用低一些的层次,但是低层不会调用高层。但是,并不是所有的系统都能够组织成这种风格。复杂的系统通常都会需要通知或者回调之类的东西,这就要求低层调用高层。如果回调被包含进ADTs中,就会导致验证问题。
面向对象编程的设计目标就是尽可能的灵活。同时,它似乎也被设计成尽可能地难以验证。
3.9 再谈点理论
对象接口和图三以及图六中的抽象数据类型签名之间有些有趣的联系。首先,接口中的方法比ADT签名中对应的操作少了一个参数。每个都缺少了rep参数。其次,ADT操作中的rep对应于对象接口每个方法中对于ISet的递归引用。这种相似性可以用如下类型函数来表达:
上面给出的类型可以用F重写为:
SetImp的原始定义和这个新定义同构。要想看到其间的联系,请注意在rep F(rep)中,这个具有域rep的函数类型提供了接口中所缺失的、出现在所有ADT操作中参数。这个带有rep的笛卡尔集提供了empty构造器。
上面的SetImp定义是把终余代数(final coalgebra) 转换为多态 演算[19]。唯一存在的问题是:因为union方法的存在,导致F并不是一个协变算子。该转换也对应于F的最大不动点,该不动点又对应于递归类型ISet。余代数和对象之间关系是一个非常活跃的研究主题[29].
3.10 小结
对象是一个导出了数据或者行为的过程接口的值。对象使用过程抽象(而非类型抽象)进行信息隐藏。对象和其类型通常都是递归的。对象是一种简单、强大的数据抽象方法。可以把对象理解为闭包、first-class模块、函数记录或者过程(processes)。对象也可以被用作过程抽象。
和抽象数据类型不同,许多人发现对象会带来很多烦扰。对象在本质上是高阶的。很难确定地知道对象中到底干了些什么事情:调用了哪个方法?到底是哪种对象?
另一方面,很多人认为对象在简单性和灵活性方面具有很强的吸引力。对象不需要复杂的类型系统。使用继承可以非常有效地对递归值进行扩充。
对象是自知的,从而只了解其自身这个事实也会导致一些混乱。比如,它妨碍了优化所需要的对多个表示的窥探。一种解决办法是在对象接口中暴漏表示细节,这会对灵活性造成限制。自知性带来的好处通常是微妙的,并且仅当系统变大、演化时才能被认识到。
最后,作为具有悠久、丰富历史的抽象这个概念中的一员,对象和ADTs一样,都根植于数学。
4. ADTs和OOP之间的关系
虽然面向对象编程和抽象数据类型是数据抽象的完全不同的两种形式,但它们之间还是存在不少的联系。许多简单的抽象可以用二者中任一种进行实现,虽然其结果有很大的不同。
4.1 静态类型 VS 动态类型
抽象数据类型和对象间主要的区别之一为:对象可以用来在动态类型语言中定义数据抽象。
对象不依赖于静态类型系统;只需要某种形式的first-class函数或者过程即可。
抽象数据类型需要静态类型系统来提供必需的类型抽象。在动态语言中采用对象而非用户自定义类型并非偶然。一般来讲,动态语言支持用于原生类型的内置抽象数据类型;此处的类型抽象是由运行时系统提供的。
类型系统仅仅提供了程序的结构化属性;并不能保证和规范的相容性。不过对于ADTs来说,类型系统可以保证只要ADT实现是正确的,那么所有基于它实现的程序都是正确的。类型系统可以防止外部客户程序对于实现细节的非法使用。纯粹的对象接口允许任意结构相容的实现,因此类型系统无法阻止对于不良实现的使用。
4.2 简单和复杂操作
对象和抽象数据类型间还有一点重叠:对于简单的数据抽象来说,二者的实现可以完全一样。简单和复杂数据抽象间的差别在于是否具有类似集合ADT中union操作那样的去窥探多个抽象值的表示的操作。
在本文中,如果一个操作窥探了多个表示,我就称其为“复杂的”。在有些文献中,复杂操作也被称为“二元的”。按照字面意思,二元操作就是那些可以接受两个抽象类型输入的操作。对于对象来说,二元方法就是除了那个被调用方法的抽象值外,还可以接受第二个抽象类型值的方法。根据这些定义,union总是二元的。
但是,并不是所有的二元方法都是复杂的。这依赖于操作的实现方法。可以在二元方法的实现中调用抽象参数的公有方法。这样做并不需要去窥探两个值的表示。图一和图二中的union操作都是简单的。但是图四中的union方法是复杂的。
纯粹的面向对象编程不支持复杂操作。因为这样做会导致使用instance-of之类的手段去窥探另外一个对象的表示。
任何只有简单操作的抽象数据类型都能够使用对象以更简单、更具扩展性的方式实现,且不会丧失任何功能性。
考虑具有如下类型的ADT实现,其中t不会出现在 或者 中:
其中,方法被划分成构造器,观测器和修改器。构造器ci创建类型t的值。观察器则接受一个类型t的值以及另外一个参数,并产生一些其他类型的值。修改器则接受一个类型为t的输入并输出一个类型为t的结果。这些模式是完备的,因为其中没有复杂操作。对于给定操作,除了t外如果不存在其他参数,那么 和 就是单位元。
创建一个表示对象接口的新类型I:
构造器定义一族函数,这些函数会调用一个用来创建对象的包装函数。该例子中使用的符号来自Pierce的书“Types and Programming Languages” [50]。
构造器首先打开ADT,构造一个合适的类型t的值,然后把它包装成一个对象。这个变换是ADTs [44]和对象[13]基本定义的一个直接推论。
反过来却并不一定为真。把实现一个接口的具有固定大小的任意面向对象类集合转换成ADT是可能的。一种简单的方法是:把对象当作ADT的表示类型,然后重写抽象。不过所得到的结果不再具有扩展性,所以这种转换会导致灵活性的丧失。
4.3 扩展性问题
在实现数据抽象时,有两个重要维度的扩展性。增加新的表示变种;增加新的操作。这个结论很自然地要求把行为组织成一个矩阵,其中把表示放在一个轴上,把观测/动作放在另外一个轴上。可以把扩展性视为对矩阵增加一行或者一列。
在20世纪70年代,也就是开始展开对于数据抽象的研究工作时,Reynolds发表了一篇预言性的论文,其中识别出了对象和抽象数据类型的关键区别[52, 23](虽然我觉得他没有意识到自己所描述的是对象)。Reynolds声明说,对于抽象数据类型可以容易地增加操作,而“过程数据值”(对象)则容易增加新的表示。从那时起,该二元特性至少被独立地发现过三次以上[18, 14, 33]。
这种二元特性对于编程有实际的影响[14]。抽象数据类型定义了把给定动作的所有行为收集在一起的操作。对象则以不同的方式来组织该矩阵,它把和给定表示有关的所用动作都收集起来。可以容易地向ADT中增加操作,向对象中增加新的表示。面向对象编程可以通过继承来增加新的操作[14],我们不对此进行详细的讨论。
有一个很有名的关于具有打印、求值以及其他一些动作的表达式的数据抽象的规范例子,Wadler后来基于这个例子,为该问题起了一个很动听的名字:“表达式问题”。
扩展性问题已经具有很多解决方法,不过它还在激发一些关于数据抽象扩展性研究的新思路[48, 15]。多方法(multi-method)是另外一种关于该问题的解法[11]。一些涉及独立扩展集成的复杂变种问题,仍然没有被完全解决。
4.4 Imperative状态和多态
本文中没有提及关于imperative状态和多态的问题,因为在很大程度上来说,它们和数据抽象是正交的。本文中讨论的整数集合可以被泛化成多态集合:set<t>。这种泛化既可以采用抽象数据类型来实现,也可以采用对象来实现。虽然目前在该方面有大量的研究在做,但是多态的问题并没有和数据抽象问题有多少交互。
抽象数据类型和对象都可以以纯functional或者imperative的风格进行定义。纯functional对象非常普通(虽然并不像其应该的那样普通)。状态问题在很大程度正交于语言的设计视角。不过,imperative编程对于程序验证有很大的影响。
5. 现实
实际编程语言中的现实情况并不是这么得纯粹和简单。许多静态类型面向对象语言都既支持纯粹的对象又支持某种形式的抽象数据类型。同时,还支持各种各样的混合情况。
5.1 Java中的面向对象编程
虽然Java并不是纯粹的面向对象语言,但是通过遵守如下的规则,也能够支持纯粹的面向对象编程风格
类只能作为构造器:类名只能出现在new关键字之后。
不用原生的相等性比较:程序中不允许使用原生的相等性(= =)比较。原生的相等性比较暴露了表示实现,且阻碍了对象间的模仿。
特别地,类不能用作成员、方法参数以及返回值的类型声明。只有接口可以被用作类型。同样,类也不能用来进行转型或者instanceof测试。
这通常被认为是好的面向对象编程风格。但是如果因为语言的强制,使得你必须遵循这种风格的话,会怎么样呢?Smalltalk语言比较接近这样要求。Smalltalk是动态类型的,因此类只能被当成构造器使用。虽然Smalltalk也支持instanceof操作,不过却很少被使用。
在Java中,破坏封装的另外一种方法是使用反射,虽然其在大多数情况下并不常用。在编写metatool(比如:调试器)以及程序生成器时,反射非常有用。不过现在,反射似乎正在更广泛的领域里面得到应用。关于反射对于数据抽象以及封装的影响,还需要进行更多的研究。
5.2 Java中的ADTs
要在静态类型面向对象语言中编写抽象数据类型,需要更多一些的工作。
把类当作类型来模仿类型抽象。类隐藏了其表示。面向对象语言并不总是支持其他语言中的积和式(sums of products)数据结构,不过可以对这种类型进行模仿:对于和(sum)类型的每一个变种,都用抽象类的一个子类与其对应。对于这些类型的模式匹配则可以通过instanceof和适当的转型进行实现。
一种直接的表示方法是用静态方法来表示ADT中所有操作,类只是存放了表示而已。
总结一下,当类名被用作类型时,它所表示的是抽象数据类型。
5.3 Haskell中的类型类
Haskll[30]中的类型类是一种用于参数化和扩展性的强大机制。类型类是一种代数签名,其把一组操作和一个或者多个类型名关联起来。下面所定义的关于整数集合的类型类和图六中的existential类型非常相似,但是在本例中使用了curried函数:
使用泛型操作,可以把函数写为:
对于test类型的修饰,表明了类型s是Set的任何实例。只要定义了正确的操作,任何类型都可以用来构建Set的实例。
实例定义能够把类型类和来自不同库的实际类型关联起来,且这三个部分均可以在无需预先了解其他部分知识的情况下独立定义。因此,类型类具有很好的灵活性和扩展性。
类型只能以一种方式成为一个类的实例。例如,无法把排序列表和一般列表同时定义为Set的不同实例。可以创建一个对已有类型进行标签化的新类型,从而突破这种限制,不过在标签化值时,会导致大量的繁琐、机械工作。
只要一个值具有必需的操作,类型类和对象接口都允许方法操作于其上,这一点二者是相似的。
另一方面,类型类和抽象数据类型一样是基于代数签名的。其主要区别在于类型类不强制对于表示的隐藏。因此,其可以在不需要ADTs的信息隐藏的情况下,提供关于类型签名的参数化抽象。从Haskell的成功这一点看来,封装在某种程度上也许是被过度强调了。
类型类不是自知性的。当函数被类型类修饰时,对于函数内部所有值,必须使用同一个类型实例。类型类不允许不同实例间的互操作。对于抽象和信息隐藏,Haskell还提供了其他的方法,比如:参数化。
另一方面,本文所讲的面向对象数据抽象同样可以在Haskell中实现。此外,可以用existential类型来把类型类操作和值结合起来去创建某种形式的对象[31]。这种实现中,类型类充当该值的方法表的作用。
5.4 Smalltalk
Smalltalk语言和系统具有许多有趣的特性。一个让人觉得好奇的事实是,Smalltalk中没有任何内置的控制流,且内置类型也非常之少。我们来看看Smalltalk中Boolean的实现,以了解其工作原理。
Smalltalk中有两个Boolean类,名字为True和False。它们实现了一个名为ifTrue:ifFalse的两参数方法:
Smalltalk中的方法名是一个关键字标签序列,其中每个关键字标识了一个参数。True方法体把给其第一个参数a发送value消息的结果作为返回值返回。False方法则返回关于第二个参数b的消息发送结果。
value方法是必需的,因为a和b所表示的只是具有一个哑参的thunks或者函数。把语句包装在方括号中,就可以创建一个thunk。可以通过向Boolean值发送两个thunks来实现条件控制。
Smalltalk中Boolean和条件控制的实现和 演算中的Church boolean完全一样[12]。由于对象是无类型语言中实现数据抽象的唯一方法,因此在Smalltalk中和无类型 演算中使用同样类型的形式就很正常了。还可以基于仍硬币的方法实现一个RandomBoolean类,或者实现一个用来跟踪共执行了多少计算的LoggingBoolean类。这些booleans可以用在任何使用标准boolean的地方,包括低层系统代码。
Smalltalk中的数不是Church数,虽然它们有些共同的特征。特别地,Smalltalk中的数和Church数采用同样的方法实现迭代。同样,Smalltalk中的集合采用和Church列表类似的方法实现了一个归约操作。
Smalltalk系统中确实包含了一个原生的整数类型,出于效率的考虑是采用ADT实现的。原生类型被包装成高层对象,相互之间通过精巧的接口进行通信以进行强制转换以及实现固定或者无限的精度。即使存在这些包装,我也认为Smalltalk对象并不是真正“纯粹的对象”,因为其实现依赖于原生的ADTs。也许,对象绝不是实现数的最好方法。我们需要更多的分析来确定其效率成本以及所获得的灵活性是否具有实际意义。
从该分析中能够得出的结论之一为:无类型 演算是第一个面向对象语言。
6. 讨论
学院派的计算机科学通常不接受除了抽象数据类型外还有另外一种形式的数据抽象这个事实。因此,教科书中会给出经典的堆栈ADT,然后说“对象是实现抽象数据类型的另外一种方法”。Sebesta研究了没有复杂方法的imperative数据抽象,采用堆栈作为例子,因此他看不到对象和ADTs间的差别就不足为奇了[54]。Tucker和Noonan同样用堆栈来说明数据抽象。但是他们同时提供了一个看起来像是从ML的case 语句翻译过来(使用Java中的instanceof)的类型检查器和求值器的Java实现。因此,对于说明面向对象编程来说是很糟糕的。
有些教科书做得要好一些。Louden [38]和Mitchell [43]是我发现的唯一两本描述对象和ADTs差别的书,Mitchell并没有进一步表明对象是一种不同种类的数据抽象。
对象的出现打断了学术界中一项长期的项目:基于ADTs创建数据的规范模型。一些广泛使用的语言把ADTs设计为其数据抽象的基本形式:ML、Ada以及Modula-2。由于面向对象编程变得越来越流行,这些语言中也因此已经融入或者尝试融入对象。
面向对象编程也受到了学术研究的广泛关注。不过,我认为学术社团在总体上并没有像工业界那样对对象那么的接受。我觉得有三个原因。首先,这里讨论的对象的概念基础并不是广为人知的。其次,学术界对于正确性的关注要高于灵活性。最后,编程语言研究者趋向于研究能够更自然地作为ADTs的数据抽象。
在选择用ADTs还是对象来实现一个给定的抽象时,涉及一些重要的设计决策。在Barbara Liskov关于CLU的历史介绍中[35]探讨了许多这方面的问题,并给出了一些她选择ADT风格的好的理由。例如,她说,“虽然程序开发支持系统必须要存储一个类型的多个实现…,在一个程序内允许多个实现看起来不是那么重要。”如果所涉及的都是些像堆栈或者整数集之类的类型,这样说是对的,但是当是些窗口、文件系统或者设备驱动之类的抽象时,允许同个系统内共存多种实现就是必需的。
Liskov同时还写道“CLU关注于数据对象的属性,并且鼓励通过考虑数据抽象属性的方式开发程序,从这一点来说它是一个面向对象语言”,她这样说对我来说有点遗憾。我相信CLU是面向对象语言并没有什么技术或者历史层面的意义。我也相信一些现代的面向对象语言确实受到了CLU的影响(尤其是表示的封装这点),但是这并不能把CLU变成一个面向对象语言。
致谢
有太多值得表示感谢的人(由于他们对于本文主题的探讨)。我感谢Olivier Danvy, Shriram Krishnamurthi, Doug Lea, Yannis Smaragdakis,Kasper Osterbye, 以及Gilad Bracha对于本文本身发表的意见。
7. 结论
对象和抽象数据类型(ADTs)是两种不同的数据抽象形式。它们都可以用来实现不具有复杂方法的简单抽象,但是对象具有扩展性而ADTs易于验证。在实现具有复杂操作(比如,比较或者组合操作)的抽象时,二者之间就会出现显著的区别。对象接口支持同样级别的灵活性,但是常常需要在接口简单性和效率之间做个权衡。抽象数据类型支持干净的接口、优化和验证,但是不允许对抽象进行混合和扩展。一些数学中的类型,包括数和集合,一般都包含处理多个抽象值的复杂操作,因此最好用ADTs来定义。更多的其他类型(包括文件。设备驱动、图形对象)一般不需要被优化的复杂操作,因此最好被实现为对象。
现代的面向对象语言都具有同时支持面向对象和ADT的能力,允许程序员对特定的情况选择ADT风格的实现。 在现代面向对象语言中,这个问题可以归结为:是否把类当作类型使用。在纯粹的面向对象风格中,类只能被用来构造对象,接口被用作类型。当把类用作类型时,程序员就隐式地选择了一种抽象数据类型的风格。这个决策会影响到程序随着时间的推移进行扩展和维护的容易程度以及对复杂操作进行优化的容易程度。对对象和ADTs之间根本区别的理解有助于做出明智的选择。