C++多态实现原理

多态的底层依靠虚函数表(vtable)+虚函数指针(vptr)实现:

当类含有虚函数时,编译器会给这个类生成一张虚函数表;该类的每个对象内部会新增一个虚指针,指向本类的虚函数表。

调用虚函数时,运行时通过对象的vptr找到虚表,再查表调用函数,而不是编译期直接绑定函数地址,以此实现运行时动态绑定。


有ai后学习东西确实方便很多。


两个核心概念

  1. 虚函数表 vtable
    • 属于类 ,同一个类所有对象共用一张虚表,存放在只读数据段。
    • 表里面存储:本类虚函数的函数地址。
    • 子类继承父类虚函数:
      • 如果 ** 重写 (override)** 虚函数:虚表中对应位置,替换成子类函数地址;(我觉得这个是关键)
      • 如果没有重写:保留父类虚函数地址。
  2. 虚函数指针 vptr
    • 属于对象 ,每个对象的内存布局最开头会隐藏增加一个指针 vptr。
    • 对象构造时,编译器自动给vptr赋值,指向当前类的虚函数表。 注意:构造函数不能是虚函数 ,对象构造过程中vptr 还没初始化完成。

多态生效的 3 个必要条件

  • 存在继承关系;
  • 父类中有虚函数 ,子类重写该虚函数;
  • 使用父类指针 / 引用指向子类对象。
cpp 复制代码
class Base{
public:
    virtual void func(){ cout << "Base" << endl; } // 虚函数
};
class Son : public Base{
public:
    void func() override { cout << "Son" << endl; } // 重写
};

int main(){
    Base* p = new Son(); // 父类指针指向子类对象
    p->func(); // 运行时多态,输出 Son ✔
    delete p;
    return 0;
}

如果不用指针 / 引用,直接Base b = Son();,会发生对象切片,无法触发多态。


这句话什么意思?

对象切片(Object Slicing)通俗讲解

Base b = Son();这句话的含义:在栈上创建一个 Son 临时对象,然后把这个临时对象里属于 Base 父类那一部分数据拷贝出来,赋值给 Base 类型变量 b;子类独有的成员直接被切掉丢掉,只剩下父类部分。 所以最后得到的 b,本质就是一个纯 Base 对象,不再是 Son 对象,自然无法触发多态。

先看内存结构

cpp 复制代码
class Base{
public:
    virtual void func(){ cout << "Base" << endl; }
    int base_val = 1;
};
class Son : public Base{
public:
    void func() override { cout << "Son" << endl; }
    int son_val = 2; // 子类独有的成员
};

Son 对象内存布局:

[vptr_Son][base_val][son_val]

包含:子类虚指针 + 父类成员 + 子类新增成员

执行 Base b = Son(); 的拷贝过程:

只拷贝父类那一段:[vptr_Base][base_val]


son_val 直接被切掉(sliced) ;并且 b 的虚指针 vptr 被设置成指向 Base 的虚函数表,不再指向 Son 的虚表。

最终 b 就是一个 Base 对象,不是 Son。此时调用 b.func(),直接调用 Base::func,不会走多态。


对比:用指针 / 引用为什么就不会切片?

cpp 复制代码
Son s;
Base* p = &s;   // 指针,只是保存s对象的地址,不会拷贝对象
Base& ref = s;  // 引用,也是s对象的别名,不会拷贝对象
  • 指针 / 引用不会复制对象内存,不会产生新对象;
  • 原始的 Son 对象完整保留在内存中,它内部的**vptr_Son**仍然指向 Son 虚表;
  • 所以 p->func() / ref.func() 在运行时查表,调用 Son::func,触发多态。



调用过程

p->func()执行步骤:

  1. 运行时取出对象*p内存首地址的**vptr**;
  2. 通过vptr找到对应的虚函数表 vtable;
  3. 在虚表中找到**func对应的函数地址**;
  4. 调用该地址函数。

关键点:函数地址是运行时才确定,叫动态绑定;普通成员函数编译期就确定地址,静态绑定。

