C++编译器必须实现语言的每一个特性。这些实现的细节当然是由编译器来决定的,并且不同的编译器有不同的方法实现语言的特性。在多数情况下,我们不用关心这些事情。然而有些特性的实现对对象大小和其成员函数执行速度有很大的影响,所以对于这些特性有一个基本的了解,知道编译器可能在背后做了些什么,就显得很重要。这种特性中最重要的例子是虚拟函数。
当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用virtual table和virtual table pointers。virtual table和virtual table pointers通常被分别地称为vtbl和vptr。
一个vtbl通常是一个函数指针数组。(一些编译器使用链表来代替数组,但是基本方法是一样的)在程序中的每个类只要声明了虚函数或继承了虚函数,它就有自己的vtbl,并且类中vtbl的项目是指向虚函数实现体的指针。例如,如下这个类定义:
class C1 {
public:
C1();
virtual ~C1();
virtual void f1();
virtual int f2(char c) const;
virtual void f3(const string& s);
void f4() const;
...
};
C1的virtual table数组看起来如下图所示:
| ---------->Implementation of C1::~C1 |
| ---------->Implementation of C1::f1 |
| ---------->Implementation of C1::f2 |
| ---------->Implementation of C1::f3 |
注意非虚函数f4不在表中,而且C1的构造函数也不在。非虚函数(包括构造函数,它也被定义为非虚函数)就象普通的C函数那样被实现,所以有关它们的使用在性能上没有特殊的考虑。
如果有一个C2类继承自C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,
class C2: public C1 {
public:
C2(); // 非虚函数
virtual ~C2(); // 重定义函数
virtual void f1(); // 重定义函数
virtual void f5(char *str); // 新的虚函数
...
};
它的virtual table项目指向与对象相适合的函数。这些项目包括指向没有被C2重定义的C1虚函数的指针:
| ---------->Implementation of C2::~C2 |
| ---------->Implementation of C2::f1 |
| ---------->Implementation of C1::f2 |
| ---------->Implementation of C1::f3 |
| ---------->Implementation of C2::f5 |
这个论述引出了虚函数所需的第一个代价:必须为每个包含虚函数的类的virtual talbe留出空间。类的vtbl的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。每个类应该只有一个virtual table,所以virtual table所需的空间不会太大,但是如果有大量的类或者在每个类中有大量的虚函数,会发现vtbl会占用大量的地址空间。
因为在程序里每个类只需要一个vtbl拷贝,所以编译器肯定会遇到一个棘手的问题:把它放在哪里。大多数程序和程序库由多个object(目标)文件连接而成,但是每个object文件之间是独立的。哪个object文件应该包含给定类的vtbl呢?可能会认为放在包含main函数的object文件里,但是程序库没有main,而且无论如何包含main的源文件不会涉及很多需要vtbl的类。编译器如何知道它们被要求建立那一个vtbl呢?
必须采取一种不同的方法,编译器厂商为此分成两个阵营。对于提供集成开发环境(包含编译程序和连接程序)的厂商,一种干脆的方法是为每一个可能需要vtbl的object文件生成一个vtbl拷贝。连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个vtbl保留一个实例。
更普通的设计方法是采用启发式算法来决定哪一个object文件应该包含类的vtbl。通常启发式算法是这样的:要在一个object文件中生成一个类的vtbl,要求该object文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。因此上述C1类的vtbl将被放置到包含C1::~C1定义的object文件里(不是内联的函数),C2类的vtbl被放置到包含C1::~C2定义的object文件里(不是内联函数)。
实际当中,这种启发式算法效果很好。如果在类中的所有虚函数都内声明为内联函数,启发式算法就会失败,大多数基于启发式算法的编译器会在每个使用它的object文件中生成一个类的vtbl。在大型系统里,这会导致程序包含同一个类的成百上千个vtbl拷贝!大多数遵循这种启发式算法的编译器会给出一些方法来人工控制vtbl的生成,但是一种更好的解决此问题的方法是避免把虚函数声明为内联函数。下面将看到,有一些原因导致现在的编译器一般总是忽略虚函数的的inline指令。
Virtual table只实现了虚拟函数的一半机制,如果只有这些是没有用的。只有用某种方法指出每个对象对应的vtbl时,它们才能使用。这是virtual table pointer的工作,它来建立这种联系。
每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:
Data members for the object |
Object’s vptr |
它表示vptr位于对象的底部,但是不要被它欺骗,不同的编译器放置它的位置也不同。存在继承的情况下,一个对象的vptr经常被数据成员所包围。如果存在多继承(Multiple inherita-nce),这幅图片会变得更复杂。现在只需简单地记住虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,必须为额外的指针付出代价。
如果对象很小,这是一个很大的代价。比如如果对象平均只有4比特的成员数据,那么额外的vptr会使成员数据大小增加一倍(假设vptr大小为4比特)。在内存受到限制的系统里,这意味着必须减少建立对象的数量。即使在内存没有限制的系统里,也会发现这会降低软件的性能,因为较大的对象有可能不适合放在缓存(cache)或虚拟内存页中(virtual memory page),这就可能使得系统换页操作增多。
假如我们有一个程序,包含几个C1和C2对象。对象、vptr和刚才我们讲到的vtbl之间的关系,就很复杂.
考虑这段这段程序代码:
void makeACall(C1 *pC1)
{
pC1->f1();
}
通过指针pC1调用虚拟函数f1。仅仅看这段代码,不会知道它调用的是那一个f1函数――C1::f1或C2::f1,因为pC1可以指向C1对象也可以指向C2对象。尽管如此编译器仍然得为在makeACall的f1函数的调用生成代码,它必须确保无论pC1指向什么对象,函数的调用必须正确。编译器生成的代码会做如下这些事情:
1. 通过对象的vptr找到类的vtbl。这是一个简单的操作,因为编译器知道在对象内哪里能找到vptr(毕竟是由编译器放置的它们)。因此这个代价只是一个偏移调整(以得到vptr)和一个指针的间接寻址(以得到vtbl)。
2. 找到对应vtbl内的指向被调用函数的指针(在上例中是f1)。这也是很简单的,因为编译器为每个虚函数在vtbl内分配了一个唯一的索引。这步的代价只是在vtbl数组内的一个偏移。
3. 调用第二步找到的的指针所指向的函数。
如果假设每个对象有一个隐藏的数据叫做vptr,而且f1在vtbl中的索引为i,此语句
pC1->f1();
生成的代码就是这样的
(*pC1->vptr[i])(pC1);
// 调用被vtbl中第i个单元指向的函数,而pC1->vptr 指向的是vtbl;pC1被做为this指针传递给函数。
这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。
在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令,”但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”如果编译器在某个函数的调用点不知道具体是哪个函数被调用,就能知道为什么它不会内联该函数的调用。这是虚函数所需的第三个代价:实际上放弃了使用内联函数。(当通过对象调用的虚函数时,它可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。)
现在为止讨论的东西适用于单继承和多继承,但是多继承的引入,事情就会变得更加复杂。详细论述其细节,在多继承里,在对象里为寻找vptr而进行的偏移量计算会变得更复杂。在单个对象里有多个vptr(一个基类对应一个);除了已经讨论过的单独的vtbl以外,还得为基类生成特殊的vtbl。因此增加了每个类和每个对象中的虚函数额外占用的空间,而且运行时调用所需的代价也增加了一些。
多继承经常导致对虚基类的需求。没有虚基类,如果一个派生类有一个以上从基类的继承路径,基类的数据成员被复制到每一个继承类对象里,继承类与基类间的每条路径都有一个拷贝。程序员一般不会希望发生这种复制,而把基类定义为虚基类则可以消除这种复制。然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。
例如考虑下面这幅图,我经常称它为“恐怖的多继承菱形”(the dreaded multiple inheritance diamond)
class A{…}; A
class B: Virtual public A {…}; B C
class C: Virtual public A {…}; D
class D: public B,public C {…};
这里A是一个虚基类,因为B和C虚拟继承了它。使用一些编译器(特别是比较老的编译器),D对象会产生这样布局:
B Data Members |
Pointer to virtual base class |
C Data Members |
Pointer to virtual base class |
D Data Members |
A Data Members |
把基类的数据成员放在对象的最底端,这显得有些奇怪,但是它经常这么做。当然如何实现是编译器的自由,它们想怎么做都可以,这幅图只是虚基类如何导致对象需要额外指针的概念性描述,所以不应该在此范围以外还使用这幅图。一些编译器可能加入更少的指针,还有一些编译器会使用某种方法而根本不加入额外的指针(这种编译器让vptr和vtbl负担双重责任)。
如果我们把这幅图与前面展示如何把virtual table pointer加入到对象里的图片合并起来,我们就会认识到如果在上述继承体系里的基类A有任何虚函数,对象D的内存布局就是这样的:
B Data Members |
Vptr |
Pointer to virtual base class |
C Data Members |
Vptr |
Pointer to virtual base class |
D Data Members |
A Data Members |
Vptr |
这里对象中被编译器加入的部分,已经做了阴影处理。这幅图可能会有误导,因为阴影部分与非阴影部分之间的面积比例由类中数据量决定。对于小类,额外的代价就大。对于包含更多数据的类,相对来说额外的代价就不大,尽管也是值得注意的。
还有一点奇怪的是虽然存在四个类,但是上述图表只有三个vptr。只要编译器喜欢,当然可以生成四个vptr,但是三个已经足够了(它发现B和D能够共享一个vptr),大多数编译器会利用这个机会来减少编译器生成的额外负担。
现在已经看到虚函数能使对象变得更大,而且不能使用内联,我们已经测试过多继承和虚基类也会增加对象的大小。让我们转向最后一个话题,运行时类型识别(RTTI)。
RTTI能让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息,让我们查询。这些信息被存储在类型为type_info的对象里,你能通过使用typeid操作符访问一个类的type_info对象。
在每个类中仅仅需要一个RTTI的拷贝,但是必须有办法得到任何对象的信息。实际上这叙述得不是很准确。语言规范上这样描述:我们保证可以获得一个对象动态类型信息,如果该类型有至少一个虚函数。这使得RTTI数据似乎有些象virtual function talbe(虚函数表)。每个类只需要信息的一个拷贝,我们需要一种方法从任何包含虚函数的对象里获得合适的信息。这种RTTI和virtual function table之间的相似点并不是巧合:RTTI被设计为在类的vtbl基础上实现。
例如,vtbl数组的索引0处可以包含一个type_info对象的指针,这个对象属于该vtbl相对应的类。上述C1类的vtbl看上去象这样:
| -----------> C1’s type_info object |
| ---------->Implementation of C1::~C1 |
| ---------->Implementation of C1::f1 |
| ---------->Implementation of C1::f2 |
| ---------->Implementation of C1::f3 |
使用这种实现方法,RTTI耗费的空间是在每个类的vtbl中的占用的额外单元再加上存储type_info对象的空间。就象在多数程序里virtual table所占的内存空间并不值得注意一样,也不太可能因为type_info对象大小而遇到问题。
下面这个表各是对虚函数、多继承、虚基类以及RTTI所需主要代价的总结
Feature | Increases Size of Objects | Increases Per-Class Data | Reduces Inlining |
Virtual Functions | Yes | Yes | Yes |
Multiple Inheritance | Yes | Yes | No |
Virtual Base Classes | Often | Sometimes | No |
RTTI | No | Yes | No |
看到这个表格以后,会很吃惊,有的宣布“还是应该使用C”。很好。但是请记住如果没有这些特性所提供的功能,你必须手工编码来实现。在多数情况下,你的人工模拟可能比编译器生成的代码效率更低,稳定性更差。例如使用嵌套的switch语句或层叠的if-then-else语句模拟虚函数的调用,其产生的代码比虚函数的调用还要多,而且代码运行速度也更慢。再有,你必须自己人工跟踪对象类型,这意味着对象会携带它们自己的类型标签(type tag)。因此你不会得到更小的对象。
理解虚函数、多继承、虚基类、RTTI所需的代价是重要的,但是如果需要这些功能,不管采取什么样的方法都得为此付出代价,理解这点也同样重要。有时确实有一些合理的原因要绕过编译器生成的服务。例如隐藏的vptr和指向虚基类的指针会使得在数据库中存储C++对象或跨进程移动它们变得困难,所以可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,自己编写代码不可能做得比编译器生成的代码更好。