C++从虚函数表的底层来看虚函数调用问题

来源:互联网 发布:js重新加载div 编辑:程序博客网 时间:2024/06/06 00:54

原本以为自己对虚函数掌握的还可以,结果前几天面试的时候被问了一个基类指针操作派生类对象的时候,构造和析构函数里调用虚函数的时候,调用的是基类的还是派生类的。结果就给答错了,我当时觉得一个类里面的虚函数表覆盖的函数已经只能指向派生类的。因为当时我觉得,面试官提醒在构造和析构函数调用的时候,派生类还不存在或者已经被析构了,还怎么能调用呢?
于是我就打算再从底层理解一下虚函数问题

1.构造/析构函数函数中调用虚函数

一如上面面试官所说,在构造和析构函数调用的时候,派生类还不存在或者已经被析构了,是不能调用派生类的虚函数的。所以这道题的标准答案是:
在构造/析构函数函数中调用虚函数,是不会出现多态的现象,而是调用其自身【基类】的虚函数
但是我当时疑惑的事情在于,因为虚函数的调用需要用到虚函数表,而在派生类中,其虚函数表中派生类相应的虚函数地址已经覆盖了基类的对应的虚函数地址,那么如何才能够找到已经被覆盖的虚函数地址的呢?
我查了一下各种资料,发现【1】在构造函数/析构函数中调用虚函数的例子能够很好的说明这个问题。
代码可以去原帖中看最后一段代码
因为类实例地址里面,虚函数表的指针时存在开头的位置,所以int* vt = (int*)*((int*)this);这一步是取得其虚函数表的指针。
在生成派生类的对象时,是先调用了基类的构造函数,再调用派生类的构造函数,析构函数的调用跟构造函数的调用顺序是相反的,它从最派生类的析构函数开始的。也就是说当基类的析构函数执行时,派生类的析构函数已经执行。
所以在基类和派生类的构造函数和析构函数都调用一个打印当前this指针地址【表示这个类实例的地址】,答应指向虚函数表的指针的指向位置。

#include <iostream>class Base{public:    Base() { PrintBase(); }    virtual ~Base() { PrintBase(); }    void PrintBase()    {        std::cout << "Address of Base: " << this << std::endl;        // 虚表的地址存在对象内存空间里的头4个字节        int* vt = (int*)*((int*)this);        std::cout << "Address of Base Vtable: " << vt << std::endl;        // 通过vt来调用Foo函数,以证明vt指向的确实是虚函数表        std::cout << "Call Foo by vt -> ";        void(*pFoo)(Base* const) = (void(*)(Base* const))vt[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前        (*pFoo)(this);        std::cout << std::endl;    }    virtual void  Foo() { std::cout << "Base" << std::endl; }};class Derive : public Base{public:    Derive() : Base() { PrintDerive(); }    virtual ~Derive() { PrintDerive(); }    void PrintDerive()    {        std::cout << "Address of Derive: " << this << std::endl;        // 虚表的地址存在对象内存空间里的头4个字节        int* vt = (int*)*((int*)this);        std::cout << "Address of Derive Vtable: " << vt << std::endl;        // 通过vt来调用Foo函数,以证明vt指向的确实是虚函数表        std::cout << "Call Foo by vt -> ";        void(*pFoo)(Base* const) = (void(*)(Base* const))vt[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前        (*pFoo)(this);        std::cout << std::endl;    }    virtual void Foo() { std::cout << "Derive" << std::endl; }};int main(){    Base* p = new Derive();    delete p;    system("pause");    return 0;}

输出结果如下

Address of Base: 004A8868Address of Base Vtable: 00C18BE0Call Foo by vt -> BaseAddress of Derive: 004A8868Address of Derive Vtable: 00C18D54Call Foo by vt -> DeriveAddress of Derive: 004A8868Address of Derive Vtable: 00C18D54Call Foo by vt -> DeriveAddress of Base: 004A8868Address of Base Vtable: 00C18BE0Call Foo by vt -> Base

可以看到在创建 new Derive();类的过程中,在调用不论是基类还是派生类的构造函数和析构函数时,this指针时不会发生变化的,也就是说这个类本身地址是没有变化的。但是其虚函数表指针指向的地址却发生了变化:
在调用基类的构造函数和析构函数时,其虚函数表指针指向的是基类的虚函数表地址,如上面程序的00C18BE0
而在调用派生类的构造函数和析构函数时,其虚函数表指针指向的是派生类的虚函数表地址,如上面程序的004A8868

所以说在类生成和析构的过程中,其虚函数表的指针时不断在发生变化的,所以就有了如下的两个问题:

  • 虚函数表什么时候生成?
  • 类里的虚函数指针什么时候初始化?

2.虚函数表什么时候生成?

【2】深入C++虚表中的说法:
拥有虚函数的类会有一个虚表,而且这个虚表存放在类定义模块的数据段中。模块的数据段通常存放定义在该模块的全局数据和静态数据,这样我们可以把虚表看作是模块的全局数据或者静态数据
也就是说虚函数表是在程序一开始运行的时候聚初始化好了的,每个类里面虚函数表中都有哪些内容都是已经订好的。类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表。
而具体类的实例中的虚表指针的时候,是在类生成过程中动态绑定的,详见下一条:

3.类里的虚函数指针什么时候初始化?

按照【2】【3】中的反汇编的得到的汇编代码我们可以看到,类实例中的虚表指针是在类调用构造函数的时候完成初始化的,只不过是在进入构造函数函数体之前就完成了初始化。

这也就是为什么构造函数不能是虚函数的原因,因为虚函数的调用需要涉及到虚表指针,而在构造函数调用之前,虚表指针还没有完成初始化,就没法调用虚的构造函数。

继续说我们的这块的初始化内容,在构造函数进入函数体之前,进行虚表的初始化,此时需要讲虚表指针初始化为当前类的虚表地址,即在基类调用构造函数的时候,会把基类的虚表地址赋值给虚表指针,而如果进行到子类的构造函数时,就把子类的虚表地址赋值给虚表指针。
所以就有了上面那个程序中,一个派生类创建过程中,其虚表指针在不断变化的原因。

而在析构函数中刚好相反,可以认为编译器在子类的析构函数的末尾将虚表指针指向了基类,然后紧接着插入了基类的析构函数。【这个部分存疑,我没有找到作证我的观点例子】

参考资料

  1. 在构造函数/析构函数中调用虚函数
  2. 深入C++虚表
  3. 从汇编层面深度剖析C++虚函数
0 0