C++ 多态下篇:虚函数表、动态绑定与多态底层原理

上一篇解决的是多态"怎么写":

复制代码
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 子对象位置出发,取得与该子对象相关的虚函数分派信息。

到这里你会发现:

复制代码
继承
对象布局
指针调整
虚函数表
动态绑定
多态

实际上不是几个互相独立的知识点,而是一整套连在一起的机制。


麻烦给作者点点关注谢谢啦!

相关推荐
破茧蝴蝶5 小时前
Python分布式爬虫完整教程
开发语言·python
狗凯之家源码网5 小时前
全新 PHP 轻量客服聊天室源码深度评测
开发语言·php·聊天室系统
远离UE45 小时前
UE5 Substrate Thin Film 源码拆析
开发语言·javascript·ue5
keyipatience6 小时前
JavaScript学习(1)书写位置,变量、数据类型、运算符
开发语言·前端·javascript·css·学习
一拳一个呆瓜6 小时前
【STL】文件系统导航
c++·stl
All for pursuit.6 小时前
【动态规划-21】312.戳气球
数据结构·c++·算法·leetcode·动态规划
栈帧之外6 小时前
【C++】STL之set
开发语言·c++
catchadmin7 小时前
免费可商用 PHP 管理后台 CatchAdmin V5.4.1 发布,新增 MCP 支持
开发语言·php
zhanghaha13147 小时前
C++构造函数的一些其他应用(逐词逐句保姆讲解)
c++
jimy18 小时前
c++的 Type Traits (二、更详细版本)
开发语言·c++