虚拟函数、多继承、虚基类和RTTI需要的代价
2007-03-01 17:07
239 查看
当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用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 |
如果有一个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 |
因为在程序里每个类只需要一个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 |
如果对象很小,这是一个很大的代价。比如如果对象平均只有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 |
如果我们把这幅图与前面展示如何把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所需主要代价的总结
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++对象或跨进程移动它们变得困难,所以可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,自己编写代码不可能做得比编译器生成的代码更好。
相关文章推荐
- 虚拟函数、多继承、虚基类和RTTI需要的代价
- C++专题总结之理解虚拟函数、多继承、虚基类和RTTI
- More Effective C++----(24)理解虚拟函数、多继承、虚继承和RTTI所需的代价
- 理解虚函数,多重继承,虚基类和RTTI所需的代价
- 12.30分钟搞定虚函数,多继承,虚基类和RTTI
- 虚拟函数、多继承、虚基类(转)
- NSCoding 协议 父类只需要实现一次,所有子类 都可以 继承 的 runtime特性
- javascript 继承 constructor 需要注意点
- java-面向对象之继承(感言:经过第一天和Java中类的继承和接口的实现的接触,自己需要学习的还很多,还要从基础抓起,尽管有点迷糊,但学到很多很喜欢这种时光!加油!)下面是查到的相关资料以备查看。
- 继承System.Web.Page类需要覆写的3个方法
- 在Android中,可以自定义类,继承ViewGroup等容器类,以实现自己需要的布局显示。
- 直接继承View来自定义控件时,需要重写onMeasure()方法并设置wrap_content时的大小 原理分析
- 继承练习 :开发一个系统时 需要对员工进行建模 员工包含3个属性 姓名 工号 工资 功能 work
- 使用RTTI为继承体系编写”==”运算符
- js中的原型继承需要注意的问题
- 成功是需要付出代价的: 32个成功观念分享
- 为什么多重继承中需要在"执行期"调整this指针
- C/C++开发语言系列之4---普通继承和虚基类构造函数的初始化顺序1
- virtual继承, 虚基类
- 什么是虚继承?虚基类?