12.30分钟搞定虚函数,多继承,虚基类和RTTI

C++的语言特性是由编译器来实现的,实现细节也由编译器来决定,对于不同的编译器,对语言特性的实现可能也不尽相同。虽然在大多数情况下,你是不用关心他们具体是如何实现的,但是有些特性的实现,对对象的大小和成员函数执行速度有很大影响,所以我们有必要了解一下编译器在背后为我们做了些什么。
1.虚函数
当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用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数组看起来如下图所示:
图1

注意只有虚函数才能在表中出现。
如果有一个C2类继承自C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,
class C2: public C1 {
public:
C2(); // 非虚函数
virtual ~C2(); // 重定义函数
virtual void f1(); // 重定义函数
virtual void f5(char *str); // 新的虚函数
...
};
它的virtual table项目指向与对象相适合的函数。这些项目包括指向没有被C2重定义的C1虚函数的指针:
图2

虚函数所要考虑的第一个代价:空间问题
你必须为每个包含虚函数的类的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,被编译器加在对象里,位置只有编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:

图3


虽然这幅图vptr位于对象的底部,但是不同的编译器放置的位置是不一样的,也有可能位于顶端。
存在继承的情况下,一个对象的vptr经常被数据成员所包围。如果存在多继承(Multiple inheritance),这幅图片会变得更复杂,等会儿我们将讨论它。现在只需简单地记住虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。
如果对象很小,这是一个很大的代价。比如如果你的对象平均只有4比特的成员数据,那么额外的vptr会使成员数据大小增加一倍(假设vptr大小为4比特)。在内存受到限制的系统里,这意味着你必须减少建立对象的数量。即使在内存没有限制的系统里,你也会发现这会降低软件的性能,因为较大的对象有可能不适合放在缓存(cache)或虚拟内存页中(virtual memory page),这就可能使得系统换页操作增多。
假如我们有一个程序,包含几个C1和C2对象。对象、vptr和刚才我们讲述的vtbl之间的关系,在程序里我们可以这样去想象:
图4

考虑这段这段程序代码:
void makeACall(C1 *pC1)
{
pC1->f1();
}
通过pC1调用虚函数f1.仅通过这段代码我们并不能确定它调用的是C1::f1还是C2::f1。因为pC1可以指向C1的对象也可以指向C2的对象。为了确保函数调用正确,编译器做了一些列的额外工作。
1.通过对象自己的vptr找到类的vtbl。
这个过程的代价是:
一个偏移调整(得到vptr)
一个指针的间接寻址(得到vtbl)
2.找到vtbl内的指向被调用函数的指针(f1)。
编译器为每个虚函数在vtbl内分配了一个唯一的索引。
代价是:在vtbl数组内的一个偏移。
3.调用第二步找到的指针所指向的函数
假设隐藏的虚函数表指针是vptr
函数f1在vtbl中的索引是i
执行pC1->f1();
编译器生成的代码会是:
(*pC1->vptr[i])(pC1);
pC1->vptr这步调用获得了vtbl的地址
pC1->vptr[i],通过索引vtbl获得函数f1的地址
最后将pC1作为this指针传递给函数
这里调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。
虚函数的第三个代价:你实际上需要放弃对虚函数使用内联。(当通过对象调用虚函数时,它可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。)
实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令,”但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”如果编译器在某个函数的调用点不知道具体是哪个函数被调用,你就能知道为什么它不会内联该函数的调用。
当引入多继承时,情况会变得更加复杂。这是因为在多继承里,寻找vptr而进行的偏移量计算会更复杂。因为在单个对象中会有多个vptr(每个基类对应一个)
多继承经常会导致对虚基类的需求。
没有虚基类,如果一个派生类的若干个基类是从相同的另外的基类继承而来,那么最原始的基类的数据成员在这个最高级的派生类中会有多个拷贝。一般我们不希望发生这种复制,那么可以通过把基类定义为虚基类就可消除这种复制。
然而虚基类本身会引起它们自己的代价,因为虚基类的实现经常使用指向虚基类的指针做为避免复制的手段,一个或者更多的指针被存储在对象里。
例如考虑下面这幅图,我经常称它为“恐怖的多继承菱形”(the dreaded multiple inheritance diamond)

图5


这里A是一个虚基类,因为B和C虚拟继承了它。使用一些编译器(特别是比较老的编译器),D对象会产生这样布局:

图6


这幅图对虚基类如何导致对象需要额外指针进行了概念性的描述。一些编译器可能加入更少的指针,还有一些编译器会使用某种方法而根本不加入额外的指针(这种编译器让vptr和vtbl负担双重责任)。
如果我们把这幅图与前面展示如何把virtual table pointer加入到对象里的图片合并起来,我们就会认识到如果在上述继承体系里的基类A有任何虚函数,对象D的内存布局就是这样的:

图7


这里阴影部分是被编译器加入的部分。
我们现在已经看到虚函数能使对象变得更大,而且不能使用内联,我们已经测试过多继承和虚基类也会增加对象的大小。
让我们转向最后一个话题,运行时类型识别(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看上去象这样:

图8


使用这种实现方法,RTTI耗费的空间是在每个类的vtbl中的占用的额外单元再加上存储type_info对象的空间。就象在多数程序里virtual table所占的内存空间并不值得注意一样,你也不太可能因为type_info对象大小而遇到问题。
下面这个表各是对虚函数、多继承、虚基类以及RTTI所需主要代价的总结:

表1


一些人看到这个表格以后,会很吃惊,他们宣布“我还是应该使用C”。很好。但是请记住如果没有这些特性所提供的功能,你必须手工编码来实现。在多数情况下,你的人工模拟可能比编译器生成的代码效率更低,稳定性更差。例如使用嵌套的switch语句或层叠的if-then-else语句模拟虚函数的调用,其产生的代码比虚函数的调用还要多,而且代码运行速度也更慢。再有,你必须自己人工跟踪对象类型,这意味着对象会携带它们自己的类型标签(type tag)。因此你不会得到更小的对象。
理解虚函数、多继承、虚基类、RTTI所需的代价是重要的,但是如果你需要这些功能,不管采取什么样的方法你都得为此付出代价,理解这点也同样重要。有时你确实有一些合理的原因要绕过编译器生成的服务。例如隐藏的vptr和指向虚基类的指针会使得在数据库中存储C++对象或跨进程移动它们变得困难,所以你可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,你自己编写代码不可能做得比编译器生成的代码更好。
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白话机器学习

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值