C++ 多态机制与虚函数实现原理(补充)

对于上一篇文章遗留下来问题的补充:

一、菱形继承(钻石问题)

在多继承场景中,最经典的问题就是菱形继承(Diamond Inheritance),也叫钻石问题。

1.什么是菱形继承?

cpp 复制代码
class GrandBase {          // 祖父类
public:
    int value = 100;
    virtual void show() { std::cout << "GrandBase\n"; }
};

class Base1 : public GrandBase { };   // 父亲1
class Base2 : public GrandBase { };   // 父亲2

class Derived : public Base1, public Base2 { };  // 孙子类

此时 Derived 对象中会出现两份 GrandBase 子对象(两份 value,两个 vptr)。

2.不使用虚继承会产生什么问题?

  • 数据冗余:同一份基类数据被复制两份,浪费内存。
  • 访问二义性:编译器不知道你想访问哪一份 GrandBase 的成员。
cpp 复制代码
Derived d;
d.value = 200;           // 编译错误:二义性!哪个 value?
d.Base1::value = 200;    // 必须显式限定,麻烦且容易出错
d.show();                // 同样二义性

即使你用限定符解决二义性,数据仍然是两份,这在大多数场景下都不是我们想要的。

3.虚继承(virtual inheritance)如何解决?

只需在中间层继承时加上 virtual 关键字:

cpp 复制代码
class Base1 : virtual public GrandBase { };
class Base2 : virtual public GrandBase { };

class Derived : public Base1, public Base2 { };

效果:

  • Derived 中只有一份GrandBase 子对象。
  • 数据冗余消失。
  • 访问不再二义性:d.value、d.show() 都可以直接使用。

4.虚继承的底层实现原理(vbptr + vbtable)

内存布局对比(简化示意,32位为例):

不使用虚继承时(有两份 GrandBase):

bash 复制代码
Derived 对象:
├── Base1 子对象
│   ├── vptr (指向 Base1 的 vtable)
│   └── GrandBase 子对象(value + vptr)
├── Base2 子对象
│   ├── vptr (指向 Base2 的 vtable)
│   └── GrandBase 子对象(value + vptr)   ← 重复!
└── Derived 自己的成员

使用虚继承后(只有一份 GrandBase):

bash 复制代码
Derived 对象:
├── Base1 子对象(不含 GrandBase)
│   ├── vptr (普通虚函数表指针)
│   └── vbptr(虚基类指针) → 指向 vbtable
├── Base2 子对象(不含 GrandBase)
│   ├── vptr
│   └── vbptr → 指向 vbtable
├── Derived 自己的成员
└── GrandBase 子对象(共享的,放在对象末尾)
    ├── value
    └── vptr(可能共享或调整)

vbptr 和 vbtable 的工作机制:

1.每个虚继承的类(Base1、Base2)都会在对象中多出一个隐藏的 vbptr(virtual base pointer)。

2.vbptr 指向一个 vbtable(虚基类表),表里存放的是偏移量(offset)。

3.通过这个偏移量,运行时可以动态计算出共享的 GrandBase 子对象在当前对象中的真实位置。

4.GrandBase 子对象被"推迟"到整个派生类对象的末尾,由最派生类(Derived)负责构造和存放。

关键特点:

1.vbptr 通常位于每个虚继承路径的子对象开头附近。

2.虚基类表(vbtable)是编译期生成的静态表,不占用每个对象的空间。

3.访问虚基类成员时,编译器会生成代码:对象地址 + vbptr 指向的偏移量 来找到正确位置。

代价:

1.每个对象体积增大(多出若干个 vbptr,通常 4/8 字节一个)。

2.访问虚基类成员的效率略低(需要一次间接寻址)。

3.构造函数更复杂:最派生类负责初始化虚基类,中间类只负责设置 vbptr。

注意:

1.virtual 关键字只需写在直接继承虚基类的中间类中(Base1 和 Base2)。

2.虚继承与虚函数(virtual function)是完全独立的两个机制,不要混淆。

