更新时间:2024-10-16 04:03
面向对象语言(Object-Oriented Language)是一类以对象作为基本程序结构单位的程序设计语言,指用于描述的设计是以对象为核心,而对象是程序运行时刻的基本成分。语言中提供了类、对象、封装、继承、多态等成分,有识认性、多态性、类别性和继承性四个主要特点。
一般认为,较典型的面向对象语言有:
simula 67,支持单继承和一定含义的多态和部分动态绑定;
Smalltalk支持单继承、多态和动态绑定;
C++,支持多继承、多态和部分动态绑定。
Java,支持单继承、多态和部分动态绑定。
五种语言涉及概念的含义虽然基本相同,但所用术语有别。
C#,也支持单继承,与Java和C++等有很多类似之处
基于类的面向对象语言是面向对象世界里的主流。它包括:
Simula,第一个面向对象语言
Smalltalk,第一个支持动态类型的语言
C++,它的大部分基于类的特性继承自Simula.等等等等。
与基于类的语言相对应的是基于对象的面向对象语言。这里“基于对象”的概念和把Visual Basic叫做基于对象的概念是不同的。这里的“基于对象”是指一个只以对象为中心,没有类的概念的语言,类似Python之类的语言。
先看一个类的定义:
classcell is
var contents: Integer :=0;
method get(): Integer is
return self.contents;
end;
method set(n:Integer) is
self.contents := n;
end;
end;
一个类是用来描述所有属于这个类的对象的共同结构的。这个cell类表示的对象拥有一个叫做contents的整数属性(attribute),这个属性被初始化成0。它还描述了两个操作contents的方法。Get和set. 这两个方法的内容都是很直观的。Self变量表示这个对象自己。
对象的动态语义可以这样理解:
一个对象在内部被表示为一个指向一组属性的指针。任何对这个对象的操作都会经过这个指针操作对象的属性和方法。而当对象被赋值或被当作参数传递的时候,所传递的只是指针,这样一来,同一组属性就可以被共享。
(注, 有些语言如C++,明确区分指向属性组的指针和属性组本身,而一些其它的语言则隐藏了这种区别)
对象可以用new从一个类中实例化。准确地说,new C分配了一组属性,并返回指向这组属性的指针。这组属性被赋予了初始值,并包括了类C所定义的方法的代码。
下面来考虑类型。对一个new C所生成的对象,把它的类型记为InstanceTypeOf(c). 一个例子是:
var myCell: InstanceTypeOf( cell ) := new cell;
这里,通过引入InstanceTypeOf(cell),开始把class和type区分开来了。也可以把cell本身当作是类型,但接下来,就会发现,那样做会导致混淆的。
方法解析(Method Lookup)
方法解析给出一个方法的调用o.m(……),一个由各个语言自己实现的叫做方法解析的过程负责找到正确的方法的代码。(者按:是不是想起了vtable了)。
直观地看,方法的代码可以被嵌入各个单个对象中,而且,对于许多面向对象语言,对属性和方法的相似的语法,也确实给人这种印象。
不过,考虑到节省空间,很少有语言这样实现。比较普遍的方法是,语言会生成许多method suite,而这些method suite可以被同一个类的对象们所共享。方法解析过程会延着对象内指向method suite的指针找到方法。
在考虑到继承的情况,方法解析会更加复杂化。Method suite也许会被组成一个树,而对一个方法的解析也许要查找一系列method suite. 而如果有多继承的话,method suite甚至可能组成有向图,或者是环。
在一些语言中,方法到底是嵌入对象中的,还是存在于method suite中这种细节,对程序员是无关紧要的。因为,所有能区分这两种模式的语言特性一般在基于类的面向对象语言中都不被支持。
比如说,方法并不能象属性一样从对象中取出来当作函数使用。方法也不能象属性一样在对象中被更新。(也就是说,更新了一个对象的方法,而同一个类的其它对象的该方法保持不变。)
子类和继承(Subclassing and Inheritance)
子类和继承子类和一般的类一样,也是用来描述对象的结构的。但是,它是通过继承其它类的结构来渐进式地实现这个目的。
父类的属性会被隐式地复制到子类,子类也可以添加新的属性。在一些语言中,子类甚至可以override父类的属性(通过更改属性的类型来实现)
父类中的方法可以被复制到子类,也可以被子类override.
一个子类的代码的示例如下:
subclassreCell of cell is
var backup: Integer := 0;
override set(n: Integer) is
self.backup := self.contents;
super.set(n);
end;
method restore()is
self.contents := self.backup;
end;
end;
对有subclass的方法解析,根据语言是静态类型还是动态类型而有所不同。
在静态类型的语言(如C++,Java)里,父类,子类的method suite的拓扑结构在编译时就已经确定,所以可以把父类的method suite里的方法合并到子类的method suite中去,方法解析时就不用再搜索这个method suite的树或图了。(按:C++的vtable就是这种方法)
而对于动态类型的语言,(也就是说,父子类的关系是在运行时决定的),method suite就无法合并了。所以,方法解析时,就要沿着这个动态生成的树或有向图搜索直到找到合适的方法。而如果语言支持多继承,这个搜索就更复杂了。
父类和子类
从上述的几个例子来看,似乎子类只是用来从父类借用一些定义,以避免重复。但是,当考虑到subsumption,事情就有些不同了。什么是Subsumption呢,请看下面这个例子:
var myCell: InstanceTypeOf(cell) := new cell;
var myReCell: InstanceTypeOf(reCell) := new reCell;
procedure f(x: InstanceTypeOf(cell)) is … end;
再看下面这段代码:
myCell := myReCell;
f(myReCell);
在这两行代码中,头一行把一个InstanceTypeOf(reCell)类型的变量赋值给一个InstanceTypeOf(cell)的变量。而第二行则用InstanceTypeOf(reCell)类型的变量作为参数传递给一个参数类型为InstanceTypeOf(cell)的函数。
这种用法在类似Pascal的语言中是不合法的。而在面向对象的语言中,依据以下的规则,它则是完全正确的用法。该规则通常被叫做subtype polimorphism,即子类型多态(按:其实subtyping应该是OO语言最区别于其它语言的地方了)
如果c’是c的子类,并且o’是c’的一个实例,那么o’也是c的一个实例。
更严格地说:
如果c’是c的子类,并且o’: InstanceTypeOf(c’),那么o’: InstanceTypeOf( c ).
仔细分析上面这条规则,可以在InstanceTypeOf的类型之间引入一个满足自反和传递性的子类型关系, 用<;:符号来表示。(按:自反就是说, 对任何a,a 关系 a都成立,比如说,数学里的相等关系就是自反的。而传递性是说,如果a 关系 b,b 关系c,就能推出a 关系c。大于,小于等关系都是具备传递性的)
那么上面这条规则可以被拆成两条规则:
1. 对任何a: A,如果 A <: B,那么 a: B.
2. InstanceTypeOf(c’) <: InstanceTypeOf(c) 当且仅当 c’是c的子类
第一条规则被叫做Subsumption. 它是判断子类型(注意,是subtype,不是subclass)的单独标准。
第二条规则可以叫做subclassing-is-subtyping (子类就是子类型,绕嘴吧)
一般来说,继承都是和subclassing相关的,所以这条规则也可以叫做:inheritance-is-subtyping (继承就是子类型)
所有的面向对象语言都支持subsumption (可以说,没有subsumption,就不成为面向对象)。
大部分的基于类的面向对象语言也并不区分subclassing和subtyping. 但是,一些最新的面向对象语言则采取了把subtyping和subclassing分开的方法。也就是说,A是B的子类,但A类的对象却不可以当作B类的对象来使用。(按:有点象C++里的私有继承,但内容比它丰富)
好吧,关于区分subclassing和subtyping,后面会讲到。
下面,重新回头来看看这个procedure f. 在subsumption的情况下,下面这个代码的动态语义是什么呢。
Procedure f(x: InstanceTypeOf(cell)) is
x.set(3);
end;
f(myReCell);
当myReCell被当作InstanceTypeOf(cell)的对象传入f的时候,x.set(3)究竟是调用哪一个版本的set方法呢。是定义在cell中的那个set还是定义在reCell中的那个呢。
这时,有两种选择,
1. Static dispatch (按照编译时的类型来决定)
2. Dynamic dispatch (按照对象运行时真正类型来决定)
(按,熟悉C++的朋友们一定微笑了,这再简单不过了。)
static dispatch没什么可说的。
dynamic dispatch却有一个有趣的属性。那就是,subsumption一定不能影响对象的状态。如果在subsumption的时候,改变了这个对象的状态,比如象C++中的对象切片,那么动态解析的方法就可能会失败。
好在,这个属性无论对语义,还是对效率,都是很有好处的。
(按,C++中的object slicing会把新的对象的vptr初始化成它自己类型的vtable指针,所以不存在动态解析的问题。但实际上,对象切片根本不能叫做subsumption。
具体语言实现中,如C++,虽然subsumption不会改变对象内部的状态,但指针的值却是可能会变化的。这也是一个让人讨厌的东西,但 C++ vtable的方案却只能这样。有一种变种的vtable方法,可以避免指针的变化,也更高效。会在另外的文章中阐述这种方法。)
关于类型信息
虽然subsumption并不改变对象的状态,在一些语言里(如Java),它甚至没有任何运行时开销。但是,它却使丢掉了一些静态的类型信息。
比如说,有一个类型InstanceTypeOf(Object),而Object类里没有定义任何属性和方法。又有一个类MyObject,它继承自Object。那么当把MyObject的对象当作InstanceTypeOf(Object)类型来处理的时候,就得到了一个什么东西也没有的没用的空对象。
当然,如果考虑一个不那么极端的情况,比如说,Object类里面定义了一个方法f,而MyObject对方法f做了重载,那么,通过dynamic dispatch,还是可以间接地操作MyObject中的属性和方法的。这也是面向对象设计和编程的典型方法。
从一个purist的角度看, dynamic dispatch是应该用来操作已经被subsumption忘掉的属性和方法的东西。它优雅,安全,所有的荣耀都归于dynamic dispatch。
不过,让purist们失望的是,大部分语言还是提供了一些在运行时检查对象类型,并从而操作被subsumption遗忘的属性和方法。这种方法一般被叫做RTTI(Run Time Type Identification)。如C++中的dynamic_cast,或Java中的instanceof.
实事求是地说,RTTI是有用的。但因为一些理论上以及方法论上的原因,它被认为是破坏了面向对象的纯洁性。
首先,它破坏了抽象,使一些本来不应该被使用的方法和属性被不正确地使用。
其次,因为运行时类型的不确定性,它有效地把程序变得更脆弱。
第三点,也许是最重要的一点,它使程序缺乏扩展性。当加入了一个新的类型时,也许需要仔细阅读dynamic_cast或instanceof的代码,必要时改动它们,以保证这个新的类型的加入不会导致问题。
很多人一提到RTTI,总是侧重于它的运行时的开销。但是,相比于方法论上的缺点,这点运行时的开销真是无足轻重的。
而在purist的框架中(按,吸一口气,目视远方,做深沉状),新的子类的加入并不需要改动已有的代码。
这是一个非常好的优点,尤其是当并不拥有全部源代码时。
总的来说,虽然RTTI (也叫type case)似乎是不可避免的一种特性,但因为它的方法论上的一些缺点,它必须被非常谨慎的使用。面向对象语言的类型系统中的很多东西就是产生于避免RTTI的各种努力。
比如有些复杂的类型系统中可以在参数和返回值上使用Self类型来避免RTTI. 这点后面会介绍到。
协变,反协变和压根儿不变 (Covarance,Contravariance and Invariance)
下面的几个小节里,来介绍一种避免RTTI的类型技术。在此之前,先来介绍“协变”,“反协变”和“压根儿不变”的概念。
协变
首先,来看一个Pair类型:A*B
这个类型支持一个getA()的操作以返回这个Pair中的A元素。
给定一个A’ <: A,那么,可以说A’*B <: A*B。
为什么呢,可以用Subsumption的属性加以证明:
假设有一个A’*B类型的对象a’*b,这里,a’:A’,b:B,a’*b <: A’*B
那么,因为,A’ <: A, 从subsumption,可以知道a’:A,getA():A 所以, a’*b<: A*B
这样,就定义A*B这个类型对于A是协变的。
同理,也可以证明A*B对于B也是协变的。
正规一点说,Covariance是这样定义的:
给定L(T),这里,类型L是通过类型T组合成的。那么,
如果 T1 <: T2 能够推出 L(T1) <: L(T2),那么就说L是对T协变的。
反协变
请看一个函数:A f(B b); (用functional language 的定义也许更简洁, 即f: B->A)
那么,给定一个B’ <: B,在B->A 和 B’->A之间有什么样的subtype关系呢。
可以证明,B->A <: B’->A。
基于篇幅,不再做推导。
所以,函数的参数类型是反协变的。
Contravariance的正规点的定义是这样的:
给定L(T),这里,类型L是通过类型T组合成的。那么,
如果 T1 <: T2 能够推出 L(T2) <: L(T1),那么就说L是对T反协变的。
同样,可以证明,函数的返回类型是协变的。
压根儿不变
那么再考虑函数g: A->A
这里,A既出现到参数的位置,又出现到返回的位置,可以证明,它既不是协变的,也不是反协变的。
对于这种既不是协变的,也不是反协变的情况,称之为Invariance
值得注意的是,对于第一个例子中的Pair类型,如果支持setA(A),那么,Pair就变成Invariance了。
方法特化 (Method Specialization)
在前面对subclass的讨论中,采取了一种最简单的override的规则,那就是,overriding的方法必须和overriden的方法有相同的signature.
但是,从类型安全的角度来说,这并不是必须的。
这样,只要A <: A’,B’ <: B,下面的代码就是合法的:
classc is
method m(x:A):B is … end;
method m1(x1:A1):B1 is … end;
end;
subclassc’ of c is.
传统的基于类的面向对象语言的一个主要特点就是inheritance,subclassing和subtyping之间的密不可分的联系。很多的面向对象语言的语法,概念,就是从这三者而来的。比如说,通过subclassing,可以继承父类的一些方法,而同时又可以在子类中改写父类的方法。这个改写过的方法,通过subtyping、subsumption,又可以从一个类型是父类的对象去调用。
但是,inheritance,subclassing,subtyping这三者并不是永远和睦相处的。在一些场合,这三者之间的纠缠不清会妨碍到通过继承或泛型得到的代码重用。因此,人们开始注意到把这三者分离开来的可能性。区分subclassing和subtyping已经很常见了。而其它的一些方法还处于研究的阶段。
在早期的面向对象语言中(如Simula),类型的定义是和方法的实现是混合在一起的。这种方式违反了已经被广泛认识到的把实现和规范(Specification) 分离的原则。这种分离得原则在开发是团队进行的时候尤其显得重要。
更一些的语言,通过引入不依赖于实现的对象类型来区分实现和规范。Modula-3以及其它如Java等的支持class和interface的语言都是采用的这种技术。
开始引入InstanceTypeOf(cell)时,它代表的概念相当有限。看上去,它似乎只表示用new cell生成的对象的类型,于是,并不能用它来表示从其它类new出来的对象。但后来,当引入了subclassing,method overriding,subsumption和dynamic dispatch之后,事情变得不那么简单了。的InstanceTypeOf(cell)已经可以用来表示从cell的子类new出来的对象,这些对象可以包括不是cell类定义的属性和方法。
如此看来,让InstanceTypeOf(cell)依赖于一个具体的类似乎是不合理的。实际上,一个InstanceTypeOf(cell)类型的对象不一定会跟classcell扯上任何关系。
它和cell类的共同之处只是它具有了所有cell类定义的方法的签名(signature).
基于这种考虑,可以引入对象类型的语法:
针对cell类和reCell类的定义:
class cell is
var contents: Integer :=0;
method get(): Integer is
return self.contents;
end;
method set(n:Integer) is
self.contents := n;
end;
end;
subclass reCell of cell is
var backup: Integer := 0;
override set(n: Integer) is
self.backup := self.contents;
super.set(n);
end;
method restore() is
self.contents := self.backup;
end;
end;
可以给出这样的对象类型定义:
ObjectType Cell is
var contents: Integer;
method get(): Integer;
method set(n:Integer);
end;
ObjectType ReCell is
var contents: Integer;
var backup: Integer;
method get(): Integer
method set(n: Integer);
method restore();
end;
这两个类型的定义包括了所有cell类和reCell类定义的属性和方法的类型,但却并不包括实现。这样,它们就可以被当作与实现细节无关的的接口以实现规范和实现的分离。两个完全无关的类c和c’,可以具有相同的类型Cell,而Cell类型的使用者不必关心它使用的是c类还是c’类。
注意,还可以加入额外的类似继承的语法来避免在ReCell里重写Cell里的方法签名。但那只是小节罢了。
在上面的讨论中,subtype的关系是建立在subclass关系的基础上的。但如果想要让type独立于class,那么也需要定义独立于subclass的subtype.
在定义subtype时,又面临着几种选择:subtype是由类型的组成结构决定的呢,还是由名字决定呢。
由类型的组成结构决定的subtype是这样的:如果类型一具有了类型二的所有需要具备的属性和方法,就说类型一是类型二的subtype.
由类型名字决定的subtype是这样的:只有当类型一具有了类型二的所有需要具备的属性和方法, 并且类型一被明确声明为类型二的subtype时,才认可这种关系。
而如果的选择是一,那么那些属性和方法是subtype所必需具备的呢,哪些是可有可无的呢
由组成结构决定的subtype能够在分布式环境和object persistence系统下进行类型匹配。缺点是,如果两个类型碰巧具有了相同的结构,但实际上却风马牛不相及,那就会造成错误。不过,这种错误是可以用一些技术来避免的。
相比之下,基于名字的subtype不容易精确定义,而且也不支持基于结构的subtype.
可以先定义一个简单的基于结构的subtype关系:
对两个类型O和O’,
O’ <: O 当 O’ 具有所有O类型的成员。O’可以有多于O的成员。
例如:ReCell <: Cell.
为了简明,这个定义没有考虑到方法的特化。
另外,当类型定义有递归存在的时候(类似于链表的定义),对subtype的定义需要额外地加小心。
因为不关心成员的顺序,这种subtype的定义自动地就支持多重的subtype.
比如说:
ObjectType ReInteger is
var contents: Integer;
var backup: Integer;
method restore();
end;
那么,就有如下的subtype的关系:
ReCell <: Cell
ReCell <: ReInteger
(按,例子中没有考虑到象interface不能包含数据域这样的细节。实际上,如果支持对数据域的override,而不支持shadowing -- 作者的基于结构的subtype语义确实隐含着这样的逻辑― 那么,interface里包含不包含数据域就无关紧要了,因为令人头疼的名字冲突问题已经不存在了)
从这个定义,可以得出:
如果c’是c的子类, 那么ObjectTypeOf(c’) <: ObjectTypeOf(c)
注意,这个定义的逆命题并不成立,也就是说:
即使c’和c之间没有subclass的关系,只要它们所定义的成员符合了subtype的定义,ObjectTypeOf(c’) <: ObjectTypeOf(c)仍然成立。
回过头再看看在前面的subclass-is-subtyping:
InstanceTypeOf(c’) <: InstanceTypeOf(c) 当且仅当 c’是c的子类在那个定义中,只有当c’是c的子类时,ObjectTypeOf(c’) <: ObjectTypeOf(c)才能成立。
相比之下,已经部分地把subclassing和subtyping分离开了。Subclassing仍然是subtyping,但subtyping不再一定要求是subclassing了。
把这种性质叫做“subclassing-implies-subtyping”而不是“subclass-is-subtyping”了。
泛型 (Type Parameters)
一般意义上来说,泛型是一种把相同的代码重用在不同的类型上的技术。它作为一个相对独立于其它面向对象特性的技术,在面向对象语言里已经变得越来越普遍了。这里之所以讨论泛型,一是因为泛型这种技术本身就很让人感兴趣,另外,也是因为泛型是一个被用来对付二元方法问题(binary method problem) 的主要工具。
和subtyping共同使用,泛型可以用来解决一些在方法特化等场合由反协变带来的类型系统的困难。考虑这样一个例子:
有Person和Vegitarian两种类型,同时,有Vegitable和Food两种类型。而且,Vegitable <: Food
ObjectType Person is
…
method eat(food: Food);
end;
ObjectType Vegetarian is
…
method eat(food: Vegitable);
end;
这里,从常识,知道一个Vegitarian是一个人。所以,希望可以有Vegetarian <: Person.
不幸的是,因为参数是反协变的,如果错误地认为Vegetarian <: Person,根据subtype的subsumption原则,一个Vegetarian的对象就可以被当作Person来用。于是一个Vegetarian就可以错误地吃起肉来。
使用泛型技术,引入Type Operator (也就是,从一个类型导出另一个类型,概念上类似于对类型的函数)。
ObjectOperator PersonEating[F<:Food] is
…
method eat(food: F);
end;
ObjectOperator VegetarianEating[F<: Vegetable] is
…
method eat(food: F);
end;
这里使用的技术被称作Bounded Type Parameterization. (Trelli/Owl,Sather,Eiffel,PolyTOIL,Raptide以及Generic Java都支持Bounded Type Parameterization. 其它的语言,如C++,只支持简单的没有类型约束的泛型)
F是一个类型参数,它可以被实例化成一个具体的类型。类似于变量的类型定义,一个bound如F<:Vegitable限制了F只能被Vegitable及其子类型所实例化。所以,VegitarianEating[Vegitable],VegitarianEating[Carrot]都是合法的类型。而VegitarianEating[Beef]就不是一个合法的类型。类型VegitarianEating[Vegitable]是VegitarianEating的一个实例,同时它等价于类型Vegitarian. (用的是基于结构的subtype)
于是,有:
对任意F<:Vegitable,VegitarianEating[F] <: PersonEating[F]
对于原来的Vegitarian类型,有:
Vegetarian = VegetarianEating[Vegetable] <: PersonEating[Vegitable]
这种关系,正确地表达了“一个素食者是一个吃蔬菜的人”的概念。
除了Bounded Type Parameterization之外,还有一种类似的方法也可以解决这个素食者的问题。这种方法被叫做:Bounded Abstract Type请看这个定义:
ObjectType Person is
Type F<: Food;
…
var lunch: F;
method eat(food: F);
end;
ObjectType Vegetarian is
Type F<: Vegitable;
…
var lunch: F;
method eat(food: F);
end;
这里,F<:Food的意思是,给定一个Person,知道他能吃某种Food,但不知道具体是哪一种。这个lunch的属性提供这个Person所吃的Food.
在创建Person对象时,可以先选定一个Food的subtype,比如说,F=Dessert. 然后,用一个Dessert类型的变量赋给属性lunch. 最后再实现一个eat(food:Dessert)的方法。
这样,Vegetarian <: Person是安全的了。当把一个Vegetarian当作一个Person处理时,这个Vegitarian可以安全地吃他自带的午餐,即使不知道他吃的是肉还是菜。
这种方法的局限在于,Person,Vegitarian只能吃他们自带的午餐。不能让他们吃买来的午餐。