运行时,对象实际类型不同,则会得到不同的虚函数表。(类首先需要包含虚函数)


虚析构函数 父类析构建议加**virtual**。

如果父类指针指向子类对象,delete p;时,只有父类析构是虚函数,才会先调用子类析构,防止子类内存泄漏。


多重继承虚表:一个对象会有多个 vptr ,每继承一个带虚函数的父类就多一张虚表。



虚函数带来的代价:

  • 对象内存增加一个指针大小(8 字节,64 位);
  • 每次调用虚函数要查表,有微小运行开销。


纯虚函数:virtual void func() = 0;,这个类是抽象类,不能实例化对象 ,虚表中对应位置存**nullptr**。



常见坑

  • 子类重写时,函数名 / 参数 / 返回值必须匹配,否则不是重写,只是函数隐藏;C++11 override关键字用来校验。
  • 静态成员函数不能是 virtual,静态函数不属于对象,没有 this 指针,无法通过 vptr 查表。



虚函数表内存布局

环境:64 位系统,指针占 8 字节

cpp 复制代码
class Base {
public:
    virtual void func1();
    virtual void func2();
    int base_a = 10;
};

class Son : public Base {
public:
    void func1() override; // 重写func1
    virtual void func3();  // 新增虚函数
    int son_b = 20;
};

Base 类对象内存布局

cpp 复制代码
Base obj;
obj内存:
+----------------+
| vptr_Base      |  8字节 → 指向Base的虚函数表
+----------------+
| base_a = 10    |  4字节
+----------------+

Base虚函数表 vtable_Base(只读数据段,属于类,所有对象共用)
+----------------+
| &Base::func1   | 虚函数地址
+----------------+
| &Base::func2   | 虚函数地址
+----------------+
| nullptr        | 虚表结束标记(部分编译器)
+----------------+

Son 子类对象内存布局(单继承)

cpp 复制代码
Son son_obj;
son_obj内存:
+----------------+
| vptr_Son       | 8字节 → 指向Son虚函数表
+----------------+
| base_a = 10    | 4字节(继承父类成员)
+----------------+
| son_b = 20     | 4字节(子类新增成员)
+----------------+

Son虚函数表 vtable_Sase:
+----------------+
| &Son::func1    | ✅ 重写:替换成子类func1地址
+----------------+
| &Base::func2   | ❗没重写:保留父类func2地址
+----------------+
| &Son::func3    | ✅ 子类新增虚函数,追加在后面
+----------------+
| nullptr        |
+----------------+

重点:单继承规则

  1. 子类重写哪个虚函数,虚表对应位置 就替换成子类函数地址;
  2. 没有重写的虚函数,保留父类地址;
  3. 子类新增虚函数,追加到虚表尾部。

演示对象切片 Base b = son_obj;

cpp 复制代码
Son son_obj 原始内存:
[vptr_Son][base_a][son_b]

Base b = son_obj; // 调用Base拷贝构造,只拷贝父类部分
b 的内存:
+----------------+
| vptr_Base      | ⚠️ vptr被重置!指向Base虚表
+----------------+
| base_a =10     |
+----------------+
// son_b 直接被丢弃,切掉了

所以b.func1(),查表找到Base::func1,不会调用 Son 的版本,多态失效。


多继承场景

普通多继承(两个父类都带虚函数)

cpp 复制代码
class A{
public:
    virtual void fa();
    int a_val = 1;
};
class B{
public:
    virtual void fb();
    int b_val = 2;
};

// C同时继承A、B
class C : public A, public B{
public:
    void fa() override; // 重写A的虚函数
    virtual void fc();  // C新增虚函数
    int c_val = 3;
};

对象 C 的内存布局

cpp 复制代码
C obj;
+----------------+
| vptr_A         |  属于A的虚指针
+----------------+
| a_val = 1      |  A的成员变量
+----------------+
| vptr_B         |  属于B的虚指针(第二个!多继承核心:几个带虚函数父类,就几个vptr)
+----------------+
| b_val = 2      |  B的成员变量
+----------------+
| c_val = 3      |  C自己新增成员
+----------------+

