More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价

当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致

编译器如何能够高效地提供这种行为呢?大多数编译器是使用 virtual tablevirtual table pointersvirtual tablevirtual table pointers 通常被分别地称为 vtblvptr

一个 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 数组看起来如下图所示:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第1张图片
如果有一个 C2 类继承自 C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,

class C2: public C1 
{ 
public: 
	C2(); // 非虚函数 
	virtual ~C2(); // 重定义函数 
	virtual void f1();
	virtual void f5(char *str); // 新的虚函数 
	... 
};

它的 virtual table 项目指向与对象相适合的函数。这些项目包括指向没有被 C2 重定义的 C1 虚函数的指针:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第2张图片
你必须为每个包含虚函数的类的 virtual talbe 留出空间。类的 vtbl 的大小与类中声明的虚函数的数量成正比。每个类应该只有一个 virtual table,所以 virtual table 所需的空间不会太大,但是如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。

因为在程序里每个类只需要一个 vtbl 拷贝,所以编译器肯定会遇到一个棘手的问题:把它放在哪里。

大多数程序和程序库由多个 object(目标)文件连接而成,但是每个 object文件之间是独立的。哪个 object 文件应该包含给定类的 vtbl 呢?

必须采取一种不同的方法,编译器厂商为此分成两个阵营。对于提供集成开发环境(包含编译程序和链接程序)的厂商,一种干脆的方法就是为每一个可能需要 vtblobject 文件生成一个 vtbl 拷贝。连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个 vtbl 保留一个实例。

更普通的设计方法是采用启发式算法来决定哪一个 object 文件应该包含类的 vtbl。通常启发式算法是这样的:要在一个 object 文件中生成一个类的 vtbl,要求该 object 文件包含该类的第一个非内联、非纯虚拟函数定义(也就是类的实现体)。因此上述 C1 类的 vtbl 将被放置到包含 C1::~C1 定义的 object 文件里(不是内联的函数),C2 类的 vtbl 被放置到包含 C1::~C2 定义的 object 文件里(不是内联函数)。

Virtual table只实现了虚拟函数的一半机制,如果只有这些是没有用的。只有用某种方法指出每个对象对应的vtbl时,它们才能使用。这是virtual table pointer的工作,它来建立这种联系。

每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:

More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第3张图片
不同的编译器放置vptr的位置不同。存在继承的情况下,一个对象的 vptr 经常被数据成员所包围。如果存在多继承,这幅图片会变得更复杂.
虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。

如果对象很小,这是一个很大的代价。比如如果你的对象平均只有 4 比特的成员数据,那么额外的 vptr 会使成员数据大小增加一倍(假设 vptr 大小为 4 比特)。在内存受到限制的系统里,这意味着你必须减少建立对象的数量。即使在内存没有限制的系统里,你也会发现这会降低软件的性能,因为较大的对象有可能不适合放在缓存或虚拟内存页中,这就可能使得系统换页操作增多.

举个例子:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第4张图片
考虑下面代码:

void makeACall(C1 *pC1) 
{ 
	pC1->f1(); 
}

通过指针 pC1 调用虚拟函数 f1
为了确保无论 pC1 指向什么对象,函数的调用必须正确。编译器生成的代码会做如下这些事情:
1.通过对象的 vptr 找到类的 vtbl。这是一个简单的操作,因为编译器知道在对象内哪里能找到 vptr。这个代价只是一个偏移调整(以得到vptr)和一个指针的间接寻址(以得到 vtbl)。

2.找到对应 vtbl 内的指向被调用函数的指针(在上例中是 f1).
3.调用第二步找到的的指针所指向的函数。

如果我们假设每个对象有一个隐藏的数据叫做 vptr,而且 f1vtbl 中的索引为 i,此语句:

pC1->f1();

生成的代码就是这样的:

(*pC1->vptr[i])(pC1);//调用被vtbl中第i个单元指向的函数,而 pC1->vptr 指向的是 vtbl;pC1 被做为this指针传递给函数。

这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。

在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令,”但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”如果编译器在某个函数的调用点不知道具体是哪个函数被调用,你就能知道为什么它不会内联该函数的调用

这是虚函数所需的第三个代价:你实际上放弃了使用内联函数。

多继承里,在对象里为寻找 vptr 而进行的偏移量计算会变得更复杂。在单个对象里有多个 vptr(每一个基类对应一个);除了我们已经讨论过的单独的自己的 vtbl 以外,还得为基类生成特殊的 vtbl。因此增加了每个类和每个对象中的虚函数额外占用的空间,而且运行时调用所需的代价也增加了一些。

例如考虑下面这幅图:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第5张图片
这里是A是一个虚基类。
因为 B 和 C 虚拟继承了它。使用一些编译器(特别是比较老的编译器),D 对象会产生这样布局:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第6张图片

如果我们把这幅图与前面展示如何把 virtual table pointer 加入到对象里的图片合并起来,我们就会认识到如果在上述继承体系里的基类 A 有任何虚函数,对象 D 的内存布局就是这样的:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第7张图片
值得注意的是:
虽然存在四个类,但是上述图表只有三个 vptr。只要编译器喜欢,
当然可以生成四个 vptr,但是三个已经足够了(它发现 BD 能够共享一个 vptr),大多数编译器会利用这个机会来减少编译器生成的额外负担。

运行时类型识别

RTTI 能让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息让我们查询。这些信息被存储在类型为 type_info 的对象里,你能通过使用 typeid 操作符访问一个类的 type_info 对象。

在每个类中仅仅需要一个 RTTI 的拷贝。

例如,vtbl 数组的索引 0 处可以包含一个 type_info 对象的指针,这个对象属于该 vtbl相对应的类。上述 C1 类的 vtbl 看上去像这样:
More Effective C++ 24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价_第8张图片

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

总结

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

你可能感兴趣的:(More,Effective,C++)