上一篇解决的是多态"怎么写":
Base* ptr = &d;
ptr->Func();
只要满足:
继承关系
+
基类指针 / 引用
+
虚函数
+
派生类重写
就能够根据对象的实际类型调用不同函数。
但真正值得思考的是:
ptr明明是Base*,编译器为什么能够在程序运行时知道它实际指向的是Derive?
答案就在 C++ 多态的底层实现机制:
对象
↓
虚函数表指针 vptr
↓
虚函数表 vtable
↓
虚函数地址
↓
调用对应函数
这一篇就把这条链彻底拆开。
一、对象中的虚函数表指针 vptr
先看一个普通类:
#include <iostream>
using namespace std;
class Base
{
public:
int _a = 10;
char _ch = 'A';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
在常见环境中,对象中主要保存成员变量。
但现在增加一个虚函数:
class Base
{
public:
virtual void Func()
{
cout << "Base::Func()" << endl;
}
private:
int _a = 10;
char _ch = 'A';
};
此时对象通常会比原来更大。
原因就在于:
含有虚函数的类对象中,编译器通常会额外保存一个虚函数表指针。
它通常称为:
vptr
即:
virtual table pointer
可以把对象粗略理解成:
Base对象
低地址
↓
┌────────────────────┐
│ vptr │
├────────────────────┤
│ _a │
├────────────────────┤
│ _ch │
│ padding │
└────────────────────┘
需要注意的是:
vptr是对象的一部分。
因此对象在哪里,vptr 就在哪里。
尤其注意在对象内部,对于这种成员还是按低地址向高地址排列,(访问对象成员方便)但是这不影响栈自己本身是高地址向低地址的,因为栈的大小在创建对象时就已经由栈顶指针esp决定了。
例如:
Base b;
如果 b 是局部对象,那么它通常位于栈中:
栈
┌──────────────────┐
│ Base b │
│ ├── vptr │
│ ├── _a │
│ └── _ch │
└──────────────────┘
如果:
Base* p = new Base;
那么:
p
这个局部指针变量本身通常在栈上,而:
new Base
产生的对象在堆上,所以对象内部的 vptr 也在堆中。
可以画成:
栈
┌───────────────┐
│ Base* p │──────────┐
└───────────────┘ │
↓
堆
┌─────────────────┐
│ Base对象 │
│ ├── vptr │
│ └── 成员变量 │
└─────────────────┘
所以不要死记:
"vptr 在栈上。"
正确理解应该是:
vptr 属于对象,对象在哪里,它就跟着在哪里。
二、虚函数表 vtable 到底是什么
对象里只有一个 vptr,那么它到底指向什么?
答案就是:
虚函数表 vtable
例如:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
};
可以先把 Base 的虚函数表粗略理解为:
Base vtable
┌─────────────────────┐
│ &Base::Func1 │
├─────────────────────┤
│ &Base::Func2 │
└─────────────────────┘
也就是说:
虚函数表本质上保存了虚函数动态分派所需要的信息,其中最核心的就是虚函数对应的地址。
一个 Base 对象:
Base对象
┌──────────────────┐
│ vptr │───────────┐
├──────────────────┤ │
│ 成员变量 │ │
└──────────────────┘ │
↓
Base vtable
┌────────────────────┐
│ &Base::Func1 │
├────────────────────┤
│ &Base::Func2 │
└────────────────────┘
这里有一个特别重要的问题:
Base b1;
Base b2;
Base b3;
是不是生成三张虚表?
通常不是。
一般可以理解成:
b1.vptr ─────┐
b2.vptr ─────┼────→ Base vtable
b3.vptr ─────┘
也就是说:
同一种类型的对象通常共享同一套虚函数表。
每个对象自己保存:
vptr
但是这些 vptr 可以指向同一张 Base 虚表。
这也很好理解。
假设:
Base b1;
Base b2;
虽然:
b1._a
b2._a
可以完全不同,但是:
Base::Func1
Base::Func2
函数代码本身没有必要因为创建两个对象就复制两份。
三、虚函数到底存在哪里
这是学习虚函数表时最容易混淆的问题之一。
很多初学者会认为:
虚函数存放在虚函数表里面。
这是不对的。
虚函数本质上仍然是:
一段编译后的机器指令。
例如:
class Base
{
public:
virtual void Func1()
{
cout << "virtual function" << endl;
}
void Func2()
{
cout << "normal function" << endl;
}
};
其中:
Func1()
是虚函数。
Func2()
是普通成员函数。
但二者编译之后,本质都是机器指令,所以都属于程序代码。
概念上:
.text 代码区域
┌────────────────────────┐
│ Base::Func1机器指令 │
├────────────────────────┤
│ Base::Func2机器指令 │
└────────────────────────┘
二者最大的区别不是存放位置,而是:
Base::Func1
↓
它的地址还会参与虚函数表的动态分派
而普通成员函数:
Base::Func2
通常不会作为虚函数表中的虚函数槽位参与这种运行时分派。
假设:
Base::Func1代码地址 = 0x401000
Base::Func2代码地址 = 0x402000
那么虚表中保存的可能类似:
Base vtable
┌─────────────────┐
│ 0x401000 │──────┐
└─────────────────┘ │
↓
.text代码段
0x401000
┌──────────────────┐
│ Base::Func1代码 │
│ ... │
│ ret │
└──────────────────┘
所以一定要分清:
虚函数表
里面保存的是用于定位虚函数的信息;
而:
虚函数本身
仍然是代码。
一句话总结:
对象中放 vptr,vptr 指向虚表,虚表中保存虚函数地址,地址最终指向代码区域中的虚函数机器指令。
四、继承和重写后,虚函数表发生了什么
现在进入整个多态底层最重要的一部分。
假设基类:
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func5()
{
cout << "Base::Func5()" << endl;
}
};
派生类:
class Derive : public Base
{
public:
void Func1() override
{
cout << "Derive::Func1()" << endl;
}
virtual void Func3()
{
cout << "Derive::Func3()" << endl;
}
void Func4()
{
cout << "Derive::Func4()" << endl;
}
};
现在分别分析。
Base 有两个虚函数:
Func1
Func2
所以可以粗略画成:
Base vtable
┌────────────────────┐
│ &Base::Func1 │
├────────────────────┤
│ &Base::Func2 │
└────────────────────┘
注意:
Func5()
不是虚函数,因此不会因为它是成员函数就自动进入虚函数表。
派生类中:
void Func1() override;
重写了:
Base::Func1
同时又新增:
virtual void Func3();
那么 Derive 对应虚表可以粗略理解为:
Derive vtable
┌────────────────────┐
│ &Derive::Func1 │ ← 重写
├────────────────────┤
│ &Base::Func2 │ ← 没有重写
├────────────────────┤
│ &Derive::Func3 │ ← 新增虚函数
└────────────────────┘
这就是为什么:
virtual function overriding
中文经常翻译为:
重写 / 覆盖。
因为从概念上看,原来:
Func1槽位
↓
Base::Func1
到了派生类之后变成:
Func1槽位
↓
Derive::Func1
可以理解成:
Base::Func1
↓
被
↓
Derive::Func1
覆盖
但是这里要特别注意:
覆盖的不是基类函数代码。
Base::Func1 的机器代码仍然存在。
变化的是:
Derive对应虚表中Func1这个槽位
现在指向:
Derive::Func1
五、动态绑定究竟是怎么实现的
现在终于可以解释最经典的问题了。
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func()
{
cout << "Base::Func()" << endl;
}
};
class Derive : public Base
{
public:
void Func() override
{
cout << "Derive::Func()" << endl;
}
};
int main()
{
Base b;
Derive d;
Base* p1 = &b;
Base* p2 = &d;
p1->Func();
p2->Func();
return 0;
}
输出:
Base::Func()
Derive::Func()
问题来了。
p1
是:
Base*
而:
p2
也是:
Base*
为什么调用结果完全不同?
因为虚函数调用不能只看:
指针变量是什么类型
还要看:
它实际指向什么对象
当:
p1->Func();
时:
p1
↓
Base对象
↓
读取对象中的vptr
↓
找到Base对应虚表
↓
找到Func槽位
↓
得到&Base::Func
↓
调用Base::Func()
而:
p2->Func();
时:
p2
↓
实际指向Derive对象
↓
读取对象对应的vptr
↓
找到Derive对应虚表
↓
找到Func槽位
↓
得到&Derive::Func
↓
调用Derive::Func()
所以虽然源代码看起来都是:
p->Func();
但是运行时得到的函数地址不同。
这就是:
动态绑定。
可以把整个过程浓缩成:
Base* p
↓
实际对象
↓
vptr
↓
vtable
↓
Func对应槽位
↓
函数地址
↓
call
这条链就是 C++ 运行时多态最核心的底层逻辑。
六、静态绑定与动态绑定
所谓"绑定",可以简单理解成:
一次函数调用最终和哪个函数实现建立联系。
C++ 中可以分成:
静态绑定
动态绑定
来看一个完整例子:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
void Func2()
{
cout << "Base::Func2()" << endl;
}
};
class Derive : public Base
{
public:
void Func1() override
{
cout << "Derive::Func1()" << endl;
}
void Func2()
{
cout << "Derive::Func2()" << endl;
}
};
int main()
{
Derive d;
Base* ptr = &d;
ptr->Func1();
ptr->Func2();
return 0;
}
输出:
Derive::Func1()
Base::Func2()
为什么?
先看:
ptr->Func1();
Func1() 是虚函数,而且 Derive 完成了重写。
所以:
ptr
↓
实际Derive对象
↓
动态查找
↓
Derive::Func1
属于:
动态绑定。
而:
ptr->Func2();
Func2() 不是虚函数。
这里不会走前面的虚函数动态分派流程。
ptr 的静态类型是:
Base*
因此调用:
Base::Func2()
这就是:
静态绑定。
所以可以总结成:
| 对比 | 静态绑定 | 动态绑定 |
|---|---|---|
| 确定目标的主要阶段 | 编译阶段 | 运行阶段 |
| 常见对象 | 普通函数 | 虚函数 |
| 是否依赖实际对象类型 | 通常不依赖 | 依赖 |
| 是否需要动态分派 | 否 | 是 |
| 常见实现是否涉及虚表 | 否 | 是 |
同时这里还可以顺便理解两个非常重要的概念。
Base* ptr = new Derive;
ptr 的:
静态类型
是:
Base*
因为变量声明就是:
Base* ptr
但是它实际指向的对象类型:
Derive
可以称为动态类型。
所以:
静态类型 = Base*
动态类型 = Derive
普通函数调用更多根据静态类型确定。
而满足多态条件的虚函数调用,会体现实际对象的动态类型。
七、虚拟地址空间中这些东西分别在哪里
现在把:
对象
vptr
vtable
虚函数地址
虚函数代码
一次性放到虚拟地址空间里。
假设:
class Base
{
public:
virtual void Func1() {}
virtual void Func2() {}
private:
int _a = 10;
};
int main()
{
Base b;
Base* p = new Base;
}
可以粗略理解:
高地址
↑
栈区 Stack
┌────────────────────────────┐
│ Base b │
│ ┌────────────────────────┐ │
│ │ vptr │─┼───────┐
│ │ _a │ │ │
│ └────────────────────────┘ │ │
│ │ │
│ Base* p │───┐ │
└────────────────────────────┘ │ │
│ │
↓ │
堆区 Heap │
┌────────────────────────────┐ │
│ new Base对象 │ │
│ ┌────────────────────────┐ │ │
│ │ vptr │─┼───────┤
│ │ _a │ │ │
│ └────────────────────────┘ │ │
└────────────────────────────┘ │
↓
只读数据相关区域
┌────────────────────────────┐
│ Base vtable │
├────────────────────────────┤
│ &Base::Func1 │─────────┐
├────────────────────────────┤ │
│ &Base::Func2 │─────┐ │
└────────────────────────────┘ │ │
↓ ↓
代码段 .text
┌────────────────────────────┐
│ Base::Func1机器代码 │
├────────────────────────────┤
│ Base::Func2机器代码 │
└────────────────────────────┘
因此可以直接记:
| 内容 | 一般怎么理解 |
|---|---|
vptr |
对象的一部分,对象在哪它就在哪 |
vtable |
通常位于程序映像的只读数据相关区域 |
| 虚函数地址 | 作为虚表中的地址值存在 |
| 虚函数机器代码 | .text 代码区域 |
| 普通成员函数机器代码 | 同样在 .text |
不过这里一定要注意:
C++ 标准规定的是程序应该表现出怎样的多态行为,并没有规定编译器必须按照某一种固定内存布局实现。
所以:
vptr
vtable
属于 GCC、Clang、MSVC 等主流编译器普遍采用的实现机制。
不同 ABI 下:
虚表具体布局
vptr具体位置
虚析构函数表项数量
RTTI信息的位置
都可能有所不同。
因此不要把某一个编译器观察出来的布局,当成所有 C++ 程序都必须遵循的标准。
八、多继承中的虚表与多态:把继承知识彻底串起来
前面单继承时比较简单。
例如:
class Base
{
public:
virtual void Func()
{
}
};
class Derive : public Base
{
};
可以粗略认为:
Derive对象
┌────────────────────┐
│ Base部分 │
│ ├── vptr │
│ └── Base成员 │
├────────────────────┤
│ Derive成员 │
└────────────────────┘
但到了多继承情况就更有意思。
class Base1
{
public:
virtual void Func1()
{
}
private:
int _b1;
};
class Base2
{
public:
virtual void Func2()
{
}
private:
int _b2;
};
class Derive : public Base1, public Base2
{
private:
int _d;
};
对象可以粗略理解为:
Derive对象
低地址
↓
┌─────────────────────────┐
│ Base1子对象 │
│ ├── vptr1 │
│ └── _b1 │
├─────────────────────────┤
│ Base2子对象 │
│ ├── vptr2 │
│ └── _b2 │
├─────────────────────────┤
│ Derive自己的成员 _d │
└─────────────────────────┘
这就和继承部分学过的指针调整联系起来了。
Derive d;
Derive* pd = &d;
Base1* p1 = &d;
Base2* p2 = &d;
对于一种典型布局:
pd
↓
Derive对象开头
p1
↓
Base1子对象
而:
p2
↓
Base2子对象
Base2 不一定在对象开头。
因此:
Base2* p2 = &d;
过程中编译器可能需要自动调整指针,使它真正指向:
Derive对象中的Base2子对象
于是多态调用:
p2->Func2();
就可以从正确的 Base2 子对象位置出发,取得与该子对象相关的虚函数分派信息。
到这里你会发现:
继承
对象布局
指针调整
虚函数表
动态绑定
多态
实际上不是几个互相独立的知识点,而是一整套连在一起的机制。