第18.9章:虚继承与菱形

English

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(链接指向英文版对应小节):