两张虚函数表

vtable_A(A 的虚表)

cpp 复制代码
& C::fa      // 重写A::fa,替换成C版本
& C::fc      // C新增虚函数,追加到第一个父类A虚表末尾
nullptr

vtable_B(B 的虚表)

cpp 复制代码
& B::fb      // fb没有重写,保留B原始函数地址
nullptr

关键要点

  1. 每继承一个拥有虚函数 的父类,子类对象就会多出一个**vptr**;
  2. 子类新增虚函数,只会追加到第一个父类 的虚表后面;
  3. 父类指针指向子类对象:
cpp 复制代码
C c;
A* pa = &c; // pa指向c对象起始地址,vptr_A位置
B* pb = &c; // pb 指向c对象里面vptr_B的地址!指针会自动偏移!

pb = &c这里编译器自动做地址偏移,跳到 B 子对象起始位置。

如果强转反过来,C* pc = (C*)pb;,不安全,需要小心指针偏移问题。






菱形继承(钻石继承)------ 多继承经典坑

cpp 复制代码
结构:
        Base
       /    \
      A      B
       \    /
         C

代码:

cpp 复制代码
class Base{
public:
    virtual void func();
    int base_val = 10;
};

class A : public Base{
public:
    int a_val = 1;
};

class B : public Base{
public:
    int b_val = 2;
};

class C : public A, public B{
public:
    int c_val = 3;
};

C 对象内存布局(普通菱形继承,没有虚继承)

cpp 复制代码
C obj
+----------------+
| vptr_A_Base    // A继承来的Base虚指针
+----------------+
| base_val = 10  // A带来的Base成员副本【第一份Base】
+----------------+
| a_val = 1
+----------------+
| vptr_B_Base    // B继承来的Base虚指针
+----------------+
| base_val = 10  // B带来的Base成员副本【第二份Base】
+----------------+
| b_val = 2
+----------------+
| c_val =3
+----------------+

两个大问题:

  1. 数据冗余 :Base的成员变量base_val存了两份,浪费内存;

  2. 二义性

    cpp 复制代码
    C c;
    c.base_val; // 编译报错!不知道是A里面那份还是B里面那份Base的base_val

    即使你写 c.A::base_val 可以指定访问,但冗余问题还在。




虚继承 virtual inheritance ------ 解决菱形继承

修改继承方式:A、B 虚继承 Base

cpp 复制代码
class Base{
public:
    virtual void func();
    int base_val = 10;
};

class A : virtual public Base{ // virtual 虚继承
public:
    int a_val = 1;
};

class B : virtual public Base{ // virtual 虚继承
public:
    int b_val = 2;
};

class C : public A, public B{
public:
    int c_val =3;
};

virtual public Base:虚继承,不是把 Base 拷贝进 A、B 里面,而是 A、B 记录一个偏移量,运行时去找唯一的 Base
虚继承 ≠ 虚函数。虚继承解决菱形继承,虚函数实现多态,两套完全独立机制。

C 对象内存布局

cpp 复制代码
C对象
├─A子对象:
│   vbptr_A      ←【虚基类指针】,不是虚函数指针vptr!
│   A自己的成员
├─B子对象:
│   vbptr_B      ←【虚基类指针】
│   B自己的成员
├─C自己成员
│
└─【唯一一份Base】放在整个对象末尾!
    vptr_Base
    base_val = 10

vbptr(虚基类指针)指向【虚基类表 vbtable】

虚基类表里存:当前这个子对象到公共 Base 的内存偏移量

  • vbptr_A 指向 A 的虚基类表:记录一个数字,比如offset=24,意思:从 A 的起始地址向后偏移 24 字节,找到 Base
  • vbptr_B 指向 B 的虚基类表:记录offset=16,意思:从 B 的起始地址向后偏移 16 字节,找到 Base



