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

到这里你会发现:

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

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


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

相关推荐
2601_962071579 小时前
【Java报错已解决】org.springframework.beans.factory.BeanCreationException
java·开发语言
淡海水10 小时前
07-04-并发-ConcurrentBag-T-工作窃取WorkStealing算法
开发语言·算法·c#·bag·concurrent·workstealing
CS_Zero11 小时前
C语言sizeof是函数吗?
c语言·开发语言
-今昭-12 小时前
《V2V 迁移实战:将 VMware 虚拟机转为 OpenStack 可用 Glance qcow2 镜像》
开发语言·python
黑马程序员毕设14 小时前
基于Java的医院药品管理系统的优化设计与实现
java·开发语言·spring boot·小程序·架构·课程设计·毕设
_Twink1e14 小时前
openJiuwen 实训营 Day 1 · WorkSwarm 入门教程
开发语言·c++·openjiuwen
问天_观心14 小时前
大模型微调学习(一)
开发语言·人工智能·学习·语言模型·github
阿里嘎多学长15 小时前
2026-09-03 GitHub 热点项目精选
开发语言·程序员·github·代码托管
dong_junshuai15 小时前
每天一个开源项目#87 fmt:24.5K Stars 的 C++ 格式化核心
c++·开源·github