C++中的虚拟机制
申鼎才
我们已经知道,用C 语言编写的过程程序就是一些数据定义和函数调用。要理解这种程序的含义,程序员必须掌握函数调用和函数实现的本身。这就是过程程序需要中间表示的原因。中间表示容易引起混淆,因为中间表示的表述是原始的,更偏向于计算机,而不偏向于所解决的问题。
因为 C++ 向 C 语言增加了许多新概念,所以程序员很自然地认为, C + +程序中的main()会比功能相同的C 程序更复杂。但令人吃惊的是,一个写得很好的C + +程序一般要比功能相同的C程序更简单和容易理解。程序员只会看到一些描述问题空间对象的定义(而不是计算机的描述),发送给这些对象的消息。这些消息表示了在这个空间的活动。面向对象程序设计的优点之一是通过阅读,很容易理解代码。通常,面向对象程序需要较少的代码,因为问题中的许多部分都可以用已存在的库代码。
C + +能够如此成功,部分原因是它的目标不只是为了将C语言转变成 OOP 语言(虽然这是最初的目的),而且还为了解决当今程序员,特别是那些在C语言中已经大量投资的程序员所面临的许多问题。
C + +的目的是提高效率。效率取决于很多东西,而语言是为了尽可能地帮助使用者,尽可能不用武断的规则或特殊的性能妨碍使用者。C + +成功是因为它立足于实际:尽可能地为程序员提供最大便利。
为了达到高效、方便,C++中引入了很多新的机制,这里要讨论的就是C++中虚拟机制,这里的虚拟机制可以从三个方面来说明:
一、虚函数
虚函数能克服类型域解决方案中的缺陷,它使程序员可以在基类里声明一些能够在各个派生类重新定义的函数。编译器和装载程序能保证对象和应用于它们的函数之间的正确对应关系。
为了创建一个 virtual 成员函数,可以简单地在这个函数声明的前面加上关键字virtual。为了使虚函数声明能起到作为在派生类里定义的函数界面的作用,在派生类里,对有关函数说描述的参数类型就不能在基类中声明的参数类型有任何差异,只有在返回类型上允许小小的改变。如果一个函数在基类中被声明为 virtual,那么在所有的派生类中它都是virtual 的。在派生类中 virtual 函数的重定义通常称为越位。
在某个虚函数的第一个声明所在的那个类里,该虚函数也必须予以定义(除非它被声明为纯虚函数)。
即使没有从某个类派生出其他类,也可以使用其中的虚函数,如果某个派生类不需要自己的虚函数版本,那就完全不必提供自己的版本。在声明派生类时,如果需要的话,就可以简单的提供一个合适当函数。
如果在派生类里存在一个函数,它具有与基类中的某个虚函数同样的名字和同样一组参数类型,我们就
说它覆盖了这个虚函数的基类版本,除非明确说明要调用的是虚函数的那一个版本,否则,在对一个对象调用虚函数时,被选用的总是那个最适于它的覆盖函数。
一个带有虚函数的类型被称为多态类型。要在C++里取得多态性的行为,被调用的函数就必须是虚函数,而对象则必须是通过指针或引用去操作,如果直接操作一个对象(而不是通过指针或引用),它的确切类型就已经为编译器所知,因此也就不需要运行时的多态性了。
通过作用域解析运算::去调用函数,就能保证不使用虚函数机制。使用带限定词的名
字还有另一个我们希望的作用,那就是,如果某个virtual函数也是inline(这也很常见),那么就可以用::特殊说明的调用使用在线替换。这就使程序员能有一种很有效的方法去处理一些重要的特殊情况,其中某个虚函数针对同一个对象调用了另一个虚函数。
关键字 virtual告诉编译器它不应当完成早捆绑,相反,它应当自动安装实现晚捆绑所必须的所有机制。为了完成这件事,编译器对每个包含虚函数的类创建一个表(称为V TA BLE)。在VTABL E中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一指针,称为 vpointer(缩写为VPTR),指向这个对象的 VTABLE。通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取得这个VPTR,并在VTABLE 表中查函数地址的代码,这样就能调用正确的函数使晚捆绑发生。
为每个类设置 VTABLE、初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的情况下。
可以看到,在任何类中,不存在显式的类型信息。简单的逻辑告诉我们,必须有一些类型信息放在对象中,否则,类型不能在运行时建立。实际上,类型信息被隐藏了。为了看到它,这里有一个例子,可以测试使用虚函数的类的长度,并与没有虚函数的类比较。
//:sizes.cpp  -- Object size  vs. virtual funcs
#include<iostream.h>
class no_virtual{
int a;
public:
void x() const {}
int i() const { return 1;}
};
class one_virtual {
int a;
public:
virtual viod x() const {}
int i()  const { return 1;}
};
class two_virtuals{
int a;
public:
virtual void x() const {}
virtual int i() const { return 1;}
};
main(){
cout <<”int : “ << sizeof(int) <<endl;
cout <<”no_virtual:”<<sizeof(no_virtual) << endl;
cout <<”void* :” <<sizeof(void*) <<endl;
cout <<”one_virtual: “<< sizeof(one_virtual) << endl;
cout <<”two_virtuals :”<<sizeof(two_virtuals) << endl;
}
不带虚函数,对象的长度恰好就是所期望的:单个 int 的长度。而带有单个虚函数的 one_virtual,对象的长度是 no_virtual的长度加上一个void指针的长度。它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针( VPTR)。在 one_virtual 和two_virtuals 之间没有区别。这是因为 VPTR指向
一个存放地址的表,只需要一个指针,因为所有虚函数地址都包含在这个表中。
这个例子至少要求一个数据成员。如果没有数据成员, C + +编译器会强制这个对象是非零长度,因为每个对象必须有一个互相区别的地址。如果我们想象在一个零长度对象的数组中索引,我们就能理解这一点。一个“哑”成员被插入到对象中,否则这个对象就有零长度。当 virtual关键字插入类型信息时,这个“哑”成员的位置就被占用。在上面例子中,用注释符号将所有类的int a去掉,我们就会看到这种情况。
为了准确
地理解使用虚
函数时编译器
做了些什么,使
屏风之后进行
的活动看得见
是有帮助的。这
里画的是在某
个含有虚函数
的继承图。
多态性与虚函数这个
instrument指
针数组没有特
殊类型信息,它的每一个元素指向一个类型为instrument 的对象。wind 、percussion、string和brass都适合这个范围,因为它们都是从instrument派生来的(并且和instrument 有相同的接口和响应相同的消息),因此,它们的地址也自然能放进这个数组里。然而,编译器并不知道它们比instrument对象更多的东西,所以,留给它们自己处理,而通常调用所有函数的基类版本。但在这里,所有这些函数都被用virtual声明,所以出现了不同的情况。每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如这个图的右面所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual 的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在brass的VTABLE中,adjust 入口就是这种情况。)然后编译器在这个类中放置VPTR(可在SIZES.CPP中发现)。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE。(这在构造函数中发生,在稍后会看得更清楚。)
一旦VPTR被初始化为指向相应的VTABLE,对象就“知道”它自己是什么类型。但只有当虚函数被调用时这种自我知识才有用。
通过基类地址调用一个虚函数时(这时编译器没有能完成早捆绑的足够的信息),要特殊处理。它不是
实现典型的函数调用,对特定地址的简单的汇编语言CALL,而是编译器为完成这个函数调用产生不同的代码。下面看到的是通过instrument指针对于brass调用adjust()。instrument引用产生如下结果:
编译器从这个instrument指针开始,这个指针指向这个对象的起始地址。所有的inst rument对象或由instrument派生的对象都有它们的VPTR,它在对象的相同的位置(常常在对象的开头),所以编译器能够取出这个对象的VPTR。VPTR指向VTABLE的开始地址。所有的VTA BLE有相同的顺序,不管何种类型的对象。play()是第一个,what()是第二个,adjust()是第三个。所以编译器知道adjust() 函数必在VPTR+2 处。这样,不是“以instrument::adj u st地址调用这个函数”(这是早捆绑,是错误活动),而是产生代码,“在VPTR+2处调用这个函数”。因为VPTR的效果和实际函数地址的确定发生在运行时,所以这样就得到了所希望的晚捆绑。向这个对象发送消息,这个对象能断定它应当做什么。
抽象基类和纯虚函数
在所有的instrument的例子中,基类instrument中的函数总是“假”函数。如果调用这些函数,就会指出已经做错了什么事。这是因为,instrument的目的是对所有从它派生来的类创建公共接口,如在右图
中看到的:
虚线表示类(一个类只是
一个描述,而不是一个物理实
体—虚线代表了它的非物理的
“性质”)。从派生类到基类
的箭头表示继承关系。
建立公共接口的唯一的
理由是使得它能对于每个不同
的子类有不同的表示。它建立
一个基本的格式,由此可以知
道什么是对于所有派生类公共
的。注意,另外一种表达方法
是称instrument为抽象基类
(或简称为抽象类),当希望
通过公共接口操作一组类时就
创建抽象类。
如果我们有一个真实的
抽象类(就像instrument),
这个类的对象几乎总是没有意义的。也就是说,instrument的含义只表示接口,不表示特例实现。所以创建一个instrument对象没有意义。我们也许想防止用户这样做。这能通过让in strument的所有虚函数打印出错信息而完成,但这种方法到运行时才能获得出错信息,并且要求用户可靠而详尽地测试。所以最好是在编译时就能发现这个问题。
C ++ 对此提供了一种机制,称为纯虚函数。下面是它的声明语法:
virtual void x() = 0;
这样做,等于告诉编译器在VTABLE中为函数保留一个间隔,但在这个特定间隔中不放
地址。只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。包含有纯虚函数的类称为纯抽象基类。
如果一个类的VTABLE是不完全的,当某人试图创建这个类的对象时,编译器做什么呢?由于它不能安全地创建一个纯抽象类的对象,所以如果我们试图制造一个纯抽象类的对象,编译器就发出一个出错信息。这样,编译器就保证了抽象类的纯洁性,我们就不用担心误用它了。
纯虚函数是非常有用的,因为它们使得类有明显的抽象性,并告诉用户和编译器希望如何使用。
注意,纯虚函数防止对纯抽象类的函数以传值方式调用。这样,它也是防止对象意外使用值向上映射的一种方法。这样就能保证在向上映射期间总是使用指针或引用。
纯虚函数防止产生VTABLE,但这并不意味着我们不希望对其他函数产生函数体。我们常常希望调用一个函数的基类版本,即便它是虚拟的。把公共代码放在尽可能靠近我们的类层次根的地方,这是很好的想法。这不仅节省了代码空间,而且能允许使改变的传播变得容易。
继承和VTABLE
可以想象,当实现继承和定义一些虚函数时,会发生什么事情?编译器对新类创建一个新VTABLE表,并且插入新函数的地址,对于没有重定义的虚函数使用基类函数的地址。无论如何,在VTABLE中总有全体函数的地址,所以绝对不会对不在其中的地址调用。(否则损失惨重。)
但当在派生类中增加新的虚函数时会发生什么呢?
类base包含单个虚函数value(),而类derived增加了第二个称为shift()的虚函数,并重定义了value的含义。下图有助于显示发生的事情,其中有编译器为base和derived创建的
两个VTABLE。
注意,编译器映射
derived VTABLE中的va
lue地址位置等于在
base VTABLE中的位置。
类似的,如果一个类从derived继承而来,它的shift版本在它的VTABLE中的位置应当等于在derived中的位置。这是因为(正如通过汇编语言例子看到的)编译器产生的代码只是简单地在VTABLE中用偏移选择虚函数。不论对象属于哪个特殊的类,它的VTABLE是以同样的方法设置的,所以对虚函数的调用将总是用同样的方法。
这样,编译器只对指向基类对象的指针工作。而这个基类只有value函数,所以它就是编译器允许调用的唯一的函数。那么,如果只有指向基类对象的指针,那么编译器怎么可能知道自己正在对derived对象工作呢?这个指针可能指向其他一些没有shift函数的类。在VTABLE中,可能有,也可能没有一些其他函数的地址,但无论何种情况,对这个VTABLE地址做虚函数调用都不是我们想要的。所以编译器防止对只在派生类中存在的函数做虚函数调用,这是幸运的,合乎逻辑的。
有一些很少见的情况:可能我们知道指针实际上指向哪一种特殊子类的对象。这时如果想调用只存在于这个子类中的函数,则必须映射这个指针。下面的语句可以纠正由前面程序产生的错误:
((derived *) B[1])->shift(3)
在这里我们碰巧知道B[1]指向derived对象,但这种情况很少见。如果我们的程序确定我们必须知道所有对象的准确的类型,那么我们应当重新考虑它,因为我们可能在进行不正确的虚函数调用。然而对于有些情况如果知道保存在一般包容器中的所有对象的准确类型,