[摘要]译者序不要将本文简单地视为是对C++特征的一个介绍。它的意义在于,一方面介绍了编程风格的演变,以及这种演变背后的动机。另一个方面,它特别澄清了基于对象的(OB)和面向对象(OO)的异同,这是具有很大意义的。我们可以看到,不管是OB还是OO,都不过是一种程序的组织形式。 这在很大程序上指出了OO着眼...
译者序
不要将本文简单地视为是对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;
//representation 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 sz <= i ) raise vector_ranger;
return v[ i ];
}
这导致堆栈回卷,直到发现一个能够处理vector_range异常的句柄为止。然后执行该异常处理句柄。
可以针对一个特定的代码块来定义异常句柄:
void f() {
vector v(10);
try { //errors here are handled by the local
//exception handler defined below
//...
int i = g(); //g might cause a range error using some vector
v[ i ] = 7;
}
except {
vector::vector_ranger:
error( "f() vector ranger error" );
return;
}
//error here are handled by the global
//exception hander defined in vector
int i = g();
v[ i ] = 7; ://g might cause a range error using some vector
//potential range error
}
可以有很多种方式来定义异常以及异常处理句柄的行为。这里列出的异常机制概貌是从Clu和Module-2+中变化而来的。这种风格的异常处理可以实现为,直到抛出异常时才执行异常处理代码。也可以容易地使用C的setjmp和longjup模拟出来。
那么,象上文定义的异常处理的语义在C++中是否可以完全伪造出来呢?很不幸,不能。问题在于,当异常发生时,运行栈必须被回卷到安装异常处理句柄的位置,在C++中,这涉及到调用在回卷过程中被销毁对象的析构函数。使用C的longjmp函数是做不到这一点的;一般地说,用户自身也不能做到这一点。
3.5强制
已经证明,用户自定义的强制是非常有用的技术,例如,构造函数complex(double)隐含着一个从double到complex的强制。程序员可以明确地指出强制,或者在必要时,如果没有二义性,编译器也可以暗中引入它:
complex a = complex(1);
complex b = 1; //implicit: 1->complex(1)
a = b + complex(2);
a = b + 2 //implicit: 2->complex(2)
C++引入用户定义的强制的原因是,在支持算术运算的语言中混合模式的算术表达式是很常见的;同时,参加运算的用户自定义类型(例如,矩阵,字符串,机器地址等)也大多可以很自然地相互映射。
从程序组织的角度来看,有一种类型的强制可以证明是格外有效的:
complex a = 2;
complex b = a+2; //interpereted as operator+(a,complex)
b = 2+a; //interpereted as operator+(complex(2),a)
在解释‘+’操作时只需要一个函数,并且对于类型系统而言,两个操作数是被同等看待的。进一步,我们看到,可以在不对整数概念做出任何调整的前提下只通过实现类complex就可以将这两个概念平滑地集成到一起。这和“纯面向对象系统”截然不同,在那里这些操作会被如下解释:
a+2; ://a.opeartor+(2)
2+a; ://2.operator(a)
这样就必须修改类integer来使得2.operator(a)合法化。当在一个系统中加入新的功能时,修改已有的代码是必须尽量避免的,一般地说,面向对象的编程技术能够很好地支持这个目标,但在这里,数据抽象技术提供了更好的解决方案。
3.6迭代器(Iterators)
一般认为,支持数据抽象的语言必须提供定义控制结构的手段。特别是,常常需要一个允许用户循环访问一个容器类型中所含元素的机制,同时又不能迫使用户依赖于容器类型的实现细节。如果有一个定义类型的强大机制,同时又能够重载操作符,则就可以在不引入独立的定义控制结构的机制的前提下实现这一目标。
对于vector,用户可以通过下标来确定其顺序,所以可以不必定义迭代器。然而我还是定义了一个来演示这个技术。迭代器可以有很多种风格,我比较喜欢的是通过重载函数操作符:
class vector_iterator{
vector & v;
int i;
public:
vector_iterator(vector& r) { i = 0; v = r; }
int operator()() { return i<v.size() ? v.elem(i++) : 0; }
};
现在我们可以象这样声明和使用迭代器:
vector v(sz);
vector_iterator next(v);
int i;
while( i = next() ) print( i );
在同一个时刻一个对象可以激活多个迭代器对象;同时,一个类型可以定义多种不同类型的迭代器以便执行不同的循环操作。迭代器是一种相当简单的控制结构,也可以定义更加一般的控制机制,例如C++标准库提供了co-routine类[15]。
对于很多容器类型,例如vector,可以将迭代机制作为类型自身的一部分来定义以避免引入独立的迭代器。可以将vector定义为具有一个“当前状态”:
class vector {
int* v;
int sz;
int current;
public:
//...
int next() { return (current++<sz) ? v[current] : 0; }
int prev() { return ( 0 < --current ) ? v[current] : 0; }
};
于是可以象这样操作:
vector v(sz);
int i;
while( i = v.next() ) print(i);
和迭代器比起来,这样的方案不够一般;但是在一种重要的特殊情况下它减少了开销:可能我们只需要一种类型的迭代器,并且在同一时刻只会有一个迭代器对象在活动。如果必要,也可以在这个简单的方案之上加上更一般的机制。请注意,使用这种简单的解决方案比起使用迭代器来需要更多的设计远见。迭代器技术也可以设计为同一个迭代器类型能够绑定到不同的容器类型,这样通过一个迭代器就可以访问不同的容器类型。
3.7 实现问题
对数据抽象的支持大多定义为语言特征并且由编译器来实现。但参数化类型最好能够通过一个对于语言的语义有更多理解的连接器来支持;同时异常处理需要运行环境的支持。他们都可以在不牺牲一般性和易用性的前提下获得很好的编译速度和效率。
随着定义类型的能力的增长,程序开始更多地依赖来自一些库中的类型(并不仅限于那些在语言的手册中描述的内容)。这很自然地需要工具来表达程序中哪些部分被插入了库中,而哪些部分是从库中抽取出来的;也需要工具来找出库包含了哪些东西和库中的哪些部分是实际被程序使用了的等等。
对于编译型语言,能够使得代码在修改以后尽量减少编译工作的工具是非常重要的。同时,连接器/加载器能够在加载执行代码时尽量不加载大量的无关和无用代码的能力也是非常关键的。特别要指出来,如果一个类型只有少数几个操作被调用,而库/连接器/加载器却将该类型的所有操作都加载入内存的行为是特别糟糕的。
4. 对面向对象的支持
有两个机制在支持面向对象编程中起了基本的作用,第一个是类的继承机制;第二个是,当在编译时无法确定一个对象的实际类型时,应当能够在运行时基于对象的实际类型来决定调用的具体方法。其中,对于方法调用机制的设计是关键。同时,如上文所述的对数据抽象的支持技术对于支持面向对象也同样是重要的,因为数据抽象的观点,以及为了在语言中优雅地支持它而作的努力在面向对象技术中同样也有效。这两种技术的成功都取决于对类型的设计以及能够高效,方便和灵活地使用这些类型。相对于数据抽象而言,面向对象技术能够设计出更加一般,更加灵活的数据类型。
4.1调用机制
支持面向对象的关键语言特征是针对一个给定的对象如何调用它的方法。例如,给定指针p,如何处理调用p->f(arg)呢?在这里存在一系列的选择。
在C++和Simula这样广泛应用静态类型检查的语言中,可以借助于类型系统来在不同的调用方式之间作出选择。在C++中,有两种函数调用的方式:
【1】普通的方法调用:具体调用那个方法在编译时间就可以决定(通过查找编译器的符号表),同时在使用标准过程调用机制基础上增加一个表示对象身份的指针。如果在某些场合标准过程调用显得效率不够高,则可以将函数声明成为内联(inline)的,编译器就尝试在调用的位置展开函数体。通过这种方式,人们可以既获得类似宏的展开机制又不牺牲标准函数的语义。这样的优化措施对于数据抽象也同样是有价值的。
【2】虚函数调用:函数调用依赖于对象的实际类型,一般地说,对象的实际类型只能在运行时间才能确定。典型的情况是,指针p具有某个基类B的类型,而p指向的对象是B的某个子类D的(就想上文例子中的shape和circle)。调用机制必须察看编译器为对象指定的一个表来决定调用那个函数。一旦找到了函数,譬如说是D::f,则可以使用上文所述的方式来调用。在编译时间,名字f转换成为函数指针表的一个索引。这样的调用机制几乎和普通的方法调用一样有效,在标准C++中,只需要额外地多做5次内存引用。
弱类型语言需要采用更加复杂的机制。在象Smalltalk之类的语言中,必须保存类的所有方法的名字以备在运行时间执行查找:
【3】方法触发:首先通过p指向的对象找到正确的方法名字的表,然后在这个表中查找是否有一个方法“f”,如果存在则调用之;否则出错。和静态类型语言在编译时间执行函数名查找不同,在这里方法名查找是针对一个实际的对象执行的。
和虚函数调用比起来,方法触发是低效的,但是它更加灵活。由于此时通常无法执行静态参数类型检查,所以这种调用方式必须和动态类型检查一起使用。
4.2 类型检查
上文所述的“形状”这个例子显示了虚函数的威力。那么方法触发为我们提供了些什么呢?答案是,我们现在可以在任意对象上尝试调用任意方法!
在任意对象上触发任意方法的能力使得类库的设计者可以将正确处理数据类型的责任转嫁到用户的头上,当然,这简化了库的设计。例如:
class stack { ://assume class any has a member next
any* v;
void push(any* p )
{
p->next = v;
v = p;
}
any* pop()
{
if( v == 0 ) return error_obj;
any* r = v;
v = v->next;
return r;
}
};
这样,用户就必须有责任保证避免以下的类型匹配错误:
stack<any*> cs;
cs.push( new Saab900 );
cs.push( new Saab37B );
plane* p = (plane*)cs.pop();
p->takeoff();
p = (plane*)cs.pop();
p->tackoff(); //Oops! Run time error: a Saab 900 is a car
// a car does not have a takeoff method;
消息处理机制可以检测到程序试图将一部汽车当作一架飞机,于是触发了一个错误。但是,只有当用户是程序员本人时这样的错误提示才可能有一些安慰性的价值;由于缺少静态类型检查,我们很难保证在最终发布的程序中不包含这样的错误。自然地,在一个没有静态类型的,基于方法的语言中这样的矛盾会更加突出。
将参数化类型和虚函数结合起来使用可以在库设计的灵活性,简单性,以及库使用的方便性上近似于方法触发,而同时又不需要放弃静态类型或在空间和时间上引入开销,例如:
stack<plane*> cs;
cs.push( new Saab900 ); // compile time error:
// type mismatch: car* passed,plane* expected
cs.push( new Saab37B);
plane* p = cs.pop();
p->takeoff(); //fine: a Saab 37B is a plane.
p = cs.pop();
p->takeoff;
基于静态类型检查/虚函数调用的程序风格和基于动态类型检查/方法触发的程序风格存在某些差异。例如,在Simula和C++中一个类为其所有子类的对象指定了一个确定的接口,而Smalltalk中的类则为其所有子类的对象指定了一个初始接口。换言之,Smalltalk中的类是一个最小的接口规范,用户可以任意尝试其他所有在接口中未指定的方法;而C++类则是一个确定的接口规范,用户可以确信,只有调用那些在接口中有定义的方法才能通过编译。
4.3 继承
考虑一个支持方法查找但不支持继承的语言,这个语言可以被称为支持面向对象吗?我认为不。很明显,我们可以利用方法查找技术来使得对象适应具体的应用环境,并因此能完成很多有趣的事情。然而,为了避免混乱,我们还是需要一个系统的手段来将对象的方法和作为对象表示的数据结构结合在一起。同时,为了使得对象的用户可以了解到对象的行为方式,又必须有一个系统的手段来表示对象不同的行为之间的共性。“这个系统而标准的手段”就是继承。
考虑一个支持继承但是不支持虚函数或者方法查找的语言,这个语言可以被称为是支持面向对象的吗?我认为不:上文所述的“形状”这个例子在这个语言中没有很好的解决方案。然而,相对于只支持数据抽象的语言,这样的语言要强大很多。这个观点来自于这样的观察:很多基于Simula和C++的程序都使用继承来组织其结构,但在其中没有使用虚函数。表达共性的能力是一个特别强大的工具,例如,不使用联合我们就可以解决各种“形状”需要一个公用表示的问题。但是,由于缺少虚函数,人们必须借助于一个“类型域”来表达对象的具体类型,这导致代码的组织缺少模块性。
类继承是一种特别有效的编程工具, 继承不仅可以用来支持面向对象编程,而且还有着更广泛的应用。在面向对象的编程实践中,我们可以使用基类来表达一般的概念,而使用子类来表达各种特例。这样的方式只是部分展示了继承的强大功能,然而那些所有函数都是虚函数的(或者都是基于方法查找的)语言很强调这样的观点。如果能够正确地控制从基类中继承而来的内容,那么类继承可以是一个从已有类型中产生新类型的强大工具。子类和基类之间的关系并不总能概括为特例和一般,“分解”(factoring)能够更加准确地表达子类和基类之间的关系。
继承是又一个我们无法简单地预言应该如何使用才算是合理的编程工具,并且在今天(即便Simula诞生已经超过20年)要简单地说断定说哪些使用方式是误用也还为时过早。
4.4多继承
假设A是B的基类,则B继承了A的所有属性;就是说,除了自身特有的一些属性之外,B就是一个A。在这样的解释之下,很明显, 让B同时继承两个基类A1和A2很可能是有用的。这称为多继承。
举一个经典的例子。假定类库提供了两个类displayed和task,分别用来表示显示管理对象和调度管理对象,则程序员就可以产生象这样的类:
class my_displayed_task : public displayed, public task{
// my stuff
};
class my_task : public task { //not displayed
// my stuff;
}
class my_displayed : public displayed { // not a task
// my stuff;
};
如果只使用单继承,那么程序员就只能使用这三个选择中的两个。这或者将导致代码的重复,或者将导致缺少弹性-或者常常两者兼有。在C++中,这样的例子可以使用多继承解决,并且相对于单继承不引人明显开销(时间和空间),也不牺牲静态类型检查。
二义性可以在编译时间解决:
class A{ public : f(); ... }
class B{ public : f(); ... }
class C: public A, public, B{ ... };
void g() {
C* p;
p->f(); //error:ambiguous
}
在这一点上,C++有别于LISP的一个支持多继承的面向对象方言。LISP通过依赖声明的顺序来解决名字冲突,或者将来自不同基类的同名的方法看作是等价的,又或者将基类中的同名方法组合成为一个最高层类中的一个更复杂的方法。
而在C++中,我们可以通过一个额外的功能来解决名字的冲突:
class C : public A, public B{
public:
f()
{
//C's own stuff
A::f();
B::f();
}
....
}
除了这样用来直接表达独立的多继承(independent multiple inheritance)的概念之外,看来还需要更一般的机制来表达在一个多继承格(multiple inheritance lattice)中类之间的依赖关系。在C++中,可以使用虚基类来表达一个类对象中的一个子对象被所有其他的子对象共享:
class W{ ... }
class Bwindow //window with border
: public virtual W
{ ... }
class Mwindow
: public virtual W
{ ... }
class BMW //window with border and menu
: public Bwindow,public Mwindow
{ ... };
这样,在一个BMW对象中,只有一个W子对象被Bwindow和Mwindow子对象共享。LISP方言提供了方法组合的概念来减少在编程中使用如此复杂的类层次,但C++没有。
4.5 封装
考虑类的一些成员(不管是数据成员还是函数成员)需要被保护起来以防止未授权的访问。那么该如何合理地界定哪些函数可以访问这些成员呢?对于面向对象的编程语言来说,最明显的答案是“定义在该对象上的所有成员函数”。但由此可以有一个不太明显的推论,即实际上我们不能完整地确定一个集合,其中包括了所有有权访问这些受保护成员的函数;因为总可以从这些具有保护成员的类中派生出新的类,并且在派生类上定义新的成员函数。这样的方案一方面在很大的程度上防止了意外地访问保护成员(因为我们不会“意外”地派生出一个新类),在另一方面,又为使用类层次建立应用提供了弹性(因为我们可以通过派生一个新类来赋予自己访问保护成员的能力)。
不幸的是,对于一个面向数据抽象的语言而言,其答案是不同的:“必须在类的声明中罗列出所有有权访问保护成员的函数”,但对于这些函数本身没有特别的要求,特别是,这些函数不必是类的成员函数。在C++中,有权访问私有成员的非成员函数称为友元函数。在上文中定义的类complex具有友元函数。有时候,将一个函数声明为多个类的友元函数也是很有用的。如果想理解一个类型的意义,特别是想修改它的时候,有一个成员函数和友元函数的完整列表是非常有好处的。
以下的例子演示了在C++中封装的几个选择:
class B{
//class member are default private
int i;
void f1();
protected:
int i2;
void f2();
public:
int i3;
void f3();
friend void g(B*); //any function can be designated as a friend
}
私有和保护成员不能被外界直接访问:
void h(B* p)
{
p->f1(); //error:B::f1 is private
p->f2(); //error:B::f2 is protected
p->f3(); //fine:B::f1 is public
}
保护成员可以在派生类中访问,但私有成员不能:
class D: public B{
public:
void g()
{
f1(); //erro: B:f1 is private
f2(); //fine: B:f2 is protected, but D is derived from B
f3(); //fine: B:f3 is public
}
}
友元函数可以象成员函数一样访问私有和保护成员:
void g(B* p)
{
p->f1(); //fine: B::f1 is private, but g() is a friend of B
p->f2(); //fine: B::f2 is protected, but g() is a friend of B
p->f3();// fine: B::f1 is public
}
随着程序规模,用户的数量的增长,同时如果用户在地理上分布比较分散,成员保护机制的重要性就会大大增加。文献Snyder[17]和Stroustrup[18]进一步讨论了保护问题。
4.6 实现问题
为了支持面向对象编程,主要需要改进运行时间系统和编程环境。在一定程度上,这是因为面向对象需要的语言机制已经由数据抽象引入了,所以不再需要很多额外的特征。
面向对象技术进一步模糊了编程语言及其环境之间的界限,因为各种一般的或是具体的用户定义类型越来越多地充斥在程序之中。这需要进一步发展运行时间系统,库工具,调试器,性能测量工具和监控工具。理想的情况是这些工具被集成到一个环境之中去。Smalltalk是这方面的一个好例子。
5 限制
为了定义了一个能够充分利用数据隐藏,数据抽象和面向对象技术的语言,我们面对的主要问题是,任何一个通用的程序语言都必须能够:
1.在传统的计算机上运行
2.和传统的操作系统并存
3.在时间效率上堪舆传统的程序语言媲美
4.用于主要的应用领域
这意味着,这个语言必须能够高效地执行算术运算(在浮点运算方面要堪舆媲美Fortran);其访问内存的方式必须能够用于设备驱动程序;同时必须能够遵从传统操作系统制定的古怪标准来生成调用。进一步,传统语言必须能够调用使用面向对象语言书写的函数,而面向对象的语言也必须能够调用使用传统语言书写的函数。
此外,如果一个面向对象的程序语言依赖于不能在传统系统结构下有效实现的技术,则它不可能成为一个通用的语言。除非得到特别的支持,否则方法触发机制的一般性实现将会成为负担。
类似的,垃圾收集可能成为性能和移植性的瓶颈。大多数面向对象的语言都采用垃圾收集机制来简化程序员的工作,同时也减少语言本身和编译器的复杂性。然而,就算我们可以在一些非关键的领域内使用垃圾收集,但一旦需要,我们就应该能够保留对存储器的控制权。另一方面,一个程序语言选择放弃垃圾收集,转而为类型管理自身的存储提供便利的表达手段也是切实可行的。C++就是一个例子。异常处理和并发特征是另外一个潜伏着问题的地方。任何依赖于连接器的支持才能有效实现的机制有可能存在移植问题。在一个语言中拥有“低级”特征的另一个方法是可以在主要的应用领域中使用一个独立的“低级”语言。
6. 结论
基于继承的编程叫做是面向对象的编程方法,基于用户自定义类型的编程叫做是基于数据抽象的编程方法。除了很少的一些例外情况之外,面向对象编程可以视为是数据抽象的一个超集。只有得到了正确的支持这些技术才能是有效的;对数据抽象的支持主要来自语言本身,而面向对象则需要来自编程环境的进一步支持。为了通用性,支持数据抽象和面向对象的语言必须能够高效地利用硬件。
7. 致谢
本文一个较早的版本在斯德哥尔摩的 Association of Simula Users会议上面世。在那里的进行的讨论导致了对本文的风格和内容的做了很多改进。Brain Kernighan和Ravi Sethi给出了很多建设性的意见。同时感谢所有为增强C++作出了贡献的人。
8.参考文献
略,请参阅原文
……