理解虚函数,多重继承,虚基类和RTTI所需的代价

这是摘自《More Effective C++ 2007》

条款24:理解虚函数,多重继承,虚基类和RTTI所需的代价

当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用 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 数组看起来如下图所示:

注意非虚函数 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 虚函数的指针:

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

这幅图片表示 vptr 位于对象的底部,但是不要被它欺骗,不同的编译器放置它的位置也不同。存在继承的情况下,一个对象的 vptr 经常被数据成员所包围。如果存在多继承(Multiple inheritance),这幅图片会变得更复杂,等会儿我们将讨论它。现在只需简单地记住虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。

如果对象很小,这是一个很大的代价。比如如果你的对象平均只有 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 multipleinheritance diamond)

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

把基类的数据成员放在对象的最底端,这显得有些奇怪,但是它经常这么做。当然如何实现是编译器的自由,它们想怎么做都可以,这幅图只是虚基类如何导致对象需要额外指针的概念性描述,所以你不应该在此范围以外还使用这幅图。一些编译器可能加入更少的指针,还有一些编译器会使用某种方法而根本不加入额外的指针(这种编译器让 vptr 和 vtbl负担双重责任)。

如果我们把这幅图与前面展示如何把 virtual table pointer 加入到对象里的图片合并起来,我们就会认识到如果在上述继承体系里的基类 A 有任何虚函数,对象 D 的内存布局就是这样的:

 

这里对象中被编译器加入的部分,我已经做了阴影处理。这幅图可能会有误导,因为阴影部分与非阴影部分之间的面积比例由类中数据量决定。对于小类,额外的代价就大。对于包含更多数据的类,相对来说额外的代价就不大,尽管也是值得注意的。还有一点奇怪的是虽然存在四个类,但是上述图表只有三个 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 看上去象这样:

使用这种实现方法,RTTI 耗费的空间是在每个类的 vtbl 中的占用的额外单元再加上存储 type_info 对象的空间。就象在多数程序里 virtual table 所占的内存空间并不值得注意一样,你也不太可能因为type_info 对象大小而遇到问题。

下面这个表各是对虚函数、多继承、虚基类以及 RTTI 所需主要代价的总结:

一些人看到这个表格以后,会很吃惊,他们宣布"我还是应该使用 C"。很好。但是请记住如果没有这些特性所提供的功能,你必须手工编码来实现。在多数情况下,你的人工模拟可能比编译器生成的代码效率更低,稳定性更差。例如使用嵌套的 switch 语句或层叠的if-then-else 语句模拟虚函数的调用,其产生的代码比虚函数的调用还要多,而且代码运行速度也更慢。再有,你必须自己人工跟踪对象类型,这意味着对象会携带它们自己的类型标签(type tag)。因此你不会得到更小的对象。

理解虚函数、多继承、虚基类、RTTI 所需的代价是重要的,但是如果你需要这些功能,不管采取什么样的方法你都得为此付出代价,理解这点也同样重要。有时你确实有一些合理的原因要绕过编译器生成的服务。例如隐藏的 vptr 和指向虚基类的指针会使得在数据库中存储 C++对象或跨进程移动它们变得困难,所以你可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,你自己编写代码不可能做得比编译器生成的代码更好。

posted @ 2012-08-19 13:45  Mr.Rico  阅读(976)  评论(0编辑  收藏  举报