第18.9章:虚继承与菱形
1. 虚继承:菱形
基类列表里的 virtual 和函数上的 virtual 是两个不同的特性。它解决另一个问题:
多重继承可能把同一个公共基类的一份以上副本放进同一个对象。
struct base
{
int id{};
virtual ~base() = default;
};
struct left : base
{
int l{};
};
struct right : base
{
int r{};
};
struct bottom : left, right
{
int b{};
};
现在一个 bottom 对象包含两个 base 子对象:一个来自
left,一个来自 right。名字 id 有歧义,转换成
base& 也有歧义:
bottom x;
// x.id = 1; // error: ambiguous, which base?
// base &br = x; // error: ambiguous base
left &l = x;
right &r = x;
base &bl = l; // the base inside left
base &br = r; // the base inside right
两个基类也有各自的状态。如果 base 里有一个 id,通过
bl 修改它不会通过 br 看到。对于真正的“是一个”菱形——一个共享的基类
子对象——把对 base 的继承声明为虚继承:
struct left : virtual base
{
int l{};
};
struct right : virtual base
{
int r{};
};
struct bottom : left, right
{
int b{};
};
现在 bottom 恰好有一个 base 子对象,由 left 和
right 两个分支共享。名字不再有歧义:
bottom x;
x.id = 1; // OK: one shared base
base &br = x; // OK: one shared base
left &l = x;
base &bl = l; // still refers to the same shared base
2. 布局与构造
代价在布局。虚基类不能在每个完整对象里都放在固定偏移,因为通向它的路径取决于最派生类型。实现通常给类
加一个指针或偏移表(virtual base pointer,或 vbptr),访问虚基类成员变成间接访问。
标准不规定布局;sizeof、偏移和隐藏指针的数量都是 ABI 细节。不要在这些东西上构建线格式。
另一个锋利边缘是构造顺序。虚基类先于非虚基类构造,最派生类负责初始化虚基类。当中间类不是最派生对象时, 它对虚基类的初始化器会被忽略:
struct base
{
base() { std::print("base\n"); }
};
struct left : virtual base
{
left() : base{} { std::print("left\n"); }
};
struct right : virtual base
{
right() : base{} { std::print("right\n"); }
};
struct bottom : left, right
{
bottom() { std::print("bottom\n"); }
};
bottom x; // base, left, right, bottom
析构按相反顺序:bottom、right、left、base。
如果你写了一个假设自己控制虚基类的构造函数,要分别把它当独立对象和更派生对象的基类来测试。同一个构造函数
在这两种情况下可能看到不同的基类初始化。
3. 交叉转换与替代方案
兄弟分支之间的交叉转换需要 RTTI。从 left* 到 right* 的
static_cast 不允许,因为编译器在没有完整对象时不知道偏移。dynamic_cast
可以找到共享基类或兄弟子对象,但它要求多态类型和一次运行时搜索:
bottom x;
left *lp = &x;
base *bp = dynamic_cast<base *>(lp); // shared virtual base
right *rp = dynamic_cast<right *>(lp); // sibling subobject, or nullptr
这正是本章其余部分反对的代码:用运行时转换恢复一个本可以用组合表达的关系。如果你需要共享接口,优先用 没有数据的纯接口,或者组合加引用。如果你需要共享状态,用一个指针或引用成员显式表达共享,不要用虚基类。
虚继承不是通用的代码复用工具。它只适用于菱形确实是领域模型的一部分、并且一个共享基类子对象在语义上正确的 狭窄场景。它在布局、构造顺序和转换上都很昂贵,而且很容易写错。在新代码里,组合几乎总是更清晰。
这就是指南里 使用类型擦除替代虚函数实现多态 的推论:如果具体类型从不逃逸出去,就没有东西可转换,RTTI 的代价也随转换一起消失。
4. 这个特性为什么存在
虚基类是 C++ 2.0 的关键创新之一,而那个时代的 iostream 重设计是它的第一次大规模使用。这就是人们
总把虚继承和 iostream 联系起来的原因。Jerry Schwarz 用多重继承重写流库时,目标是让一个共享的
basic_ios 持有状态和缓冲区,让 basic_istream 增加读操作、
basic_ostream 增加写操作、basic_iostream 同时提供两者。如果没有虚继承,
basic_iostream 会包含两个 basic_ios 子对象:两套标志、两个缓冲区、两个错误状态。
虚继承把菱形折叠成一个基类。
这是一个真实的建模问题,但为了在一个库里解决它,语言被加进了一个最复杂的对象模型特性。每个 C++ 程序都要 为它付出代价:虚基类偏移、最派生类构造、被忽略的初始化器、交叉转换和 RTTI。大多数程序从不需要菱形。 iostream 需要,是因为在那个年代,深层类层次是库唯一会用的工具。
fast_io 走的是另一条路。设备是模板参数,不是基类。print、scan 和
transmit 是接收设备引用的函数,读写类型持有一个设备的引用。没有可复制的共享基类,
所以没有菱形、没有虚基类、也没有 RTTI。共享是一个在类型里可见的成员,而不是对象布局里的隐藏调整。
这也是指南里 优先使用自由函数而不是成员函数 和 考虑使用类型擦除而不是面向对象编程(OOP) 在 I/O 上的应用:操作是设备上的函数,不是层次里的方法。
安全代价来自同一份复杂性。类型混淆、破坏 vtable 或虚基类指针、对已经损坏的对象做
dynamic_cast,都是常见的利用手法。带隐藏偏移和运行时指针调整的布局,给攻击者更多可破坏的状态,
也带来更多“偏移错了就变成另一个对象”的位置。这不是避免虚继承的主要原因,但它是简单布局胜出的又一个理由。
教训不是 iostream 的设计者不小心。他们是在用当时的工具解决一个真实的重复问题,那个年代把运行时多态和深层 继承当成良好设计的默认形态。教训是:库内部的建模问题不该逼出语言级机制;模板加组合可以用少得多的机械 表达同样的共享。
5. 指南引用
本页适用的规则,来自
Portable-Cpp-Guideline(链接指向英文版对应小节):
- 考虑使用类型擦除而不是面向对象编程(OOP)。 菱形之所以存在,只是因为设计被表达成类层次。去掉层次,菱形就消失了。
- 使用类型擦除替代虚函数实现多态。 共享接口不需要共享的基类子对象,它只需要共享的调用签名。
- 优先使用 C 风格伪 vtable 单例,而不是 C++ vtable。 虚继承给分派机制加上了虚基类偏移和 RTTI。两者都是 ABI 相关的;当表是你自己的时候,两者都不需要。
-
如果必须使用虚函数,始终标注
virtual、override或final。 虚继承不是标注规则的豁免 (第18.3章)。 -
优先使用自由函数而不是成员函数。
print、scan和transmit把设备作为参数;它们不需要类层次来实现多态。 - 避免私有字段和方法。 共享基类子对象把状态藏在继承路径后面;显式成员可以被检查和测试。
-
避免
new/unique_ptr。 iostream 风格的层次是让两者看起来必要的历史路径。菱形是同一个错误在类型系统里的表达。