C++笔记:虚函数背后的虚表
1. 为什么需要虚表
- 在我们学习C++的时候,几乎每书本都会告诉我们“想要实现多态就必须依赖虚函数”,非虚函数只能静态绑定而不具备多态性,只有虚函数才具有动态绑定的特性。而为了实现虚函数,C++则使用一种特殊的后期绑定(动态绑定)形式,称为 虚表(The virtual table)。虚表的就是一个函数查找表,用来解决函数的动态绑定问题。因此,虚表存在的意义就是——解决动态绑定问题,进而实现面向对象编程中的多态性。
2.静态绑定与动态绑定的简单理解
- 静态绑定:就是在“编译期”就可以确定需要调用哪些函数,并将这些函数的入口地址直接固化到调用这个函数的指令中。如同下面的这段代码中
base.function()调用就是一个“静态绑定”的函数调用:
// 静态绑定
#include <iostream>
#include <cstdlib>
using namespace std;
class Base {
public:
void function() { cout << "Base::function" << endl; }
virtual void sayhello() { cout << "Hello!" << endl; }
};
int main()
{
Base *base = new Base();
base->function(); // function在编译期间就知道是调用Base类中的function函数
delete base;
return EXIT_SUCCESS;
}
- 从下面的主函数部分的汇编代码片段(g++编译器)中,我们不难发现编译器在编译的时候直接生成了一条名为
call _ZN4Base8functionEv的指令,而_ZN4Base8functionEv正是base.function()函数的入口名称。这也就意味着程序在“执行前”,编译器就已经知道在此处应该调用是函数base->function(),而不是其他的函数,并将相关的指令固化下来。这就是“静态绑定”。
main:
.LFB1459:
.loc 1 12 0
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
.loc 1 13 0
movl $1, %edi
call _Znwm@PLT
movq %rax, -8(%rbp)
.loc 1 14 0
movq -8(%rbp), %rax
movq %rax, %rdi
call _ZN4Base8functionEv ; !! 这里 !! 直接使用call指令调用Base类中的function()成员函数
.loc 1 15 0
movq -8(%rbp), %rax
movl $1, %esi
movq %rax, %rdi
call _ZdlPvm@PLT
.loc 1 16 0
movl $0, %eax
.loc 1 17 0
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
- 动态绑定:与静态绑定恰恰相反,编译器无法在“编译期”知道需要调用哪些函数。也就没有办法像静态绑定那样在编译的时候把函数调用固化为类似
call _ZN4Base8functionEv指令。那么,为什么实现多态就必须要动态绑定呢,静态绑定不行吗?我们一起来看下面这段代码:
// 动态绑定
#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;
class Base {
public:
virtual void function() { cout << "Base::function" << endl; }
virtual void sayhello() { cout << "Hello!" << endl; }
};
class DeriveA : public Base{
public:
virtual void function() { cout << "DeriveA::function" << endl; }
};
class DeriveB : public Base{
public:
virtual void function() { cout << "DeriveB::function" << endl; }
};
int main()
{
Base *ptr = nullptr;
if (time(nullptr) % 2) // 这里time(nullptr)获取当前系统的unix时间戳, 它为奇数和偶数概率各为一半
ptr = new DeriveA(); // 当时间戳为奇数时, 创建DeriveA
else
ptr = new DeriveB(); // 当时间戳为偶数时, 创建DeriveB
ptr->function();
delete ptr;
return EXIT_SUCCESS;
}
- 上面这段代码中
time(nullptr)获取当前系统的unxi时间戳,当时间戳为奇数时ptr指向DeriveA类对象,而当时间戳为偶数时ptr指向DeriveB类对象。当编译器编译ptr->function()这条语句时,编译器不知道ptr的背后是一个DeriveA类还是一个DeriveB类,而由于要实现多态性,就必须保证当ptr指向DeriveA的时候调用DeriveA中的function(),当ptr指向DeriveB的时候调用DeriveB中的function(),而ptr指向谁这只能 在程序运行起来的时候才能知晓。因此,面向对象编程所需要的多态性就必须依赖“动态绑定”才能实现。当然,如果没有使用虚函数,不同函数不具备多态性,编译器在编译时就不会理睬ptr具体指向什么, 直接执行静态绑定。
3.虚表是如何实现动态绑定进而实现多态性的?
- 在讨论完静态绑定与动态绑定之后,我们终于进入正题——虚表是怎么样实现动态绑定进而实现多态性的?
- 这里我们依然以“动态绑定”中的代码为例子,上面的代码中一共有3个类分别是
Base,DeriveA,DeriveB。- 首先,编译器在编译每个类的时候都会为其生成一张“虚表”,这张虚表其实就是一个编译器在编译时设置的一个“静态数组”。虚表的每一个元素都对应一个虚函数,这些虚函数可以被类的对象来调用。
- 之后,编译器会为每一个类添加一个“隐藏的指针”,我们把它叫做
*__vptr。这个*__vptr指针是类的实例被创建的时候,编译器自动为其设置的。如果我们把这个指针显示地展现出来,大概就像这样:
// 动态绑定
#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;
class Base {
public:
FunctionPointer *__vptr; //!隐藏指针! 指向虚表
virtual void function() { cout << "Base::function" << endl; }
virtual void sayhello() { cout << "Hello!" << endl; }
};
class DeriveA : public Base {
public:
FunctionPointer *__vptr; //!隐藏指针! 指向虚表
virtual void function() { cout << "DeriveA::function" << endl; }
};
class DeriveB : public Base {
public:
FunctionPointer *__vptr; //!隐藏指针! 指向虚表
virtual void function() { cout << "DeriveB::function" << endl; }
};
- 我们把上面的每个类及其虚表画出来,并用箭头表示指针的指向关系,就可得到下面这张图:

