24 理解虚函数、多重继承、虚基类和RTTI的代价

当调用一个虚函数时,一般都是使用了virtual table和virtual table pointer,这两个简称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数组看起来如下图:

 注意非虚函数不在表中,而且C1得构造函数也不在内。

如果一个C2继承自C1,重新定了它继承的一些虚函数,并加入了自己的一些虚函数:

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

 它的virtual table指向与对象相对应的函数,这些项目包括指向没有被C2重定义的C1虚函数的指针:

这个论述引出了虚函数所需的第一个代价:你必须为每一个包含虚函数的的类的virtual table留出空间。类的vtlb的大小和类中声明的虚函数的数量成正比(包括冲基类继承的虚函数)。每个类因该只有一个vtbl,所以vtbl所需空间不会太大,但是如果你有大量的的类或者在每个类有大量的虚函数,你会发现vtbl会占用大量的地址空间。

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

每个声明了虚函数的对象都有它,它是一个看不见的成员,指向对应的类的virtual table,我们称之为vptr,被编译器加在对象里,位置只有编译器知道。理论上讲,对象的大概布局如下:

 

不用的编译器放置它的位置不同。存在继承的情况下,一个对象的vptr经常被数据成员所包围。多重继承更加复杂。

虚函数的第二个代价:在每一个包含虚函数的类的对象里,你必须额外指针付出代价。

对象太大可能不适合放在缓存(cache)货虚拟内存页(virtual memory page),这就可能使系统换页操作增多。

假如我们有一个程序,包含几个C1对象和C2对象。对象、vptr和vtbl之间的关系,大概如下:

 

虚函数的所需的代价与内联有关。实际上虚函数不能内联的。”内联“:在编译器用被调用的函数本身来替换函数调用的命令。”虚函数“知道运行期才知道该调用哪个函数。所以虚函数的第三个代价:放弃了使用内联函数。

多重继承一般会导致虚基类的要求。没有虚基类,如果一个派生类有一个以上的基类的集成路径,基类的数据成员会被复制到每一个派生类对象里,继承类与基类间都有一个拷贝。虚基类可以解决这个问题。

虚基类也会有自己的代价,因为虚基类的实现经常使用指向虚基类的指针作为避免复制的手段,一个或者更多的的指针存储在对象里。

例如下面的图,钻石继承:

 

这里A是虚基类,B和C继承了它。一般编译器,D对象会产生这样的布局:

如果此图和前面展示的vptr加入对象的图结合在一起,我们就认识如果在钻石继承体系中A有任何虚函数,对象D的内存布局如下:

 

图中虽然四个类,但是上述只有三个vptr,只要编译器喜欢,当然可以生成四个vptr,三个足够了(B和D共享一个vptr),大多数编译器会利用这样的机会来减少编译器的负担。

RTTI能让我们在运行时找到对象的和类的有关信息。RTTI被设计在类的vtbl基础上实现的。 

例如,vtbl数组的的索引为0可以包含一个type_info对象的指针,这个对象数以该vtbl相对应的类。上述C1的vtbl看上去如下图:

总结:

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

猜你喜欢

转载自blog.csdn.net/weixin_28712713/article/details/81451023