18.6 虚表¶
By Alex on February 8th, 2008 | last modified by Alex on December 21st, 2020
翻译 by dashjay 2020-12-24 | 最后修改于 2020-12-24
为了实现虚函数,C++ 使用了一种特殊的形式的后期绑定,也就是我们所了解的虚表。虚表是一个函数快速查询表,用来在后期绑定的方式解析函数调用。虚表有时候也叫其他名字,例如 "vtable", "virtual function table", "virtual method table" 或者 "dispatch table"。
因为知道虚函数如何工作没有必要使用虚函数,这部分可以当做选读。
虚表实际上非常简单,虽然用语言描述起来有点复杂。首先,每个使用虚函数的类(或者继承了又虚函数的类)都会有他的虚表。这个表是一个简单的静态数组,在编译的时候就预留好了。一个虚表包含了每一个可以被通过类的对象调用的虚函数的入口。表中的每个入口都是一个简单的函数指针指向了该类能初级的最终派生的函数。
其次,编译器也需要添加一个隐藏的指针到基类中,我们叫他 *__vptr.
。当一个类实例创建时,*__vptr
被自动设置,以便它指向那个类实例的虚表。不像 *this
指针,会自动作为函数参数,能够让编译器解析自己,*__vptr
是一个真实的指针。因此,它使得每个类对象分配比自己稍微大一些的空间。这意味着 *__vptr
能被派生类继承,非常重要。
现在,你可能疑惑这些东西是如何搭配在一起的,让我们来看一个简单的例子:
class Base
{
public:
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
因为这里有三个类,编译器会设置3个虚表:一个给 Base,一个给 D1,另一个给 D2。
编译器也添加了一个隐藏的指针到使用虚函数的最基类。尽管由编译器自动实现,我们也会在下一个例子中展示它添加在哪里:
class Base
{
public:
FunctionPointer *__vptr;
virtual void function1() {};
virtual void function2() {};
};
class D1: public Base
{
public:
virtual void function1() {};
};
class D2: public Base
{
public:
virtual void function2() {};
};
当一个类被创建的时候,*__vptr
被指向该类的虚表。例如,当一个基类被创建的时候,*__vptr
被指向基类的虚表。当对象 D1
或 D2
被构建的时候, *__vptr
分别指向了 D1 或者 D2的虚表。
现在,让我们讨论一下虚表如何被填充的。因为有两个虚函数,因此每个虚表将会有两个入口(一个给 function1()
,另一个给 function2()
)。记住当这些虚表被填充的时候,每一个入口都填充该类能允许调用的最终派生的函数版本(the most-derived function an object of that class type can call)。
基类的虚表是非常简单的。一个基类的对象只能基类的成员。基类没有办法访问 D1 或者 D2 的函数。因此,function1
的入口指向了 Base::function1()
而 function2
的入口指向了 Base::function2()
。
D1 的虚表稍微更加复杂一些,一个 D1
类型的对象既能够访问 D1
也能访问 Base
。然而,D1
重写了函数 function1()
,使得 D1::function1()
比 Base::function1()
派生程度更大(more derived)。因此,function1
入口指向了 D1::function1()
。D1
没有重写 function2()
,因此 function2
的入口将会指向 `Base::function2()。
D2
的虚表和 D1
是相似的,其中 function1
的入口指向了 Base::function1()
,并且 function2
的入口指向了 D2::function2()
。
这里有个图像可以描述:
尽管这图看起来有点疯狂,但其实非常简单:每个类中的 *__vptr
指向该类的虚表。虚表的入口指向了该类能允许调用的最终派生的函数版本。
以你思考一下当我们创建一个 D1 的时候,会发生什么?
int main()
{
D1 d1;
}
因为 d1
是一个 D1
对象,d1
有它的 *__vptr
指向 D1 虚表。
现在,让我们设置一个基类指针到 D1
:
int main()
{
D1 d1;
Base *dPtr = &d1;
return 0;
}
注意因为 dPtr
是一个基类指针,他仅仅指向了 d1
的基类部分的。然而,也注意到 *__vptr
是在该类的基类部分,因此 dPtr
可以访问这个指针。最终,注意 dPtr->__vptr
指向了 D1
的虚表!因此,即便 dPtr
是基类的,它仍然能够访问 D1
的虚表(通过 __vptr
)。
因此当我们尝试调用 dPtr->function1()
的时候,会发生什么?
int main()
{
D1 d1;
Base *dPtr = &d1;
dPtr->function1();
return 0;
}
首先,程序意识到 function1()
是一个虚函数。其次,程序使用 dPtr->__vptr
来得到 D1
的虚表。然后,它查看 D1
的虚表中应该调用哪个版本的 function1()
。这已经指向了 D1::function1()
。因此 dPtr->function1()
指向了 D1::function1()
!
现在,你可能会说了,”但如果 dPtr
真的指向一个基类对象而不是 D1 对象,仍然会调用 D1::function1()
吗?“。答案是否定的。
int main()
{
Base b;
Base *bPtr = &b;
bPtr->function1();
return 0;
}
在这个例子中,当 b
被创建的时候,__vptr
指向了 Base
类的虚表,而不是 D1
的虚表。因此,dPtr->__vptr
将也会指向 Base
的虚表。Base
的虚表中,function1()
的入口指向了 Base::function1()
。因此,dPtr->function1()
解析到了 Base::function1()
,也是基类能够调用的最终继承版本的 function1()
通过使用这些表,编译器和程序能够确保函数调用解析到合适的虚函数中,几十你仅仅使用一个指向基类的指针或者引用!
调用一个虚函数比调用一个非虚函数更慢,因为几个原因。第一,我们不得不使用 *__vptr
去获得合适的虚表。第二,我们不得索引虚表来找到合适的需要调用的函数。只有那样我们才能调用函数。结果,我们不得不做三个操作来找到要调用的函数,而不是普通的间接函数调用的两次操作,或者直接调用的单独操作。然而,在现代的计算机里,这额外的时间通常相当无关紧要。
提醒一下,任何使用虚函数的类都有一个 *__vptr
,并且因此对象会比设想的本身稍大一些。虚函数是强大的,但是他们确实存在性能损耗。