访问 c.base_val 的过程

  1. 编译器发现 base_val 属于虚基类 Base;
  2. 通过 A 或者 B 的**vbptr拿到虚基类表**;
  3. 查表得到偏移量;
  4. 当前地址 + 偏移 → 定位到唯一那一份 Base,读取 base_val。

✅ 现在:Base 只有一份!c.base_val 不再有二义性。




虚继承 + 虚函数,混合在一起(容易混淆的地方)

Base 里面带虚函数,所以公共 Base 子对象自己有一个 vptr_Base

❗A 和 B 里面的**vbptr是虚基类指针**,不是虚函数指针 vptr

  • vptr:虚函数指针,用来找虚函数表,实现多态
  • vbptr:虚基类指针,用来找虚基类表,找公共基类(虚继承专用)

举个直观类比(方便理解)

普通继承:A 买了一份 Base,B 也单独买一份 Base。C 继承 A、B,手里拿到两份 Base。

虚继承:A、B 都不把 Base 买下来,只各自拿一张纸条(vbptr,虚基类表),纸条上写着 Base 存放的地址偏移。C 继承 A、B 之后,只创建一份 Base 放在最后,A 和 B 的纸条都指向这同一个 Base。




虚继承的开销?

  1. 每个虚继承的子类,多一个vbptr****指针(8 字节 64 位);
  2. 访问虚基类成员时,多一次查表 + 偏移计算,运行时开销。

所以虚继承一般只用来解决菱形继承,不推荐随便使用。




我想知道,上面的情况下,a还可以被new出来吗?

可以!A 类依然可以 new A() 创建对象,不受虚继承 virtual public Base 的影响。 虚继承只是修改继承体系中子类的内存布局 ,A 本身仍然是一个正常可实例化的类。

cpp 复制代码
A* pa = new A(); 完全合法,看看 new A() 的内存布局
当我们单独创建一个 A 对象:
cpp 复制代码
A 对象(new A出来的)
+----------------+
| vbptr_A        // A的虚基类指针
+----------------+
| a_val = 1      // A自己成员
+----------------+
| vptr_Base      // Base虚指针(Base实例放在末尾)
+----------------+
| base_val = 10  // Base成员
+----------------+
  • 单独 new A 的时候,编译器会自动生成一份 Base放在 A 对象尾部;
  • vbptr_A 虚基类表里面记录偏移,找到这个 Base;
  • 虚继承的含义是:当 A 和 B 同时被 C 继承的时候,共享 Base;单独实例 A,A 自己带一份 Base。


一句话理解虚继承:virtual public Base 不是说 A 不能单独存在;它只是一个标记:如果未来有人同时继承 A、B,就共用一份 Base;如果单独用 A,A 自己正常带一份 Base。




其实,虚继承和虚函数,菱形继承混在一起让我有点晕了,先这样,后面再梳理梳理。

相关推荐
无名猿1 小时前
unique_ptr 完全指南:独占所有权与零开销
c++·性能优化·内存管理·标准库·现代c++
笑鸿的学习笔记1 小时前
C++笔记之大块顺序写
java·c++·笔记
无名猿2 小时前
对象构造与析构顺序全解:声明顺序、继承链与逆序析构
c++·内存管理·调试技巧·现代c++
殷色玫瑰2 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·linux·c语言·c++
C++ 老炮儿的技术栈2 小时前
不一样的数值交换
数据结构·c++·人工智能·算法·c·csdn开发云
C++ 老炮儿的技术栈3 小时前
main函数之后的调用
c语言·c++·人工智能·qt·mfc·c
知识分享小能手3 小时前
C++ 学习教程,从入门到精通,C++ 友元、异常和其他特性 — 详细知识点(15)
开发语言·c++·学习
ebiobiz3 小时前
极海 APM32 使用 Nimmake 编译指南
c++·python·单片机·嵌入式硬件·mcu
朝朝辞暮i3 小时前
C++ 第 26 课:对象、指针与 ->
java·开发语言·c++