C++虚函数、多继承和虚基类学习心得
前段时间一直在学习C++中对象的内存布局,由于C++中支持多继承和虚继承,使得对象的内存布局可能变得有些复杂,刚开始去学习时会有点摸不着头脑。另外不同的编译器很可能有着不同的内存布局,进一步加大了学习难度。
网上已经有很多讲解内存布局的文章,其中很多讲得很清楚了,如:
http://blog.csdn.net/haoel/article/details/1948051
http://www.cnblogs.com/itech/archive/2009/02/27/1399996.html
http://www.cnblogs.com/neoragex2002/archive/2007/11/01/VC8_Object_Layout_Secret.html
上面三篇文章基本上对C++内存布局进行了足够多的介绍,大家可以参考。
最近我无意中翻开Scott Mayers的著作《More Effective C++》,发现Item24 : Understand the costs of virtual functions, multiple inheritance, virtual base classes, and RTTI,对虚函数、多继承和虚基类的内存布局和开销进行了分析。个人感觉讲得高屋建瓴,既通俗易懂,又没有陷入具体编译器实现的细节,所以根据作者的意思,加上自己的理解,跟大家分享。如有不足之处,还请大家参考Mayers大神的原文。
我们知道C++中有很多的语言特性,这是由C++的定位所决定的。这些特性是让不少初学者望而却步的原因之一,也是C++强大功能的一个缩影。
对于编译器的厂商来说,必须找到一种方式来实现C++中的每一个语言特性。当然了,实现方法有很多种,所以各个编译器都有自己的独特之处。C++程序员固然不需要去了解编译器实现的方方面面,但是对于一些技术有一个大概的了解还是非常重要的,因为对于某种语言特性的正确认识可以帮助你写出更优秀的代码。
作为C++中实现多态的重要方式,虚函数在被调用的时候,具体执行的代码必须能够找到对应的对象的动态类型,从而调用正确的函数。为了做到这一点,大部分编译器都是通过虚函数表(vtbl)和虚函数表指针(vptr)来实现的。
一个虚函数表一般而言是一个函数指针数组,当然也有可能是用链表实现的。程序中每一个声明或者继承了虚函数的类都有自己的虚函数表,虚函数表中的每个entry是指向这个类的虚函数实现的指针。
比如,一个类的定义如下:
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的虚函数表下图所示:
注意:函数f4不在虚函数表中,C1的构造函数也不在。即非虚函数——包括构造函数(构造函数概念上不可能是虚函数)的实现和普通的C函数实现一样,调用这些函数没有特别的开销。因为他们在编译期间就可以确定,不需要在运行时刻去寻找。
如果C2继承了C1,重新定义了继承来了虚函数,或者加入了一些新的虚函数:
class C2 { public: C2(); virtual ~C2(); virtual void f1(); virtual void f5(char* str); ... };
C2的虚函数表可以表示为:
以上的讨论就告诉了我们虚函数的第一个开销:每一个包含虚函数的类都需要专门的空间存放这个类的虚函数表。
当然了,因为虚函数表是类层次的,并不是对象层次的,所以这个开销应该不算特别大。
虚函数表本身是没有用的,一个运行时对象能够找到与它对应的虚函数表才能够实现多态,因此编译器通过虚函数表指针来建立对象与虚函数之间的对应关系。
每一个定义了虚函数的类的对象都包含了一个隐藏的字段(vptr)指向对应的类的虚函数表,这个字段是由编译器加在对象的某个位置上(具体编译器实现不一样)。概念上看,一个定义了虚函数的类的对象的布局如图所示:
图中vptr在对象的最后面,但是各个编译器可能放在不同的位置。此时,注意虚函数的第二个开销:需要在包含虚函数的类的每个对象中放置一个额外的指针。
这意味着类的每一个对象都会比原来的要大,占用更多的内存,即使系统的内存是充足的,也有可能导致性能的下降。因为越大的对象就越不容易在cache或虚拟内存的页中完整地存在,这样换页的概率就会增加,从而降低效率。
考虑如下的程序片段:
void makeACall(C1 *pC1) { pC1->f1(); }
这是一个通过指针pC1访问虚函数f1的调用。仅仅通过代码我们是无法知道哪个f1被调用的——C1::f1或者C2::f1,因为pC1有可能指向一个C1对象,也有可能指向一个C2对象。所以编译器必须在makeACall()内部生成额外的代码来确保调用正确的f1。整个流程如下:
1、通过vptr找到对象的vtbl;
2、找到vtbl中对应函数的指针;
3、调用2中指针指向的函数。
即编译器为
pC1->f1();
生成的代码是:
(*pC1->vptr[i])(pC1)
这段代码和非虚函数的调用效率几乎没有差别,在多数机器上,只是多执行了几条指令而已。调用虚函数的开销基本上和通过函数指针访问效率相同。也就是说,虚函数本身并不是性能的瓶颈。
虚函数的运行时开销本质上是因为内联的原因。对于所有实际的应用中,虚函数都是非内联的。这从概念上容易理解:内联意味着“在编译期间,使用被调用的函数体去代替函数调用”,但是virtual却意味着“等到运行时刻采取确定调用哪个函数”。编译器在编译期间不知道某个调用点具体调用某个函数,也就没办法将函数内联。这是虚函数第三个开销:必须放弃内联。
上面所讲的内容适用于单继承和多继承,但是随着多继承映入我们的眼帘,事情就变得复杂了。虽然说没有必要去深究细节,但是有了多继承以后,在对象内部通过计算偏移来寻找vptr变得更加复杂;除了之前提到的独立的vtbl,对于每个基类都要生成专门的vtbl。因此,虚函数带来的类层次和对象层次的空间开销都变大了,同时运行期间的调用开销也略有增加。
多继承往往会需要用到虚基类。如果没有虚基类,如果一个子类不止有一条继承路径到同一个基类,那么这个基类中的数据成员就会出现冗余,这种冗余一般是程序员不想要的。如果虚继承的方式,就可以避免这种冗余。虚基类本身可能会带来一些开销,但是虚基类的实现一般会使用指针指向其虚基类的部分来消除冗余,一个或多个这样的指针会存储在对象之中。
比如,一个非常出名的钻石型继承:
class A { ... }; class B : virtual public A { ... }; class C : virtual public A { ... }; class D : public B, public C { ... };
其中A是虚基类,因为B和C都从它虚继承过来。对于某些编译器来说(尤其是那些较老的编译器),D类型的一个对象的内存布局很可能看起来像这样:
把基类的数据成员放在对象的最后面看上去可能会有点奇怪,但是编译器一般都是这样实现的。当然,编译器的实现可以按照它们自己的方式来组织内存,所以这个图仅仅给出了一个概念性的总览,它告诉你虚基类可能会在对象中增加一个隐藏的指针。有一些实现加的指针较少,有些实现甚至可以不加指针。(这些实现的vptr和vtbl同时承担了双重作用)。
如果把这张图和之前虚函数表指针的图合在一起,我们就意识到,如果基类A如果包含虚函数的话,D类型的对象的内存布局就应该像这样:
图中可能看起来比较奇怪的地方是,一共有涉及到4个类,却只有3个vptr。具体的编译器实现可以产生4个vptr,但是3个vptr已经足够了(B和D共享一个vptr),多数编译器通过这种方式来减少空间开销。
目前为止,我们知道了虚函数使得对象变大,并且阻止了内联;虚继承和虚基类也可能增加对象的大小。下面会提到最后一个主题,运行时类型检测(RTTI)的开销。
RTTI允许我们在运行时刻知道对象的类型信息,所以必须在某个地方存储这样的信息以便我们去查询。这样的信息被放在一个type_info类型的对象中,可以通过typeid操作符来访问对象的type_info对象。
对于每一个类来说,只需要一份RTTI信息的拷贝,但是必须有一种方法使得类的每一个对象都能找到这个信息。事实上,这个说法并不正确。C++的specification中指出,编译器应当提供对象的确切的动态类型当且仅当这个类至少有一个虚函数(某些时候,为了使得对象拥有动态类型,可以简单地将其析构函数定义为virtual)。从这个意义上来说,RTTI和虚函数表的信息很类似。这种类似不是巧合,RTTI可以在类的虚函数表中实现。
例如,前面的C1加上RTTI信息以后,虚函数可能如下所示:
这样的实现的空间开销是每一个类的vtbl多了一个条目,以及每个类增加了type_info的对象。这个开销通常来说是比较小的。
下面这张表总结了虚函数、多继承、虚基类、以及RTTI的主要开销:
特性 | 增加对象大小 | 增加类的大小 | 减少内联 |
虚函数 | 是 | 是 | 是 |
多继承 | 是 | 是 | 否 |
虚基类 | 经常 | 有时 | 否 |
RTTI | 否 | 是 | 否 |
下面这段总结我感觉非常有道理:
有人看到这张表以后会说,”我坚持使用C“。这种想法很正常,但是记住:如果要通过C来实现以上类似的功能,你必须自己手动写很多代码。在绝大多数情况下,手写的代码效率不会比编译器自动生成的代码效率高或者更健壮。使用switch语句或者级联的if-then-else来模拟虚函数调用,会产生更多的代码,代码运行也不会快。这意味着,你自己写的对象要自己保存着类型信息,这很可能产生更大的对象。
认识到虚函数、多继承、虚基类和RTTI的带来的开销是非常重要的,同样重要的是,要认识到如果你需要使用到这些特性提供的功能,你就必须付出一定的代价,反之亦然。有的时候,你有合理的理由不使用编译器提供的服务,比如隐藏的虚基类和指向虚基类的指针使得在数据库中存储C++对象变得困难,所以会通过其它方式手工模拟以完成这些任务。但是从效率的角度来看,你手写的代码未必会比编译器产生的代码效率更高。