理解 C++ 虚函数表

引言

虚表是 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 指针,在通过类对象指针调用时,编译器会根据类型找到相应非虚函数的地址,这个工作是编译时完成的。

《理解 C++ 虚函数表》

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

揭开 vTable 的神秘面纱

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

《理解 C++ 虚函数表》

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

《理解 C++ 虚函数表》

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

《理解 C++ 虚函数表》

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

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

《理解 C++ 虚函数表》

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

《理解 C++ 虚函数表》

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

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

Wrap Up

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

    原文作者:算法小白
    原文地址: https://juejin.im/entry/5780eccac4c9710066d3d8bd
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