译者序 不要将本文简单地视为是对C++特征的一个介绍。它的意义在于,一方面介绍了编程风格的演变,以及这种演变背后的动机 。另一个方面,它特别澄清了基于对象的(OB)和面向对象(OO)的异同,这是具有很大意义的。我们可以看到, 不管是OB还是OO,都不过是一种程序的组织形式。 这在很大程序上指出了OO着眼于解决什么样的问题 (程序如何组织才能有弹性,容易重用和理解),而不解决什么问题(数据结构的设计,算法的设计)等等。
摘要 “面向对象编程”和“数据抽象”已经成为常用的编程术语,然而,很少有人能够就它们的含义取得一致的认识;本文以Ada,C++,Module 2,Simula和Smalltalk等语言为背景对此给出一个非正式的定义。基本的想法是将“支持数据抽象”等同于定义和使用新数据类型的能力,而将“支持面向对象编程”等同于对类层次的表达能力。同时,还讨论了通用编程语言为支持此种编程风格而必须提供的机制。文中虽然采用C++来表述问题,但其讨论的范围并不仅限于这个语言。
1 介绍 并不是所有的语言都是面向对象的。一般认为,APL,Ada,Clu,C++,LOOPS和Smalltalk是面向对象的,我也曾经听说过关于使用C, Pascal,Module-2,和CHILL进行面向对象设计的讨论。那么是否可以尝试使用Fortran和Cobol来进行面向对象设计呢?我认为那也一定是可行的。在很多圈子里,“面向对象”已经成为“优秀”的高科技代名词,在商业出版领域可以看到有以下的三段论: Ada是优秀的 面向对象是优秀的 所以Ada是面向对象的 本文从通用编程语言的角度出发陈述了“面向对象”技术的概貌: 第2节比较了数据抽象和面向对象之间的异同,也将它们和其他的编程风格做了区分;同时,指出了为了支持不同的编程风格所需的重要机制。 第3节陈述了为高效地支持数据抽象所需的语言机制。 第4节讨论了支持面向对象所需的设施。 第5节陈述了传统硬件体系结构和操作系统对于数据抽象和面向对象编程施加的限制。
文中例子程序使用C++来书写,这部分是出于介绍C++的目的,部分是因为C++是少数几个同时支持数据抽象,面向对象程序设计和传统编程风格的语言。本文不讨论为支持特定高层语言特性而涉及的并发性和特殊硬件支持。
2.编程风格(Programming Paradigms) 面向对象编程是一种用来针对一类问题编写优质代码的编程技术。一个语言称为是“面向对象”的如果它支持(Support)面向对象风格的编程。 在这里存在一个重要的区别。一个语言称为是“支持”某种风格的编程技术的,如果它提供了便于实施(方便地,安全地和高效地)该种风格编程的手段;反之,如果需要使用额外的技能和手段来获得基于某种风格的编码,则这个语言就是不“支持”该种编程风格的,我们只能说这个语言“使能”(Enable)了某种编程风格。举例来说,人们可以使用Fortran编写结构化程序,使用C语言编写类型安全的程序,在Module-2中使用数据抽象技术,但是,这些任务都具有不必要的困难性,因为这些语言都不“支持”那些编程风格。 对于某种编程风格的支持不仅意味着语言提供明确的并且可以直接使用的编程手段,而且还意味着在编译时间和运行时间提供某种检查,以防止代码无意中偏离了该种风格。类型检查是一个特别明显的例子,二义性检查和运行时间检查也可以扩充语言支持特定编程风格的能力。同时,象标准库和编程环境等等都可以增强这种支持。 并不一定说一个语言如果支持了某种特性,则它就一定优于其他没有支持该特性的语言。在这里存在着太多的反例。重要的不是一个语言具有多少特性,而是它具有的特性是否能够在特定的领域内足以支持特定的编程风格。
1.所有的特性必须是清晰,优雅地集成进语言的。 2.通过组合使用这些特性必须足以获得解决方案,而不再需要使用其他特性。 3.假冒的和“特殊目的”的特性必须尽可能的少。 4.所有的特性都不能在那些不使用它们的程序中强加上过多的开销。 5.用户只需要了解那些在程序中被明确使用的特性所构成的语言子集就可以编写程序。
最后两点可以概括为“程序员不会被他们不了解的东西伤害”。如果对于一个特性是否有用存在任何疑问,则该特性就最好被抛弃。在语言中加上一个特性要远比从中或者从其文献中去掉一个容易得多。 以下将罗列一些编程风格以及支持它们的核心语言机制,但对此并不打算讨论得过于深入和繁琐。
2.1 过程化编程 最初的(可能也是目前最常用的)编程风格是: 决定需要那些过程 使用能够得到的最好的算法 设计的重点在于处理过程和执行运算的算法,语言为此提供了将参数传递给函数以及从函数中返回值的机制。和这种思维方式相关的文献集中讨论了传参的不同方式,区分不同参数的方式,以及各种不同的过程(过程,函数,宏)等等。Fortran是最早的过程语言,Algol60,Algol68,C和Pascal是一些后继的过程语言。 平方根函数是个典型的例子,它简单地产生传入参数的平方根。为此,该函数执行一个简单的数学运算: double sqrt(double arg) { //the code for calculting a square root }
void some_function() { Double root2 = sqrt(2); } 从程序结构的角度来看,函数理清了算法之间的杂乱关系。
2.2 数据隐藏 随着时间的推移,程序设计的重点从重于过程设计转向重于对数据的组织,这反映了程序规模的增长。数据和直接操作数据的一集函数合称为一个模块。程序设计的风格变为: 决定需要那些模块 分解程序,使得数据隐藏在不同的模块之中 这种风格被称为“数据隐藏规则”。而在那些不必将数据和与它相关的过程绑定到一起的场合可以只使用过程程序设计风格。特别地,那些用来设计“好的过程”的技术现在可以应用到模块之内的每个过程之上。最常见的例子是定义一个堆栈模块,设计时有以下问题需要解决: 1.为堆栈模块提供一个用户接口(例如,函数 push()和pop() ) 2.保证堆栈的表示(例如,一个元素的阵列)只能通过模块的接口来访问 3.保证堆栈在它第一次被访问之前执行过初始化
以下是一个不甚严格的堆栈模块的外部接口: //declaration of the interface of module stack of charater char pop(); void push(char); const stack_size = 100;
假定这个外部定义保存在stack.h文件之中,而其模块内部表示如下: #include "stack.h" static char v[stack_size]; static char* p = v; char pop() { //Check for underflow and pop }
void push(char c) { //check for overflow and push }
要将堆栈的表示修改为链表是很方便的,用户不能访问堆栈的内部表示(因为v 和p 已经被声明为static的,因此只能在声明它们的模块内部引用它们)。可以象这样使用这个堆栈模块: #include "stack.h" void some_function() { char c = pop(push('c')); if( c != 'c' ) error( "impossible" ); }
Pascal没有提供令人满意的设施来实施这种绑定。将一个名字和程序的其它部分隔离开来的唯一办法是使它局部于一个过程之内,这导致了奇怪的过程嵌套以及对于全局数据的过度依赖。 C语言的表现略好一些,在上面所述的例子之中,可以将数据和与它相关的过程保存在同一个文件之中以形成模块,由此程序员可以控制哪些名字是全局可见的(被声明为static的名字只在本模块内可见)。由此,C语言可以在一定程度上支持模块化;然而C缺乏使用这种机制的一般性框架,同时,通过static控制名字访问显得过于低级。 Pascal的一个后继语言,Module-2,走得更远一些。它形式化了模块这个概念,提供了一些基本的语言构成,如良定义的模块声明,对于名字范围的明确控制(import,export), 模块的初始化机制,以及一组公认的对这些机制的使用方式。 C和Module-2在这个领域内的区别可以概括为,C只是“使能”了将程序分解为模块,而Module-2则“支持”这种技术。
2.3数据抽象 模块化编程发展成为将某种类型的数据集中置于一个类型管理模块的控制之下的编程风格。如果有人需要两个stack,则他可能设计出一个具有如下接口的堆栈管理模块:
class stack_id; //stack_id is a type //no details about stacks or stack_ids are known here stack_id create_stack(int size); //make a stack and return its identifier destroy_stack(stack_id); void push( stack_id,char) char pop(stack_id) 相对于以往那些无结构的混乱风格,这当然是一次重大的改进。然而,通过这种方式实现的“类型”又明显地和语言的内建类型有区别。每一个类型管理模块都必须分别定义自己的机制来生成自己的“变量”;这里没有什么明确的方法可以赋予变量以标识符,也不可能让编译器和编程环境了解变量的名字。同时,没有办法让这些变量服从常用的变量作用域规则和参数传递规则。 通过模块机制建立起来的类型在很多重要的方面都和内建类型存在区别,同时,它获得的支持也远比内建类型获得要低级得多。例如: void f() { stack_id s1; stack_id s2; s1 = create_stack(200); //Oops: forgot to create s2
shar c1 = pop(s1,push(s1,'a')); if( c1!='c') error("impossible" ); char c2 = pop(s2,push(s2,'a')) if( c2!= 'c') error( "impossible");
destroy(s2); //Oops,forgot to destroy s1 }
换言之,支持数据隐藏风格的模块概念只是使能了数据抽象,但它不支持这种风格。
Ada, Clu和C++等语言通过允许用户定义和内建类型行为相似的“类型”来解决这个问题。这种“类型”通常称为“抽象数据类型”。于是,编程风格变为: 决定需要那些类型 为每一个类型实现一组完整的操作 而在那些不需要为一个类型生成多个对象的场合可以只使用数据隐藏技术。有理数和复数等算术类型是抽象数据类型的常见例子:
class complex{ doube re, im; public: complex(double r, double i) { re =r ;im = i; } complex( double r) { re=r; im = 0; } //float->complex conversion
friend complex operator+(complex,complex); friend compelx operator-(complex,complex); //binary minus firend complex opeator-(complex);//unary minus friend compelx operator*(complex,complex); friend complex operator/(complex,complex); //... }
类complex(用户自定义类型)的声明确定了一个复数的“表示”和一组和它相关的操作。“表示”是私有的,就是说,只能通过在complex类中声明的函数才能访问re和im 。函数可以如下定义: complex operator+(complex a1, complex a2) { return complex( a1.re + a2.re, a1.im + a2.im ); } 可以象这样使用: complex a = 2.3; complex b = 1/a; complex c = a-b*complex(1,2.3); //... c = -(a/b)+2; 大多数(但不是全部)模块可以使用“类型”来获得更好的表达。对于那些更加适合表达成为“模块”的概念,程序员可以定义一个只生成单个对象的类型来作为替代。当然,语言也可以在提供自定义类型机制之外再提供一个独立的模块机制。 2.4数据抽象的问题 一个抽象数据类型定义了一类黑盒,一经定义完成,则它和程序的其他部分不再发生交互。除非修改它的定义,否则很难将它用于新的用途。考虑为一个图形系统定义一个类型shape。假定当前系统支持圆,三角形和正方形,同时还有其他的一些相关类: class point { /*...*/ }; class color{ /*...*/ }; shape类可能定义成这样: enum kind{ circle,triangle,squre}; class shape{ point center; color col; kind k; //redivsentation of shape public: point where() { return center; } void move(point to) { center = to; draw(); } void draw(); void rotate(int); //more operation }; 为了允许draw,rotate知道当前处理的是何种形状,其中的类型域"k"必须存在(在类Pascal语言中,可使用带标记k的可变记录 ),函数draw可以定义成这样: void shape::draw() { switch( k ) { case circle: //draw a circle; break; case triangle: //draw a triangle; break; case square: //draw a square; break; } } 这是混乱的。象draw这样的函数必须了解当前存在的各种“形状”,因此每当系统新增一个新的“形状”,这些函数就必须被改写。为了定义一个新的“形状”就必须检查,同时也可能修改shape的所有操作。所以除非可以修改源码,否则将不可能在系统中增加新的“形状”。而既然增加一个新的“形状”将导致修改shape所有重要的操作,这就意味着编程需要更高的技巧同时也可能为现存的其他“形状”引入bug。同时,建立在一般类型shape之上的应用框架(或者其中的一部分)可能要求每一个具体的“形状”必须具有定长的表示,这会为如何表示具体的形状带来很大的限制。 2.5 面向对象编程 问题在于没有将各种形状的一般性属性(具有颜色,可以绘画)和特定形状的专有属性(圆具有半径,使用画圆函数执行绘画)区分开来。对这种区分的表达和利用形成了面向对象的编程。只有可以用来直接表达这种区分的语言才是支持面向对象的,其他语言不是。 Simula的继承机制提供了一个解决方案。首先,指定一个类来定义形状的一般性的属性: class shape{ point center; color col; public: point where(){ return center; } void move(point to){ center = to; draw() } virtual void draw(); virtual void rotate(int); //....... } 调用接口可以确定但实现尚不能确定的函数都被标记成为“virtual”(在Simula和C++中意味着可以被某个子类重新定义)。给定了这些定义以后,我们可以写出操作形状的一般性函数: void rotate_all(shape* v, int size, int angle) //rotate all members of vector "v" of size "size" "angle" degrees { for( int i = 0; i < size; i++) v[i].rotate(angle); } 为了定义了一个特定的形状,我们必须声明这是一个“形状”,同时指定它所有的属性(包括虚函数) class circle : public shape{ int radius; public: void draw(){ /*...*/ } void rotate(int){} //yes, the null function } 在C++中,类circle称为从类shape中派生,而类shape则称为是类circle的基类。也可以使用子类(subclass)和超类(superclass)这两个术语。 编程的风格变为: 决定需要那些类 为每一个类提供完整的操作 使用继承明确地获得一般性 而在不需要表达一般性的场合可以只使用数据抽象。通过继承和虚函数可以发掘出的类型之间的共性的多少是衡量面向对象编程技术是否适用于特定应用领域的核心标准。某些领域,例如交互式图形系统,特别适合应用面向对象技术;而另外一些领域,例如经典的算术类型和基于它们的运算系统,则看来使用数据抽象就足够了,面向对象技术在这里不一定是必要的。 在一个系统中的不同类型之间发掘一般性不是一个容易的过程,可以发掘出的一般性的多少取决于系统的设计方法。设计时必须积极地寻找一般性,一方面应当基于已经存在的类型构造新的类型,另一方面可以通过察看不同类型之间表现出的相似性决定是否可以归纳出一个基类。 文献 Nygarrd[13]和Kerr[9]尝试了不基于特定语言解释面向对象编程;文献Cargill[4]是对面向对象编程的案例研究。 3.对数据抽象的支持 为类型定义一组操作同时限制只允许这组操作访问类型的数据是对数据抽象编程的基本支持。随后,程序员很快发现需要进一步的语言机制来方便定义和使用这些新类型。操作符重载是一个很好的例子。
3.1初始化和清除 一旦类型的表示被隐藏了起来,则必须提供一个机制来执行对变量的初始化。一个简单的方案是要求用户在使用一个变量之前先调用一个特定的函数来初始化它。例如: class vector{ int sz; int* v; public: void init(int size); // call init to initialize sz and v before the first use of a //vector //... }
vector v; //don't use v here v.init(10); //use v here 这容易导致错误并且不够优雅。好一点的方案允许类型的设计者为初始化提供一个特别的函数;给定了这个函数,分配和初始化一个变量变成了同一个操作。这个特定的函数经常被称为构造函数。在某些场合初始化一个对象可能并不是十分简单的,这样就常常需要一个对等的操作来在对象被最后一次使用之后执行清除。在C++中,这样的一个清除函数称为析构函数。考虑一个vector类型: class vector{ int sz; int* v; public: vector(int); //constructor ~vector(); //destructor int& operator[](int index); }; vector的构造函数可以定义为分配空间,象这样: vector::vector(int s) { if( s<=0 ) error("bad vector size' ); sz = s; v = new int[s]; //allocate an array of "s" integers } vector的析构函数释放这部分空间 vector::~vector() { (译注:此处最好是delete []v;) delete v; //deallocate the memory pointed to by v } C++不支持垃圾收集,这种允许一个类型自己管理存储空间而不需要用户来干预的技术是一个补偿。存储管理是构造/析构函数经常执行的操作,但是它们也常常用来执行与此无关的事情。
3.2赋值和初始化 对于很多类型而言,控制其初始化和清除过程就已经足够了,但并不是所有的类型都如此。有时候控制拷贝过程也是十分必要的,考虑vector: vector v1[100]; vector v2 = v1; //make a new vector v2 initialized to v1 v1 = v2; //assign v2 to v1
在这里必须有机制来定义v2初始化和对v1赋值的含义,当然也可以选择提供机制来禁止这种拷贝。理想的情况是,这两种机制都存在。例如: class vector{ int *v; int sz; public: //.... void operator=(vector&); //assignment vector(vector&); //initialization }; 给出了用户定义的操作来解释vector的赋值和初始化。赋值可以象这样定义: ( 译注:由于在上文class vector中operator=(vector&a)声明为void类型,所以这里的定义最好为 void vector::operator(vector&a) ) vector::operator=(vector&a) //check size and copy elements { if( sz != a.sz ) error( "bad vector size for = " ); for( int = 0; i<sz;i ++) v[i] = a.v[i]; } 虽然赋值操作可以依赖于一个“旧的 ”的vector对象,但初始化操作就必须有所不同,例如: vector::vector(vector& a) // initialize a vector from another vector { sz = a.sz; v = new int[sz]; for( int i = 0; i < sz; i++ ) v[i]=a.v[i]; //copy elements } 在C++中,一个形如X(X&) 的构造函数定义了从X的一个对象出发构造X的另一个对象的初始化过程。除了明确地构造X的对象之外,X(X&)也被用来处理传值的传参过程和函数的返回值。 在C++中,可以通过将赋值声明为私有来禁止对于对象的赋值操作。 class X{ void operator=(X&); //only members of x can X(X&); //copy an x //... public: //... } Ada不支持构造,析构,对赋值的重载和用户定义的参数传递和返回机制,这严重限制了用户自定义类型的种类,同时强迫程序员回到“数据隐藏”技术,就是说,用户必须设计和使用类型管理模块而不是真正的类型。 3.3参数化类型 为什么我们要定义一个整数类型的vector呢?要知道,用户常常需要一个对于vector的作者而言类型未知的vector。因此,vector应当采用一种可以将“类型”作为参数来引用的表达方式加以定义: class vector<class T>{ //vector of elements of type T T* v; int sz; public: vector( int s) { if( s<= 0 ) error( "bad vector size" ); v = new T[sz = s ]; //allocate an array of "s" "T"s } T& opeartor[](int i); int size() { return sz; } //... } 特定类型的vector可以象这样定义和使用: vector<int> v1(100); //v1 is a vector of 100 integers vector<complex> v2(200); //v2 is a vector of 200 complex numbers v2[ i ] = complex(v1[x], v1[y]); Ada,Clu和ML支持参数化类型。不幸的是,C++不支持(译注,现在的C++标准支持参数化类型,称为模板);这里使用的记号只是为了演示;但在必要时,可以使用宏来模拟参数化类型。和那些指定了所有类型的类比起来这样做并没有在运行时引入更多的开销。 一般来说,一个参数化类型总会依赖于参数类型的某些方面。例如,vector的有些操作假定参数类型定义了赋值操作。那么人们如何保证这一点呢?一种方案是要求参数化类型的设计者表明这种依赖关系。例如,“T必须是一种定义了赋值操作的类型”。另一个好一点的办法让参数化类型的规格和参数类型的规格彼此独立,编译器可以检测到对不存在操作的调用,并且可以给出相应的错误提示。例如: cannot define vector(non_copy)::operator[](non_copy&) : type non_copy does not have operator= 这种技术使得我们可以在“操作”这个级别上处理参数类型和参数化类型之间的依赖性。例如,我们可能定义一个具有排序功能的vector,排序操作可能用到参数类型的<,<= 和=操作。然而,只要不调用vector的排序功能,我们还是可以使用一个没有<操作的类型来参数化vector。 从参数化类型中生成的每一个类型之间是彼此独立的,这是一个问题。例如,vector<char>和vector<complex>之间完全无关。理想的情况是,人们可以表达并且利用从同一个参数化类型中生成的各个类型之间具有的共性,例如,vector<char>和vector<complex>都具有一个和类型无关的size()操作。从vector的定义中推导出size可以被实例类型共用是可能的,但其过程并不简单。解释型的语言或者同时支持参数化类型和继承机制的语言在这个方面具有优势。
3.4 异常处理 随着程序规模的增长,特别是当程序库对外发布后,提供一个处理错误(或者更一般地说,“异常情况”)的标准机制是重要的。Ada,Algol68和Clu各自支持一套处理异常的标准机制。不幸的是,C++不直接支持异常处理(译注,现在的C++标准已经支持异常处理),而必须使用函数指针,“异常对象”,“错误状态”和C的库函数signal和longjump等机制来伪造。这些机制不够一般,同时也不能提供一个处理错误的标准框架。 重新考虑一下vector的例子。当一个越界的索引值被传递给索引(subscribe)操作时,会发生什么?vector的设计者应该可以为此指定一个缺省行为: class vector { ... except vector_range{ //define an exception called vector_range //and specify default code for handling it error("global,vector range error" ); exit( 99 ); } } vector::opeartor[]()可以触发异常处理代码而不是调用出错函数: int& vector::operator[](int i) { if( 0 < i |