• 文章
  • Wade 远离未知的 C++ 水域。第四部分
作者:
2013年7月15日(最后更新:2013年7月15日)

Wade 远离未知的 C++ 水域。第四部分。

评分:4.3/5(21票)
*****

这一次我们将讨论 C++ 中的虚继承,并找出为什么应该非常小心地使用它。查看本系列的其他文章:N1, N2, N3

虚基类的初始化

首先,让我们找出类在没有虚继承的情况下是如何在内存中分配的。看看这段代码片段

class Base { ... };
class X : public Base { ... };
class Y : public Base { ... };
class XY : public X, public Y { ... };

这很清楚:非虚基类 'Base' 的成员被分配为派生类的数据成员。这导致 'XY' 对象包含两个独立的 'Base' 子对象。下面是一个说明性的图

图 1. 多个非虚继承。

当我们处理虚继承时,虚基类的一个对象在派生类对象中只包含一次。图 2 显示了下面代码片段中 'XY' 对象的结构。

class Base { ... };
class X : public virtual Base { ... };
class Y : public virtual Base { ... };
class XY : public X, public Y { ... };

图 2. 多个虚继承。

内存中最有可能为共享子对象 'Base' 分配空间是在 'XY' 对象的末尾。类的确切实现取决于编译器。例如,'X' 和 'Y' 类可能存储指向共享对象 'Base' 的指针。但据我所知,这种做法现在已经过时了。对共享子对象的引用通常通过偏移量或存储在虚函数表中的信息来实现。

“最派生”的类 'XY' 自己知道虚基类 'Base' 的子对象确切地分配在哪里。这就是为什么最派生类负责初始化所有虚基类的子对象。

'XY' 的构造函数初始化 'Base' 子对象以及 'X' 和 'Y' 中指向它的指针。之后,'X'、'Y' 和 'XY' 类的所有其他成员都被初始化。

一旦 'XY' 构造函数初始化了 'Base' 子对象,'X' 和 'Y' 的构造函数就不允许重新初始化它。具体的实现方式取决于编译器。例如,它可以向 'X' 和 'Y' 的构造函数传递一个特殊的附加参数,告诉它们不要初始化 'Base' 类。

现在是最有趣的事情,它引起了很多困惑和许多错误。看看下面的构造函数

X::X(int A) : Base(A) {}
Y::Y(int A) : Base(A) {}
XY::XY() : X(3), Y(6) {}

基类构造函数将接收什么数字作为参数——3 还是 6?都不是!

构造函数 'XY' 初始化了虚子对象 'Base',但它是隐式完成的。默认情况下调用的是 'Base' 构造函数。

由于 'XY' 构造函数调用了 'X' 或 'Y' 构造函数,它不会重新初始化 'Base'。这就是为什么 'Base' 没有接收传入参数而被调用的原因。

虚基类的问题不止于此。除了构造函数,还有赋值运算符。如果我没记错的话,标准告诉我们,编译器生成的赋值运算符可能会将值赋给虚基类的子对象多次或一次。所以,你不知道 'Base' 对象会被复制多少次。

如果你自己实现赋值运算符,请确保你已经阻止了 'Base' 对象被多次复制。下面的代码片段是不正确的

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    X::operator =(*this);
    Y::operator =(*this);
    ....
  }
  return *this;
}

此代码会导致 'Base' 对象被复制两次。为了避免这种情况,我们应该在 'X' 和 'Y' 类中添加特殊函数来防止复制 'Base' 类的成员。'Base' 类的内容只复制一次,就在同一个代码片段中。这是修复后的代码

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    Base::operator =(*this);
    X::PartialAssign(*this);
    Y::PartialAssign(*this);
    ....
  }
  return *this;
}

这段代码可以正常工作,但看起来仍然不够简洁明了。这就是为什么建议程序员避免多重虚继承的原因。

虚基类与类型转换

由于虚基类在内存中分配的特殊性,你无法进行如下类型转换

Base *b = Get();
XY *q = static_cast<XY *>(b); // Compilation error
XY *w = (XY *)(b); // Compilation error

然而,坚定的程序员会通过使用 'reinterpret_cast' 运算符来实现这一点

XY *e = reinterpret_cast<XY *>(b);

但是,结果几乎无用。它将把 'Base' 对象开头的地址解释为 'XY' 对象开头的地址,这是完全不同的。有关详细信息,请参见图 3。

进行类型转换的唯一方法是使用 dynamic_cast 运算符。但是过度使用 dynamic_cast 会让代码变得糟糕。

图 3. 类型转换。

我们是否应该放弃虚继承?

我同意许多作者的观点,即应尽一切可能避免虚继承以及普通的多重继承。

虚继承会导致对象初始化和复制出现问题。由于“最派生”的类负责这些操作,它必须了解基类结构的全部细节。因此,类之间出现了更复杂的依赖关系,这会使项目结构复杂化,并迫使你在重构过程中对所有这些类进行一些额外的修改。所有这些都会导致新的 bug,并使代码可读性降低。

类型转换问题也可能成为 bug 的来源。你可以通过使用 dynamic_cast 运算符来部分解决这些问题。但是它太慢了,如果你的代码中不得不频繁使用它,那就意味着你的项目架构可能非常糟糕。项目结构几乎总是可以在没有多重继承的情况下实现的。毕竟,在许多其他语言中不存在这样的奇技淫巧,而且它并不能阻止这些语言的程序员编写大型复杂项目。

我们不能坚持完全拒绝虚继承:它有时可能是有用和方便的。但总是三思而后行,然后再堆砌复杂的类。培育一片小类组成的森林,具有浅层继承,比处理几棵巨大的树要好。例如,多重继承在大多数情况下可以用对象组合来代替。

多重继承的好处

好的,我们现在理解并同意对多重虚继承和多重继承本身的批评。但是,有没有安全且方便使用的场景?

是的,我至少可以举出一个例子:Mix-ins。如果你不知道它是什么,请参阅书籍《Enough Rope to Shoot Yourself in the Foot》[3]

Mix-in 类不包含任何数据。它所有的函数通常都是纯虚函数。它没有构造函数,即使有,它也不会做任何事情。这意味着在创建或复制这些类时不会出现任何问题。

如果基类是 mix-in 类,赋值是无害的。即使一个对象被复制了很多次,也没关系:程序在编译后就会摆脱它。

参考文献

  1. Stephen C. Dewhurst. "C++ Gotchas: Avoiding Common Problems in Coding and Design". - Addison-Wesley Professional. - 352 pages; illustrations. ISBN-13: 978-0321125187. (参见 Gotchas 45 和 53)。
  2. Wikipedia. 对象组合
  3. Allen I. Holub. "Enough Rope to Shoot Yourself in the Foot". (你可以在网上轻松找到它。从第 101 节开始阅读)。