虚函数的底层实现机制

发布于 2021-03-20  1493 次阅读


C++在基类中声明一个带关键之Virtual的函数,这个函数叫虚函数;它可以在该基类的派生类中被重新定义并被赋予另外一种处理功能。通过指向指向派生类的基类指针或引用调用虚函数,编译器可以根据指向对象的类型在运行时决定调用的目标函数。这就实现了多态。

编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这种数组成为虚函数表(virtual function table, vtbl),即,每个类使用一个虚函数表,每个类对象用一个虚表指针。

举个例子:基类对象包含一个虚表指针,指向基类中所有虚函数的地址表。派生类对象也将包含一个虚表指针,指向派生类虚函数表。看下面两种情况:

1.如果派生类重写了基类的虚方法,该派生类虚函数表将保存重写的虚函数的地址,而不是基类的虚函数地址。

2.如果基类中的虚方法没有在派生类中重写,那么派生类将继承基类中的虚方法,而且派生类中虚函数表将保存基类中未被重写的虚函数的地址。注意,如果派生类中定义了新的虚方法,则该虚函数的地址也将被添加到派生类虚函数表中。

这个Instrument指针数组没有特殊类型信息,它的每一个元素都指向一个类型为Instrument的对象。Wind、Percussion、Stringed和Brass都可以归入这个类别之中,因为它们都是从Instrument派生来的(并因而与Instrument有相同的接口和可以响应相同的消息),所以它们的地址自然被放进这个数组。然而,编译器并不知道它们是比Instrument对象具有更多内容的东西,所以,就将它们留给其自己的设备处理,而通常调用所有函数的基类版本。但在这里,所有这些函数都被用virtual声明,所以出现了不同的情况。

每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个惟一的VTABLE,如这个图的右面所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在Brass 的VTABLE中,adjust的入口就是这种情况。)然后编译器在这个类中放置VPTR(可在 Sizes.ccp中发现)。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE的起始地址。

一旦VPTR被初始化为指向相应的VTABLE,对象就“知道”它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。当通过基类地址调用一个虚函数(此时编译器没有能完成早捆绑所需的所有信息),要特殊处理。它不是实现典型的函数调用,那样只是简单地用汇编语言CALL特定的地址,而是编译器为完成这个函数调用而产生不同的代码。下面看到的是通过Instrument指针对于Brass调用adjust()(Instrument引用产生同样的结果)。

编译器从这个Instrument指针开始,这个指针指向这个对象的起始地址。对于所有的Instrument对象或由Instrument派生的对象,它们的VPTR都在对象的相同位置(常常在对象的开头),所以编译器能够取出这个对象的VPTR。VPTR指向VTABLE的起始地址。所有的VTABLE都具有相同的顺序,不管何种类型的对象。play()是第一个,what()是第二个,adjust()是第三个。所以无论什么特殊的对象类型,编译器都知道adjust()函数必在VPTR+2处。这样,不是“以Instrument:adjust地址调用这个函数”(这是早捆绑,是错误动作),而是产生代码,即实际上“在VPTR+2处调用这个函数”。因为获取 VPTR和确定实际函数地址发生在运行时,所以这样就得到了所希望的晚捆绑。我们向这个对象发送消息,随后这个对象能断定它应当做什么。

源代码如下:

针对Brass和Woodwind, adjust()函数没有重写(重新定义)。当出现这种情况时,将会自动地调用继承层次中“最近”的定义—编译器保证对于虚函数总是有某种定义,所以决不会出现最终调用不与函数体捆绑的情况.