理解 C++ 虚函数表

作者: Cyandev | 来源:发表于2016-07-09 20:21 被阅读2031次

    引言

    虚表是 C++ 中一个十分重要的概念,面向对象编程的多态性在 C++ 中的实现全靠虚表来实现。在聊虚表之前我们先回顾一下什么事多态性。

    多态实际上就是让一个父类指针,通过赋予子类对象的地址,可以呈现出多种形态和功能。如果这么说比较抽象的话,我们看一个例子就明白了:

    class Base {
        int m_tag;
    public:
        Base(int tag) : m_tag(tag) {}
        
        void print() {
            cout << "Base::print() called" << endl;
        }
        
        virtual void vPrint() {
            cout << "Base::vPrint() called" << endl;
        }
        
        virtual void printTag() {
            cout << "Base::m_tag of this instance is: " << m_tag << endl;
        }
    };
    
    class Derived : public Base {
    public:
        Derived(int tag) : Base(tag) {}
        
        void print() {
            cout << "Derived1::print() called" << endl;
        }
        
        virtual void vPrint() {
            cout << "Derived::vPrint() called" << endl;
        }
    };
    

    在上面的代码中,我们声明了一个父类 Base,和它的一个派生类 Derived,其中 print() 实例方法是非虚函数,而其余两个实例方法被声明为了虚函数。并且在子类中我们重新了 print()vPrint()。下面我们构造出一个 Derived 实例,并分别将其地址赋给 Base 指针和 Derived 指针:

    int main(int argc, char *argv[]) {
        Derived *foo = new Derived(1);
        Base *bar = foo;
        
        foo->print();
        foo->vPrint();
        
        bar->print();
        bar->vPrint();
        
        return 0;
    }
    

    我们看看程序运行的结果:

    Derived1::print() called
    Derived::vPrint() called
    Base::print() called
    Derived::vPrint() called
    

    可以看到,对于 Derived 指针的操作正如它应该表现的样子,然而当我们把相同对象的地址赋给 Base 指针时,可以发现它的非虚函数竟然表现出了父类的行为,并没有被重写的样子。

    这是什么原因呢?

    C++ 类的实质是什么

    首先我们要明白 C++ 中类的实质到底是什么。实际上,类在 C++ 中就是 struct (结构体)的一种扩展,允许了更高级的继承和虚函数。那么也就是说,结构体缺少的实际上就是虚函数。

    对于一般的成员变量,它和结构体在内存布局上是完全一样的,不管是顺序还是内存对齐,完全一致。而一个类的方法地址并不会存储在一个实例的内存中。对于非虚函数,它们在内存中的地址是唯一的,你可以把它想象成普通函数,只不过第一个参数是 this 指针,在通过类对象指针调用时,编译器会根据类型找到相应非虚函数的地址,这个工作是编译时完成的。

    也就是说,什么指针指向什么函数这是固定的,反正指针如果是 Base *,那我就直接执行 Base::print() 函数。

    揭开 vTable 的神秘面纱

    既然非虚函数实现这么简单,那虚函数是不是会很复杂?其实并不是那么复杂。虚函数的地址被存储一张叫做虚表的东西里,我们其实很容易拿到这个虚表。下面我们通过 dump memory 的方式来揪出一个类的虚表:

    看到我选中的那个字节,那是我们的一个实例变量,在这个实例变量的前面有 8 个字节的内容,那实际就是虚表的地址了,我们尝试将这个地址所指向的内容拿出来:

    这就是虚表的内容了,什么?你不信,下面我就把虚表中第一个函数揪出来执行一下:


    可以看到,Derived 类中重写的 vPrint() 方法已经被执行。这就说明虚函数在执行时是一个动态的过程,并不是在编译时就确定下来要执行哪一个函数,而是运行时从虚表查到真正要执行的函数的地址,然后再将 this 指针传入执行。

    到这里,我们已经大致了解了虚函数是怎样工作的了。下面我们看看 Base 类和 Derived 类的虚表有什么区别。我修改了源码,实例化了一个 Base 类对象 baz,然后分别 dump 出 Base 类和 Derived 类的内存:

    可以看出,两个对象的虚表指针是不同的。然后我们看看这两者虚表有什么不同:

    这两张虚表的第一个函数不同,因为 Derived 类重写了 vPrint() 方法,所以 Derived 的虚表第一个函数指针会有不同,而 printTag() 我并没有重写,所以两张表指向一个同一个函数。

    所以每个类都会维护一张虚表,编译时,编译器根据类的声明创建出虚表,当对象被构造时,虚表的地址就会被写入这个对象内存的起始位置。这就是多态性在 C++ 中实现的方式,而像 Java、OC 这样的语言由于 Runtime 的存在,这些对象会有多余的内存空间记录类的信息(meta-object),在运行时根据这些信息解析出相应的函数去执行。虽然不同,但是异曲同工。

    理解虚函数表有什么作用呢?

    • 能让你更好地理解 C++
    • 一些 hook 技术就是利用虚表来实现的

    Wrap Up

    这篇文章就简单地讲了一下多态和虚函数在 C++ 中的实现,我们说 C++ 非常 magical 就是因为它能用最简单的方式去实现各种面向对象编程的特性,十分值得我们终身学习。

    相关文章

      网友评论

      • d1943cc92679:执行的时候,如何确定一个函数 如vPrint是在类的非虚函数地址中找还是在虚表中找
        liuqinh2s:由关键字virtual标识
      • _zoro:请问那析构函数是怎么样在虚表中存在的呢?按理说应该是基类存自己的析构,派生类也是存自己的。可是delete派生类对象的时候 是如何执行了基类的delete呢?内存空间是如何变化的?
        liuqinh2s:虚函数表的实现是由编译器完成的,编译器对代码做了很多手脚,建议看Inside the C++ object model这本书,这本书我写了一个笔记,可以参考着看:https://liuqinh2s.github.io/Programming-Notes/,派生类的析构函数里面被编译器手动添加了对基类析构函数的调用。
        Cyandev:@_zoro 逐层调用,内存空间在最后统一被释放
        Cyandev:@_zoro 如果基类析构函数是虚函数,delete 的时候调用析构函数的行为就跟调用普通虚函数一样了

      本文标题:理解 C++ 虚函数表

      本文链接:https://www.haomeiwen.com/subject/nktdjttx.html