二、常见陷阱与深入理解

在使用多态和继承时,以下四个陷阱非常容易踩到,务必深刻理解其原因和正确写法。

1.通过基类指针 delete 非虚析构 → 未定义行为
cpp 复制代码
Base* p = new Derived();
delete p;     // 如果 Base 的析构函数不是 virtual -> 危险

原因:

  • delete 时,编译器根据指针的静态类型(Base*)决定调用哪个析构函数。
  • 如果 ~Base() 不是 virtual,只会调用 Base::Base(),Derived::Derived() 根本不会执行。
  • 结果:Derived 中申请的资源(内存、文件句柄、锁等)得不到释放,导致内存泄漏或资源泄漏。
  • 这属于未定义行为,可能崩溃、数据损坏,或在某些环境下"侥幸"正常,换个编译器/优化级别就出问题。

正确做法:

cpp 复制代码
class Base {
public:
    virtual ~Base() = default;   // 必须加 virtual
};

凡是打算多态删除的基类,析构函数必须是 virtual.

2.在构造函数/析构函数中调用虚函数 -> 永远调用当前类的版本

反直觉现象:

在 Base 的构造函数里调用虚函数,永远执行的是 Base 版本,而不是派生类重写的版本。

根本原因:

  • 对象构造顺序是先基类后派生类。
  • 当 Base 构造函数执行时,Derived 部分还没有构造完成,对象此时还"不是一个完整的 Derived"。
  • 析构时顺序相反,先派生类后基类,Base 析构时 Derived 部分已经销毁。
  • C++ 标准明确规定:此时对虚函数的调用不进行动态绑定,而是静态绑定到当前正在构造/析构的类的版本。

建议:

  • 尽量避免在构造函数和析构函数中调用虚函数。
  • 如果确实需要,可以在对象完全构造后再调用(例如通过成员函数或工厂模式)。
3.值传递对象(切片)-> 多态彻底失效

示例:

cpp 复制代码
void process(Base b) {      // 值传递
    b.show();               // 永远调用 Base::show(),即使传入 Derived 对象
}

std::vector<Base> vec;
vec.push_back(Derived{});   // 切片发生

原因:

值传递/值拷贝时,编译器只会拷贝 Base 部分的数据和 vptr。

Derived 特有的成员和虚函数表信息被"切掉"。

对象不再是 Derived,变成了一个纯粹的 Base 对象,多态机制失效。

正确做法:

始终使用指针(Base*、std::unique_ptr)或引用(Base&、const Base&)。

永远不要用值传递可能发生多态的对象。

可以这样说:多态只认指针和引用,值传递直接切片。

4.不要在 vtable 里放非虚函数

含义:

vtable(虚表)只存放声明为 virtual 的函数的地址。

非虚函数在编译期就已经静态绑定,直接跳转到函数地址,不需要通过 vptr 查表。

因此,非虚函数不会出现在 vtable 中。

为什么强调这一点?

如果把不需要多态的函数也声明为 virtual,会白白增加每个对象的 vptr 开销和 vtable 空间。

正确做法:只有真正需要"运行时根据实际类型调用"的函数才加 virtual。

相关推荐
Yupureki2 小时前
《实战项目-个人在线OJ平台》1.项目简介和演示
c语言·数据结构·c++·sql·算法·性能优化·html5
无敌秋2 小时前
C++ public, private, protected类的继承
开发语言·c++
m0_579393662 小时前
C++代码混淆与保护
开发语言·c++·算法
qq_148115372 小时前
C++中的享元模式实战
开发语言·c++·算法
夜悊2 小时前
指针、引用和常量的关系
c++
烟花巷子2 小时前
C++中的解释器模式
开发语言·c++·算法
暮冬-  Gentle°2 小时前
C++中的策略模式高级应用
开发语言·c++·算法
txinyu的博客2 小时前
解析muduo源码之 HttpRequest.h
c++
2401_879693872 小时前
C++中的代理模式高级应用
开发语言·c++·算法