C++ 继承与多态
- 继承(Inheritance)
- 派生类的默认成员函数
- 继承中的菱形继承(Diamond Inheritance)
- 虚拟继承(Virtual Inheritance)—— 解决方案
- 多态(Polymorphism)
- 虚函数表(Virtual Function Table,vtable)
- 多继承中的虚函数表(Multiple Inheritance vtable)
- 多继承举例
继承(Inheritance)
在 C++ 中,继承是一种通过已有类型构建新类型的机制,用来表达“一个类是另一个类的一种特殊形式”。它是面向对象编程(OOP)的核心概念之一,主要用于代码复用、语义抽象和多态支持。
从本质上讲,继承并不是简单的代码复制,而是一种类型关系的建立。
继承解决了什么问题?
在没有继承的情况下,如果多个类拥有相同的数据成员或成员函数,通常只能通过复制代码的方式实现,这会带来:
- 重复代码多,维护成本高
- 修改公共逻辑时容易遗漏
- 类型之间缺乏统一抽象
继承的出现,允许我们将公共部分抽象到一个基类中,由派生类复用并扩展,从而解决这些问题。
基类与派生类
继承关系中涉及两类角色:
基类(Base Class)
提供公共接口和通用实现,表示“更抽象、更通用”的概念。派生类(Derived Class)
在基类的基础上进行扩展,表示“更具体、更特化”的概念。
派生类在语义上应满足:
派生类 is-a 基类
例如:
Studentis aPersonCircleis aShapeDogis anAnimal
如果不满足 is-a 关系,就不应该使用继承。
继承的基本定义形式
在 C++ 中,继承通过在类定义中指定基类来实现:
class Derived : public Base {
// 派生类定义
};
这里的含义是:
Derived继承自BaseDerived拥有Base的成员(受访问控制影响)Derived是一个新的类型,而不是Base的别名
继承的本质:建立类型层次
继承在语言层面建立了一种类型层次结构(type hierarchy):
Base
↑
Derived
这种层次结构使得:
- 派生类对象可以被当作基类对象来使用(向上转换)
- 可以通过统一的基类接口操作不同的派生类对象
- 为多态(polymorphism)提供基础
这也是 C++ 支持运行时多态的前提条件。
继承与“拥有”的区别
继承表达的是 “是什么(is-a)”,而不是 “有什么(has-a)”。
对比:
class Engine {};
class Car {
Engine e; // has-a(组合)
};
class Student : public Person {
// is-a
};
错误使用继承往往会导致:
- 类型语义混乱
- 接口暴露不合理
- 破坏可维护性
因此在设计中常见的一条原则是:
优先使用组合,而不是继承
只有当关系明确是 is-a 时,才选择继承。
继承与接口抽象
在 C++ 中,继承不仅用于复用实现,也常用于接口抽象:
class Shape {
public:
virtual double area() const = 0;
};
class Circle : public Shape {
public:
double area() const override {
return 3.14 * r * r;
}
};
这里:
Shape提供统一接口- 不关心具体实现
- 通过基类指针/引用实现多态调用
总的来说:
- 继承是一种 类型关系,而不是代码复制手段
- 派生类是基类的一种特殊形式(is-a)
- 继承用于代码复用、语义抽象和多态支持
- 继承通过
class Derived : public Base定义 - 不满足 is-a 关系的设计应避免使用继承
- 继承是 C++ 多态机制和类型转换规则的基础
派生类的默认成员函数
在 C++ 中,派生类同样遵循“默认成员函数”的生成规则。理解派生类的默认成员函数,必须先明确一个前提:
派生类本身也是一个类,它也有一套“编译器可能自动生成的成员函数”。
不过,与普通类相比,派生类的默认成员函数是否被生成、以及它们的行为,都会受到“基类”的直接影响。
一、回顾:类的默认成员函数(简要)
对于一个普通类,编译器可能自动生成以下 6 个默认成员函数:
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数(C++11)
- 移动赋值运算符(C++11)
这些函数是否生成,取决于:
- 是否被用户显式声明
- 成员是否可拷贝 / 可移动 / 可析构
二、派生类的默认成员函数是什么?
派生类拥有与普通类完全相同的一组默认成员函数:
Derived()~Derived()Derived(const Derived&)Derived& operator=(const Derived&)Derived(Derived&&)Derived& operator=(Derived&&)
不同之处在于:
派生类的这些默认成员函数,会隐式地调用“基类对应的成员函数”。
三、派生类默认构造函数的行为
如果派生类没有显式定义构造函数,编译器会尝试生成默认构造函数:
class Base {
public:
Base() {}
};
class Derived : public Base {
int x;
};
等价行为(概念上):
Derived::Derived()
: Base(), x()
{}
注意点
- 基类必须可默认构造
- 如果基类没有默认构造函数,派生类将无法自动生成默认构造函数
class Base {
public:
Base(int);
};
class Derived : public Base {
};
// ❌ 编译错误:Base 无默认构造
四、派生类的默认析构函数
派生类的默认析构函数行为是固定的:
~Derived()
析构顺序:
- 先析构派生类自身成员
- 再调用基类析构函数
这一步是 自动完成的,无法被绕过。
多态场景的关键点
如果存在多态:
Base* p = new Derived;
delete p;
那么 Base 的析构函数必须是 virtual,否则会导致派生类析构函数不被调用。
五、派生类的默认拷贝构造函数
当派生类的拷贝构造函数被隐式生成时,其行为是:
Derived::Derived(const Derived& other)
: Base(other), // 拷贝基类子对象
member(other.member)
{}
也就是说:
- 先拷贝 基类子对象
- 再拷贝 派生类自己的成员
关键限制
- 基类必须 可拷贝构造
- 否则派生类的拷贝构造函数会被 删除(= delete)
六、派生类的默认拷贝赋值运算符
默认生成的拷贝赋值行为:
Derived& Derived::operator=(const Derived& rhs) {
Base::operator=(rhs); // 赋值基类子对象
member = rhs.member; // 赋值派生类成员
return *this;
}
同样:
- 基类必须可拷贝赋值
- 否则派生类的拷贝赋值会被删除
七、派生类的默认移动构造与移动赋值
C++11 之后,派生类也支持隐式生成移动操作。
默认移动构造行为:
Derived::Derived(Derived&& other)
: Base(std::move(other)),
member(std::move(other.member))
{}
默认移动赋值行为:
Derived& Derived::operator=(Derived&& rhs) {
Base::operator=(std::move(rhs));
member = std::move(rhs.member);
return *this;
}
重要规则
基类必须支持对应的移动操作
如果基类只有拷贝,没有移动:
- 派生类的移动操作可能被删除
- 或退化为拷贝(取决于具体情形)
八、派生类默认成员函数的生成规则总结
派生类的默认成员函数是否生成,取决于 三方面:
- 派生类自身是否显式声明了这些函数
- 派生类成员是否满足拷贝 / 移动 / 析构要求
- 基类是否支持对应操作
一句话总结:
派生类能否“默认生成某个成员函数”,
完全取决于“基类 + 成员 + 自身声明”的综合结果。
九、一个非常容易忽略的点
只要基类的某个特殊成员函数被删除或不可访问,
派生类对应的默认成员函数也会被隐式删除。
这是很多“为什么派生类不能拷贝 / 不能移动”的根本原因。
- 派生类与普通类一样,拥有完整的一套默认成员函数
- 派生类默认成员函数会 自动调用基类的对应成员函数
- 基类是否支持构造 / 析构 / 拷贝 / 移动,直接决定派生类是否可用
- 构造顺序:先基类,后派生类
- 析构顺序:先派生类,后基类
- 理解派生类默认成员函数,是理解切片、多态、对象模型的基础
继承中的菱形继承(Diamond Inheritance)
菱形继承是 C++ 多重继承中最经典、也最容易出问题的一种结构。理解它,基本就理解了 虚拟继承存在的根本原因。
我们一步一步来:
先看问题 → 再看为什么错 → 最后看虚拟继承如何在底层解决。
一、什么是菱形继承?
所谓“菱形继承”,指的是类继承关系形成一个菱形结构:
graph TB
A[Base]
B[Derived1]
C[Derived2]
D[MostDerived]
A --> B
A --> C
B --> D
C --> D
代码形式如下:
struct A {
int x;
};
struct B : public A {};
struct C : public A {};
struct D : public B, public C {};
二、菱形继承带来的核心问题
1️⃣ 数据二义性(最直观的问题)
D d;
d.x; // ❌ 编译错误:不明确
原因是:
D中 存在两份A子对象- 一份来自
B - 一份来自
C
编译器不知道你要的是:
d.B::x // A via B
d.C::x // A via C
2️⃣ 内存冗余(隐藏但更严重)
我们来看 没有虚拟继承时的对象内存布局。
❌ 非虚继承下的内存结构
graph LR
subgraph D_object["D 对象内存布局(非虚继承)"]
subgraph B_part["B 子对象"]
A1["A 子对象 (via B)"]
end
subgraph C_part["C 子对象"]
A2["A 子对象 (via C)"]
end
end
等价内存示意(线性):
| A(B) | B | A(C) | C | D |
结果:
A被 重复存储两次- 状态可能不一致
- 构造 / 析构
A两次(非常危险)
三、为什么这是一个“设计层面的问题”?
菱形继承本质上在语义上通常是这样的:
B 是一种 A
C 是一种 A
D 既是 B,又是 C
👉 那 D 应该“只有一个 A”
这时:
- 逻辑上:A 应该唯一
- 语言默认行为:A 被复制了两份
➡️ 语言默认行为不符合语义需求
虚拟继承(Virtual Inheritance)—— 解决方案
四、什么是虚拟继承?
虚拟继承通过 virtual 关键字告诉编译器:
“这个基类在整个继承体系中只保留一份实例”
改写代码:
struct A {
int x;
};
struct B : virtual public A {};
struct C : virtual public A {};
struct D : public B, public C {};
五、虚拟继承解决了什么问题?
✔ 只有一份 A 子对象
✔ 不再有成员二义性
✔ 构造 / 析构顺序可控
六、虚拟继承下的内存布局(核心)
✅ 虚拟继承后的对象结构
graph LR
subgraph D_object["D 对象内存布局(虚拟继承)"]
subgraph B_part["B 子对象"]
vbptrB["虚基指针 vbptr"]
end
subgraph C_part["C 子对象"]
vbptrC["虚基指针 vbptr"]
end
A1["唯一的 A 虚基类子对象"]
end
线性理解:
| B(vbptr) | C(vbptr) | A | D |
关键变化:
A不再嵌入在 B / C 中A被提升为 “虚基类子对象”B、C只保存一个 虚基指针(vbptr)
七、虚基表(Virtual Base Table,vbtable)的作用
1️⃣ vbptr 是什么?
- vbptr:指向虚基表的指针
- 每个虚继承路径都有一个 vbptr
2️⃣ vbtable 存什么?
vbtable 中记录的是:
- 从当前子对象
- 到 虚基类子对象 A 的偏移量
示意:
graph TB
vbptr --> vbtable
vbtable --> offsetA["offset to A"]
访问 A::x 时:
// 伪代码
A* a = (char*)this + vbtable[offset_to_A];
八、对照:非虚继承 vs 虚拟继承(重点对比)
❌ 非虚继承
graph TB
D --> B --> A1
D --> C --> A2
- A 被复制两份
- 没有 indirection
- 访问快,但语义错误
✅ 虚拟继承
graph TB
D --> B
D --> C
B -->|vbptr| A
C -->|vbptr| A
- A 只有一份
- 多一次间接寻址
- 正确表达“共享基类”
九、构造与析构顺序的变化(重要)
非虚继承构造顺序
A(B) → B → A(C) → C → D
虚拟继承构造顺序
A → B → C → D
虚基类永远由“最派生类”负责构造
析构顺序完全相反。
十、虚拟继承的代价
虚拟继承并不是免费的:
- 对象体积增大(vbptr)
- 成员访问多一次间接寻址
- 构造 / 析构更复杂
- 对 ABI 和布局要求更高
所以:
只有在“确实需要共享同一基类实例”时,才使用虚拟继承
菱形继承会导致基类子对象被重复继承,引发成员二义性和内存冗余问题。
C++ 通过虚拟继承机制,使虚基类在整个继承体系中只保留一份实例。
虚拟继承的底层实现依赖虚基指针(vbptr)和虚基表(vbtable),
通过间接寻址定位唯一的虚基类子对象,从而解决菱形继承问题。
接下来,我们看一下下面这段代码:
#include <iostream>
using namespace std;
class Base {
public:
int a;
};
class Derived1 : virtual public Base {
public:
};
class Derived2: virtual public Base {
public:
};
class Derived : public Derived1, public Derived2 {
public:
};
int main() {
Derived d;
d.Derived1::a = 10;
std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
d.Derived2::a = 20;
std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
d.a = 30;
std::cout << d.Derived1::a << " " << d.Derived2::a << " " << d.a << std::endl;
std::cout << &(d.Derived1::a) << " " << &(d.Derived2::a) << " " << &(d.a) << std::endl;
return 0;
}
运行结果为:
10 10 10
20 20 20
30 30 30
0xe715ffe20 0xe715ffe20 0xe715ffe20
这样一来,我们的上述论述就得到了验证:无论是通过 Derived1 还是 Derived2 访问 Base 的成员 a,实际上访问的都是同一份数据。
多态(Polymorphism)
在 C++ 中,多态指的是:
同一接口,在不同对象类型上表现出不同行为的能力。
它解决的不是“代码能不能运行”的问题,而是:
如何在不修改调用方代码的前提下,让程序在运行期选择正确的行为。
一、从直觉理解多态
先看一个最简单、最直观的例子:
class Animal {
public:
virtual void speak() {
std::cout << "Animal speaks\n";
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "Dog barks\n";
}
};
class Cat : public Animal {
public:
void speak() override {
std::cout << "Cat meows\n";
}
};
调用代码:
Animal* a1 = new Dog();
Animal* a2 = new Cat();
a1->speak(); // Dog barks
a2->speak(); // Cat meows
这里:
- 调用表达式完全一样:
a->speak() - 实际执行的函数不同
- 决定权在 运行期(runtime)
这就是多态。
二、多态解决的核心问题
如果没有多态,你只能这样写:
if (type == DOG) { ... }
else if (type == CAT) { ... }
问题在于:
- 每新增一个派生类
- 就要修改所有
if / switch - 代码 违反开闭原则(Open–Closed Principle)
多态的目标是:
新增行为,而不修改已有代码
三、多态成立的三个必要条件(非常重要)
在 C++ 中,要实现运行时多态,必须同时满足:
1️⃣ 存在继承关系
2️⃣ 基类中有 virtual 函数
3️⃣ 使用基类指针或基类引用指向派生类对象
缺一不可。
Base* p = new Derived(); // ✔
Base& r = derived; // ✔
Base obj = derived; // ❌(对象切片)
四、静态绑定 vs 动态绑定
静态绑定(非多态)
class Base {
public:
void foo() {
std::cout << "Base\n";
}
};
class Derived : public Base {
public:
void foo() {
std::cout << "Derived\n";
}
};
Base* p = new Derived();
p->foo(); // Base::foo
原因:
foo不是虚函数- 调用在编译期就确定
- 实现的本质上是函数重定义(函数隐藏),而不是重写(Override)
动态绑定(多态)
class Base {
public:
virtual void foo() {
std::cout << "Base\n";
}
};
class Derived : public Base {
public:
void foo() override {
std::cout << "Derived\n";
}
};
Base* p = new Derived();
p->foo(); // Derived::foo
原因:
- 函数调用通过 虚函数表(vtable)
- 决策延迟到运行期
- 实现的本质是函数重写(Override),底层会基于虚函数表进行动态分发
五、多态与“接口”的关系
在 C++ 中:
- 抽象基类 ≈ 接口
- 通过 纯虚函数表达“能力”
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
派生类只需:
class Circle : public Shape {
public:
double area() const override {
return 3.14 * r * r;
}
};
调用方只依赖 Shape:
void printArea(const Shape& s) {
std::cout << s.area() << std::endl;
}
六、多态与对象切片(Slice)
多态一定不能这样用:
Base b = Derived(); // 对象切片
b.foo(); // Base::foo
原因:
- 只拷贝了
Base子对象 - 派生类部分被丢弃
- vptr 指向 Base 的 vtable,导致调用的实际上是基类版本
👉 多态在此直接失效
七、多态在内存层面的本质(一句话)
多态本质上是:
通过基类指针访问对象时,借助对象内部的 vptr,在运行期选择正确的函数入口。
你后面讲的:
- vptr
- vtable
- RTTI
- dynamic_cast
都会围绕这一点展开。
八、多态的典型使用场景
- 框架 / 库设计
- 插件系统
- 回调接口
- 状态机
- GUI / 游戏 / 渲染引擎
- 面向接口编程
多态是面向对象的核心机制之一,它通过虚函数和继承,使得程序能够在运行期根据对象的实际类型选择正确的行为,从而实现高扩展、低耦合的设计。
虚函数表(Virtual Function Table,vtable)
虚函数表是 C++ 运行时多态的核心实现机制。
如果说“多态是什么”是概念层面的理解,那么 vtable 决定了多态是如何真正发生的。
一、为什么需要虚函数表?
先看一个问题:
Base* p = new Derived();
p->func();
在编译期:
- 编译器只知道
p的静态类型是Base* - 但实际对象类型是
Derived
👉 编译期无法确定该调用哪个函数实现
解决办法只有一个:
把函数选择的决定权,延迟到运行期
虚函数表正是为此而生。
二、vtable 的基本概念
1️⃣ vtable 是什么?
虚函数表是一张函数指针表
表中存放的是:
👉 虚函数在当前类中的最终实现地址
2️⃣ 谁拥有 vtable?
- 每一个“含有虚函数的类”
- 都对应 一张 vtable
- 不是每个对象一张表
3️⃣ 对象里存的是什么?
对象内部存的是一个 vptr(虚函数指针)
graph LR
Object --> vptr --> vtable
vtable --> f1["virtual func1"]
vtable --> f2["virtual func2"]
三、一个最小可运行模型
class Base {
public:
virtual void f() {
std::cout << "Base::f\n";
}
};
class Derived : public Base {
public:
void f() override {
std::cout << "Derived::f\n";
}
};
Base* p = new Derived();
p->f();
四、内存层面的真实结构(关键)
1️⃣ 对象布局(简化)
graph TB
subgraph Derived对象
vptr["vptr"]
data["普通成员"]
end
2️⃣ vtable 内容
graph TB
subgraph Derived_vtable["Derived 的 vtable"]
f0["Derived::f"]
end
p->f() 的执行过程等价于:
(*(p->vptr)[0])(p);
五、vtable 如何支持“覆盖(override)”?
派生类在生成自己的 vtable 时:
如果没有 override:
- 继承基类的函数指针
如果 override:
- 用派生类函数地址 替换对应槽位
对照示意
graph LR
subgraph Base_vt["Base vtable"]
Bf["Base::f"]
end
subgraph Derived_vt["Derived vtable"]
Df["Derived::f"]
end
槽位顺序 保持一致,只是内容不同。
六、多个虚函数的情况
class Base {
public:
virtual void f1();
virtual void f2();
};
class Derived : public Base {
public:
void f2() override;
};
vtable:
graph TB
subgraph Derived_vtable
slot0["Base::f1"]
slot1["Derived::f2"]
end
七、析构函数为什么必须是虚的?
Base* p = new Derived();
delete p;
如果析构函数不是虚的:
delete调用的是Base::~BaseDerived资源泄漏
原因:
析构函数也是通过 vtable 调度的
正确写法:
virtual ~Base() = default;
八、vtable 是什么时候生成的?
编译期生成
放在:
.rodata- 或只读段
所有对象共享
vptr:
- 在构造函数中写入
- 指向对应类的 vtable
九、构造 / 析构期间的特殊行为(非常容易踩坑)
class Base {
public:
Base() { f(); }
virtual void f() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void f() override { std::cout << "Derived\n"; }
};
输出:
Base
原因:
构造期间,vptr 只指向“当前正在构造的类”的 vtable
不会发生向下多态
十、vtable ≠ 标准保证(但事实标准)
重要说明:
C++ 标准 从未规定 vtable 必须存在
但:
- 几乎所有主流编译器都采用该模型
- 已成为事实上的 ABI 标准
十一、vtable 与 vbtable 的区别(承上启下)
| 项目 | vtable | vbtable |
|---|---|---|
| 目的 | 支持多态 | 支持虚继承 |
| 是否标准可观察 | 间接是 | 否 |
| 对象中 | vptr | vbptr |
| 表内容 | 函数地址 | 偏移量 |
虚函数表是 C++ 实现运行时多态的核心机制。对象中通过虚函数指针指向类级别的虚函数表,函数调用在运行期通过该表间接完成,从而实现“基类接口、派生类行为”的多态效果。
多继承中的虚函数表(Multiple Inheritance vtable)
在单继承中,一个对象通常只有一个 vptr 和一张 vtable;
而在多继承中,这个结论会被彻底打破:
多继承会引入多个 vptr、多张 vtable,以及 this 指针调整(this-adjustment)。
这一节是理解 C++ 对象模型的“分水岭”。
一、为什么多继承会让 vtable 变复杂?
先看一个最小示例:
class A {
public:
virtual void fa();
};
class B {
public:
virtual void fb();
};
class C : public A, public B {
public:
void fa() override;
void fb() override;
};
此时:
C同时是 A 和 B- 通过
A*和B*访问C this指针的起始位置 并不相同
二、C 对象的内存布局(核心)
graph TB
subgraph C对象
A_part["A 子对象<br/>vptr_A"]
B_part["B 子对象<br/>vptr_B"]
C_data["C 自身成员"]
end
关键点:
A 子对象在偏移 0
B 子对象在偏移 ≠ 0
每个“含虚函数的基类子对象”都需要:
- 自己的 vptr
👉 所以 C 至少有两个 vptr
三、多张 vtable 是如何存在的?
每一个“基类视角”都需要一张 vtable:
graph LR
vptr_A --> vtable_A_for_C
vptr_B --> vtable_B_for_C
注意命名:
vtable_A_for_Cvtable_B_for_C
它们都是 为 C 量身定做的
四、vtable 内容不只是函数指针(重点)
在多继承下,vtable 中的函数入口通常不是“裸函数地址”,而是:
this-adjusting thunk(调整 this 的小函数)(将会在后面详细讲解thunk函数)
原因
B* pb = static_cast<C*>(obj);
pb->fb();
此时:
pb指向的是 B 子对象起始地址但
C::fb期望的this:- 是
C*或B* + 偏移
- 是
五、this 指针调整机制(非常关键)
在单继承当中,基类和派生类的 this 指针是相同的;但在多继承中,不同基类视角下的 this 指针可能不同。
当 Derived 类继承于 Base1 和 Base2 时:
- 通过
Base1*访问Derived时,this指向Derived对象的起始位置 - 通过
Base2*访问Derived时,this需要调整到Derived对象中Base2子对象的起始位置
问题就出在这里了,如果指向 Base2 子对象的 this 直接传给 Derived 的成员函数,会导致访问错误,我们需要保证 this 指向正确的位置。
此时,编译器会生成一个 this-adjusting thunk,它的职责就是:
- 接收
Base2*形式的this - 调整
this指针到Derived对象的正确位置
thunk 的职责:
this = this + offset_to_C;
call C::fb(this);
六、一个具体 vtable 示例(概念级)
A 视角 vtable(C)
vtable_A_for_C:
[0] &C::fa
B 视角 vtable(C)
vtable_B_for_C:
[0] &thunk_adjust_and_call_C_fb
七、派生类 override 如何反映到多张 vtable?
C::fa覆盖A::fa- 替换 A-vtable 中对应槽
C::fb覆盖B::fb- 替换 B-vtable 中对应槽
两张表 互不干扰
八、为什么不能“只用一张 vtable”?
原因在于:
- 不同基类视角
this指向不同子对象起点- 同一个函数需要不同的 this 修正
👉 单 vtable 无法同时满足多个入口语义
九、菱形继承 + 虚函数 = 终极复杂形态
class A {
public:
virtual void f();
};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {
public:
void f() override;
};
此时对象中可能同时存在:
- vptr(多态)
- vbptr(虚继承)
- vtable
- vbtable
- this-adjust thunk
示意:
graph TB
D --> B --> vbptr
D --> C --> vbptr
D --> vptrs["多个 vptr"]
vbptr --> vbtable
vptrs --> vtable
十、性能与代价
| 项目 | 影响 |
|---|---|
| vptr 数量 | 增加 |
| vtable 数量 | 增加 |
| 调用路径 | 多一次 thunk |
| 对象体积 | 增大 |
| ABI 复杂度 | 很高 |
但这是 语义正确性换来的代价。
十一、工程实践建议
❌ 避免“非接口型”的多继承
✅ 多继承主要用于:
- 纯接口(无数据成员)
- mixin
❌ 数据 + 多继承 + 虚函数 = 高风险
在多继承中,每个含虚函数的基类子对象都会引入自己的虚函数指针和虚函数表。为了保证通过不同基类指针调用派生类函数的语义正确,编译器会在虚函数表中引入 this 指针调整机制,使得多态调用在复杂继承结构下仍然能够正确工作。
多继承举例
#include <iostream>
class Base1 {
public:
virtual void func1() {std::cout << "Base func" << std::endl;}
virtual void func3() {std::cout << "Base func3" << std::endl;}
virtual ~Base1() {std::cout << "Base destructor" << std::endl;}
public:
int a;
int b;
int c;
static int d;
};
int Base1::d = 0;
class Base2 {
public:
virtual void func2() {std::cout << "Base func" << std::endl;}
virtual void func4() {std::cout << "Base func4" << std::endl;}
virtual ~Base2() {std::cout << "Base destructor" << std::endl;}
public:
int e;
int f;
int g;
static int h;
};
int Base2::h = 0;
class Derived : public Base1, public Base2 {
public:
void func1() override {std::cout << "Derived func1" << std::endl;}
void func2() override {std::cout << "Derived func2" << std::endl;}
void func5() {std::cout << "Derived func5" << std::endl;}
~Derived() {std::cout << "Derived destructor" << std::endl;}
public:
int i;
int j;
int k;
};
该继承结构是一个标准的非虚多继承 + 虚函数覆盖:
graph TB
Base1
Base2
Derived
Base1 --> Derived
Base2 --> Derived
特点:
- Base1 和 Base2 各自都有虚函数
- Derived 分别 override 了 Base1::func1 和 Base2::func2
- Base1 / Base2 都有虚析构
- 无虚继承(没有 vbptr)
Derived 对象的整体内存布局
在 主流 ABI(MSVC / Itanium) 下,Derived 对象布局可抽象为:
graph TB
subgraph Derived对象
subgraph Base1_part["Base1 子对象"]
vptr1["vptr(Base1视角)"]
a
b
c
end
subgraph Base2_part["Base2 子对象"]
vptr2["vptr(Base2视角)"]
e
f
g
end
subgraph Derived_part["Derived 自身成员"]
i
j
k
end
end
关键结论:
👉 Derived 对象中有两个 vptr
- 一个属于 Base1 子对象
- 一个属于 Base2 子对象
为什么必须有两张 vtable?
因为下面这两种调用是完全不同的语义入口:
Base1* p1 = new Derived();
p1->func1();
Base2* p2 = new Derived();
p2->func2();
p1指向 Derived 对象的起始地址p2指向 Derived 对象中 Base2 子对象的起始地址
👉 this 指针起点不同
👉 必须有 不同的 vtable 入口
Base1 视角的 vtable(Derived 版)
Base1 自身的虚函数声明顺序
virtual void func1();
virtual void func3();
virtual ~Base1();
Derived 对 Base1 的 override 情况
| 函数 | 是否 override |
|---|---|
| func1 | ✅ 是 |
| func3 | ❌ 否 |
| ~Base1 | ✅(通过虚析构链) |
Base1 视角下的 vtable(Derived)
graph TB
subgraph vtable_Base1_for_Derived
slot0["Derived::func1"]
slot1["Base1::func3"]
slot2["Derived::~Derived (析构 thunk)"]
slot3["Base1::~Base1"]
end
说明:
func1→ 被 Derived 覆盖func3→ 继承 Base1 实现析构函数:
通过虚表保证 delete Base1* 正确析构 Derived
实际实现通常是:
- thunk → 调整 this → 调用 ~Derived
Base2 视角的 vtable(Derived 版)
Base2 的虚函数顺序
virtual void func2();
virtual void func4();
virtual ~Base2();
Derived override 情况
| 函数 | 是否 override |
|---|---|
| func2 | ✅ 是 |
| func4 | ❌ 否 |
| ~Base2 | ✅ |
Base2 视角下的 vtable(Derived)
⚠️ 这里是多继承的关键点
graph TB
subgraph vtable_Base2_for_Derived
slot0["thunk → Derived::func2"]
slot1["Base2::func4"]
slot2["thunk → Derived::~Derived"]
slot3["Base2::~Base2"]
end
为什么 Base2 的 vtable 里有 thunk?
因为:
Base2* p = new Derived();
p->func2();
此时:
p指向的是 Base2 子对象但
Derived::func2期望的this:- 是
Derived*
- 是
二者地址 不相等
所以编译器生成:
this-adjust thunk:
this = this - offset(Base2)
call Derived::func2(this)
👉 thunk 是 vtable 中的“中转站”
Derived 自己新增的虚函数去哪了?
void func5();
⚠️ 注意:
func5 不是 virtual
所以:
- ❌ 不进入任何 vtable
- ✅ 只能通过
Derived*静态绑定调用
完整 vtable 结构总览
graph LR
vptr1["Derived.Base1::vptr"] --> vt1["vtable(Base1 for Derived)"]
vptr2["Derived.Base2::vptr"] --> vt2["vtable(Base2 for Derived)"]
vt1 --> f1["Derived::func1"]
vt1 --> f3["Base1::func3"]
vt1 --> d1["Derived::~Derived"]
vt1 --> db1["Base1::~Base1"]
vt2 --> t2["thunk → Derived::func2"]
vt2 --> f4["Base2::func4"]
vt2 --> d2["thunk → Derived::~Derived"]
vt2 --> db2["Base2::~Base2"]
析构过程验证
Base1* p = new Derived();
delete p;
执行顺序一定是:
Derived destructor
Base2 destructor
Base1 destructor
原因:
- 通过 Base1 vtable 进入 ~Derived
- 再按对象布局逆序析构
在多继承中,每个含虚函数的基类子对象都会引入独立的虚函数指针和虚函数表。派生类会为每个基类视角生成对应的 vtable,并在其中通过 this 指针调整 thunk 保证虚函数调用和析构过程的语义正确性。