在上图中,每个类中的* __vptr指向该类的虚拟表。对于被子类覆写了虚函数,在虚表中对应指向该函数的条目将指向该子类中的该函数(如DeriveA类中的function函数);如果子类没有覆写父类的虚函数,在虚表中对应指向该函数的条目将指向该子类的父类中的该函数(如DeriveA类中的sayhello函数)
-
现在,让我们考虑一下——当把一个基类指针指向派生类对象的时候发生了什么:
-
首先,我们创建一个
DeriveA子类对象da:int main() { DeriverA da ; //... } -
之后,我们让基类指针指向该对象:
int main() { DeriverA da; Base *bda = &da; //... }我们注意到由于
bda是一个Base类型的指针,因此通过bda指针我们只能访问DeriverA类中的“基类部分”(比如function函数)。然而,我们还要注意* __vptr也是属于这个所谓的“基类部分”的,因此通过bda也可以访问这个指针。但是,bda->__vptr指向的是DeriverA的虚表!因此,即使bda指针是Base类型,它仍然可以访问DeriverA的虚表(通过__vptr指针)。 -
最后,我们来分析一下使用
bda来调用function()的过程中发生了什么。首先,程序认识到function()是一个虚拟函数。其次,程序使用bda->__vptr来访问DeriverA的虚表。最终,通过DeriverA的虚表找到需要调用的function()——即DeriverA::function()。因此,bda->function()解析为DeriverA::function()!int main() { DeriverA da; Base *bda = &da; bda->function(); //... }
-
-
如果我们使用图来表示就是:

4.对虚表的一点思考
- 调用虚函数相比调用非虚函数,有一定的效率上的损失,这主要来自一下的几个原因:
- 首先,我们必须使用
* __vptr来获得恰当的虚拟表; - 其次,我们必须查找虚拟表,以找到要调用的正确函数。只有这样我们才能调用这个函数。
- 首先,我们必须使用
- 因此,我们必须执行3个操作(1.对对象指针解引用; 2.对
__vptr指针进行解引用得到虚表;3. 索引虚表中的函数,找到需要调用的函数)来找到要调用的函数,而不是对普通的间接函数调用执行2个操作(1.对对象指针解引用,2.找到需要调用的函数),或对直接函数调用执行1个操作。 - 尽管虚函数有一定效率上的损失,但是对于现代计算机来说,这些效率上的损失几乎微不足道。另外,任何使用虚函数的类都有一个编译器为我们生成
__vptr指针。因此该类的每个对象会多占用一个指针变量的内存空间。所以说,虚函数在空间上也有一定的成本。
5.参考资料
[1] www.learncpp.com
另外博主目前也只是个学生,如果博文中有任何错误,希望各位朋友帮忙指出!!谢谢!!
浙公网安备 33010602011771号