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.参考文献
略,请参阅原文
……