虚函数表
本节阅读量:考虑以下程序:
|
|
首先,让我们看看对 base.getName() 的调用。由于这是一个非虚函数,编译器会使用 base 的静态类型(Base)在编译时确定它应该解析为 Base::getName()。
尽管看起来几乎相同,但对 base.getNameVirtual() 的调用必须以不同方式解析。由于这是虚函数调用,编译器必须使用 base 的动态类型来解析调用,而 base 的动态类型要到运行时才知道。因此,只有在运行时才能确定这次 base.getNameVirtual() 调用会解析为 Derived::getNameVirtual()。
那么,虚函数实际上是如何工作的呢?
虚函数表
C++ 标准没有指定应该如何实现虚函数(这些细节由具体编译器决定)。
然而,C++ 实现通常使用一种称为虚函数表的动态绑定机制来实现虚函数。
虚函数表是用于以动态绑定(延迟绑定)方式解析函数调用的查找表。虚函数表有时也被称为 “vtable”、“虚方法表”或“分派表”。在 C++ 中,虚函数解析有时称为动态分派。
由于使用虚函数不需要知道虚函数表的工作方式,因此可以将此部分视为可选阅读。
尽管用文字描述起来有点复杂,但虚函数表实际上相当简单。首先,每个使用虚函数的类都有一个对应的虚函数表。该表只是编译器在编译时设置的静态数组。虚函数表包含该类对象可以调用的每个虚函数条目。表中的每个条目都是一个函数指针,指向该类可以访问的最底层派生函数。
其次,编译器还会添加一个隐藏指针。该指针是基类的成员,我们将其称为 *__vptr。*__vptr 会在创建类对象时自动设置,使它指向该类的虚函数表。与 this 指针(它实际上是编译器用于解析自引用的函数参数)不同,*__vptr 是真正的指针成员。因此,每个分配的类对象都会增大一个指针的大小。这也意味着 *__vptr 会被派生类继承,这一点很重要。
现在,您可能会疑惑这些内容是如何组合在一起的,因此让我们看一个简单示例:
|
|
因为这里有 3 个类,所以编译器将设置 3 个虚函数表:一个用于 Base,一个用于 D1,另一个用于 D2。
编译器还会向使用虚函数的最顶层基类添加隐藏指针成员。尽管编译器会自动执行此操作,但我们在下一个示例中把它写出来,只是为了展示它的添加位置:
|
|
创建类对象时,*__vptr 会被设置为指向该类的虚函数表。例如,创建 Base 类型的对象时,*__vptr 会指向 Base 的虚函数表。构造 D1 或 D2 类型的对象时,*__vptr 会分别指向 D1 或 D2 的虚函数表。
现在,让我们讨论这些虚函数表如何填充。因为这里只有两个虚函数,所以每个虚函数表都有两个条目(一个用于 function1(),另一个用于 function2())。请记住,填充这些虚函数表时,每个条目都会填入该类型对象可以调用的最底层派生函数。
Base 对象的虚函数表很简单。Base 类型的对象只能访问 Base 的成员。Base 无权访问 D1 或 D2 的函数。因此,function1 的条目指向 Base::function1(),function2 的条目指向 Base::function2()。
D1 的虚函数表稍微复杂一些。D1 类型的对象可以访问 D1 和 Base 的成员。然而,D1 重写了 function1(),因此 D1::function1() 比 Base::function1() 更底层。因此,function1 的条目指向 D1::function1()。D1 尚未重写 function2(),因此 function2 的条目仍然指向 Base::function2()。
D2 的虚函数表类似于 D1,只是 function1 的条目指向 Base::function1(),function2 的条目指向 D2::function2()。
下面是示意的图片:
尽管这个图看起来有点复杂,但它其实非常简单:每个类中的 *__vptr 指向该类的虚函数表。虚函数表中的条目指向该类对象允许调用的最底层派生函数版本。
因此,请考虑创建 D1 类型的对象时会发生什么:
|
|
由于 d1 是 D1 对象,因此 d1 会将其 *__vptr 设置为指向 D1 虚函数表。
现在,让我们将 Base 指针设置为指向 D1:
|
|
请注意,由于 dPtr 是 Base 指针,因此它只指向 d1 的 Base 部分。然而,*__vptr 位于类的 Base 部分,因此 dPtr 可以访问该指针。最后,注意 dPtr->__vptr 指向 D1 虚函数表!因此,即使 dPtr 是 Base* 类型,它仍然可以通过 __vptr 访问 D1 的虚函数表。
那么,当我们试图调用dPtr->function1()时会发生什么呢?
|
|
首先,程序识别出 function1() 是虚函数。其次,程序使用 dPtr->__vptr 访问 D1 的虚函数表。第三,它在 D1 的虚函数表中查找应该调用 function1() 的哪个版本。该条目已经被设置为 D1::function1()。因此,dPtr->function1() 会解析为 D1::function1()!
现在,您可能会问:“但如果 dPtr 确实指向 Base 对象,而不是 D1 对象,它还会调用 D1::function1() 吗?” 答案是否定的。
|
|
在这种情况下,创建 b 时,b.__vptr 指向 Base 的虚函数表,而不是 D1 的虚函数表。由于 bPtr 指向 b,因此 bPtr->__vptr 也指向 Base 的虚函数表。Base 虚函数表中 function1() 的条目指向 Base::function1。因此,bPtr->function1() 解析为 Base::function1(),这是 Base 对象能够调用的 function1() 的最底层派生版本。
通过使用这些表,编译器和程序能够确保函数调用解析到合适的虚函数,即使您只使用指向基类的指针或引用!
调用虚函数比调用非虚函数慢,原因有几个:首先,我们必须使用 *__vptr 访问合适的虚函数表。其次,我们必须索引虚函数表,以找到要调用的正确函数。只有这样,才能调用对应的函数。因此,我们需要执行 3 个操作才能找到要调用的函数,而普通间接函数调用通常只需要 2 个操作,直接函数调用只需要 1 个操作。当然,对于现代计算机,这种额外时间通常相当微不足道。
另外,作为提醒,任何使用虚函数的类都有一个 *__vptr,因此该类的每个对象都会多占用一个指针的空间。虚函数功能很强大,但确实存在性能成本。