引言
虚表是 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 就是因为它能用最简单的方式去实现各种面向对象编程的特性,十分值得我们终身学习。