恰当的继承

继承 — 正确的继承和可替代性

我应该隐藏基类中的公共成员函数吗?

永远不要,永远不要,永远不要这样做。永远不要。永远不要!

试图隐藏(消除、撤销、私有化)继承的 public 成员函数是一个太过常见的设计错误。它通常源于思维混乱。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

Derived*Base* 转换没问题;为什么 Derived**Base** 不行?

因为将 Derived**Base** 转换是无效且危险的。

C++ 允许将 Derived*Base* 转换,因为 Derived 对象是一种 Base 对象。然而,尝试将 Derived**Base** 转换会被标记为错误。尽管这个错误可能不明显,但它仍然是件好事。例如,如果你可以将 Car**Vehicle** 转换,并且你也可以类似地将 NuclearSubmarine**Vehicle** 转换,你就可以将这两个指针赋值,最终导致一个 Car* 指向一个 NuclearSubmarine

class Vehicle {
public:
  virtual ~Vehicle() { }
  virtual void startEngine() = 0;
};

class Car : public Vehicle {
public:
  virtual void startEngine();
  virtual void openGasCap();
};

class NuclearSubmarine : public Vehicle {
public:
  virtual void startEngine();
  virtual void fireNuclearMissile();
};

int main()
{
  Car   car;
  Car*  carPtr = &car;
  Car** carPtrPtr = &carPtr;
  Vehicle** vehiclePtrPtr = carPtrPtr;  // This is an error in C++
  NuclearSubmarine  sub;
  NuclearSubmarine* subPtr = ⊂
  *vehiclePtrPtr = subPtr;
  // This last line would have caused carPtr to point to sub !
  carPtr->openGasCap();  // This might call fireNuclearMissile()!
  // ...
}

换句话说,如果允许将 Derived**Base** 转换,那么 Base** 可以被解引用(产生一个 Base*),并且 Base* 可以指向一个不同的派生类对象,这可能会导致国家安全方面的严重问题(谁知道如果你对一个你认为是 Car 的对象调用 openGasCap() 成员函数,但实际上它是一个 NuclearSubmarine,会发生什么!!)。尝试上面的代码,看看它会做什么——在大多数编译器上它会调用 NuclearSubmarine::fireNuclearMissile()

(顺便说一句,你需要使用指针强制类型转换才能编译。建议:尝试不使用指针强制类型转换来编译,看看编译器会做什么。当错误消息出现在屏幕上时,如果你真的保持安静,你应该能听到你的编译器低声恳求你:“请不要使用指针强制类型转换!指针强制类型转换会阻止我告诉你代码中的错误,但它们不会让你的错误消失!指针强制类型转换是邪恶的!”至少我的编译器是这么说的。)

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

(注意:这与禁止将 Foo** 转换为 const Foo** 之间存在概念上的相似性。)

一个 Car 的停车场是一种 Vehicle 的停车场吗?

不。

我知道这听起来很奇怪,但这是真的。你可以将其视为上一个 FAQ 的直接结果,或者你可以这样推断:如果这种“是一种”关系有效,那么有人可以将一个 Vehicle 停车场指针指向一个 Car 停车场,这将允许有人将任何类型的 Vehicle 添加到 Car 停车场(假设 Vehicle 停车场有一个类似 add(Vehicle&) 的成员函数)。换句话说,你可以在一个 Car 停车场停放一辆 Bicycle、一艘 SpaceShuttle,甚至一艘 NuclearSubmarine。如果有人从 Car 停车场取出一个他们认为是 Car 的东西,却发现它实际上是 NuclearSubmarine,那肯定会让人吃惊。天哪,openGasCap() 方法会做什么呢??

也许这个会有帮助:一个 Thing 的容器不是一个 Anything 的容器,即使 Thing 是一种 Anything。硬着头皮接受吧;这是真的。

你不必喜欢它。但你必须接受它。

我们在 OO/C++ 培训课程中使用的最后一个例子:“一个 Apple 袋子不是一种 Fruit 袋子。”如果一个 Apple 袋子可以作为一个 Fruit 袋子传递,那么有人可以将一个 Banana 放入袋子中,即使它本来只应该包含 Apple

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

一个 Derived 数组是一种 Base 数组吗?

不。

这是前一个 FAQ 的推论。不幸的是,这个可能会让你陷入很多麻烦。考虑一下这个:

class Base {
public:
  virtual void f();             // 1
};

class Derived : public Base {
public:
  // ...
private:
  int i_;                       // 2
};

void userCode(Base* arrayOfBase)
{
  arrayOfBase[1].f();           // 3
}

int main()
{
  Derived arrayOfDerived[10];   // 4
  userCode(arrayOfDerived);     // 5
  // ...
}

编译器认为这完全是类型安全的。第 5 行将 Derived* 转换为 Base*。但实际上它是极其邪恶的:由于 DerivedBase 大,第 3 行进行的指针算术是错误的:编译器在计算 arrayOfBase[1] 的地址时使用 sizeof(Base),而数组是一个 Derived 数组,这意味着第 3 行计算的地址(以及随后的 f() 成员函数调用)甚至不在任何对象的开头!它正好在一个 Derived 对象的中间。假设你的编译器使用通常的 virtual 函数方法,这将把第一个 Derivedint i_ 重新解释为指向虚表,它将跟随那个“指针”(此时意味着我们正在从一个随机内存位置挖出东西),并抓取该位置前几个内存字中的一个,并将其解释为 C++ 成员函数的地址,然后将该(随机内存位置)加载到指令指针中,并开始从该内存位置抓取机器指令。这崩溃的可能性非常高。

根本问题在于 C++ 无法区分“指向某个东西的指针”和“指向某个东西数组的指针”。C++ 自然地从 C 继承了这个特性。

注意:如果我们使用数组类(例如,来自标准库std::array<Derived, 10>)而不是使用原始数组,那么这个问题就会在编译时被正确捕获为错误,而不是运行时灾难。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

数组-of-Derived 是一种数组-of-Base,这意味着数组不好吗?

是的,数组是邪恶的。(只是一半开玩笑)。

说真的,数组与指针密切相关,而指针是出了名的难以处理。但是,如果你完全掌握了为什么上述几个 FAQ 从设计角度来看是个问题(例如,如果你真的知道为什么 Thing 的容器不是 Anything 的容器),并且如果你认为所有其他将维护你的代码的人也完全掌握了这些 OO 设计真理,那么你就可以随意使用数组。但如果你像大多数人一样,你应该使用一个模板容器类,例如来自标准库std::array<T, N>,而不是固定大小的原始数组。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

一个 Circle 是一种 Ellipse 吗?

视情况而定。但如果 Ellipse 保证可以非对称地改变其大小,则不是。

例如,如果 Ellipse 有一个 setSize(x,y) 成员函数,承诺对象的 width() 将为 x 且其 height() 将为 y,那么 Circle 就不能是 Ellipse 的一种。简单地说,如果 Ellipse 能做 Circle 不能做的事,那么 Circle 就不能是 Ellipse 的一种。

这在 CircleEllipse 之间留下两种有效关系:

  • CircleEllipse 设为完全不相关的类。
  • 从一个表示“不一定能执行不等 setSize() 操作的椭圆”的基类派生 CircleEllipse

在第一种情况下,Ellipse 可以从 class AsymmetricShape 派生,并且可以在 AsymmetricShape 中引入 setSize(x,y)。然而,Circle 可以从 SymmetricShape 派生,该类具有 setSize(size) 成员函数。

在第二种情况下,class Oval 只能有 setSize(size),它将 width()height() 都设置为 sizeEllipseCircle 都可以继承自 OvalEllipse ——但不是 Circle——可以添加 setSize(x,y) 操作(但如果两个操作都使用相同的成员函数名 setSize(),请注意隐藏规则)。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

(注意:setSize(x,y) 并非神圣不可侵犯。根据你的目标,阻止用户更改 Ellipse 的尺寸可能是可以的,在这种情况下,在 Ellipse 中不设置 setSize(x,y) 方法是一个有效的设计选择。然而,这一系列常见问题讨论的是当你想要创建一个预先存在的基类的派生类,而该基类中有一个“不可接受”的方法时该怎么做。当然,理想的情况是在基类尚不存在时发现这个问题。但生活并非总是理想的……)

除了“Circle 是/不是 Ellipse 的一种”的困境,还有其他选择吗?

如果你声称所有 Ellipse 都可以非对称地挤压,并且你声称 Circle 是一种 Ellipse,并且你声称 Circle 不能非对称地挤压,那么你显然必须撤回你的一个主张。你可以取消 Ellipse::setSize(x,y),取消 CircleEllipse 之间的继承关系,或者承认你的 Circle 不一定是圆形的。你也可以完全取消 Circle,在这种情况下,圆性只是 Ellipse 对象的一种临时状态,而不是对象的永久特性。

这是新 OO/C++ 程序员经常陷入的两个最常见的陷阱。他们试图使用编码技巧来掩盖一个破碎的设计,例如,他们可能会重新定义 Circle::setSize(x,y) 以抛出异常、调用 abort()、选择两个参数的平均值,或者什么也不做。不幸的是,所有这些技巧都会让用户感到惊讶,因为用户期望 width() == x 并且 height() == y。你绝对不能做的一件事就是让你的用户感到惊讶。

如果保留“Circle 是一种 Ellipse”的继承关系对你很重要,你可以弱化 EllipsesetSize(x,y) 所做的承诺。例如,你可以将承诺改为:“此成员函数可能width() 设置为 x 和/或可能height() 设置为 y,或者它什么也不做”。不幸的是,这会将契约稀释成无用的废话,因为用户无法依赖任何有意义的行为。因此,整个层次结构开始变得毫无价值(如果你被问及对象有什么用,而你只能耸耸肩,那么很难说服别人使用这个对象)。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

(注意:setSize(x,y) 并非神圣不可侵犯。根据你的目标,阻止用户更改 Ellipse 的尺寸可能是可以的,在这种情况下,在 Ellipse 中不设置 setSize(x,y) 方法是一个有效的设计选择。然而,这一系列常见问题讨论的是当你想要创建一个预先存在的基类的派生类,而该基类中有一个“不可接受”的方法时该怎么做。当然,理想的情况是在基类尚不存在时发现这个问题。但生活并非总是理想的……)

但我拥有数学博士学位,我确信圆是一种椭圆!这是否意味着马歇尔·克莱恩很笨?还是 C++ 很笨?还是 OO 很笨?

实际上,它不意味着这些。但我会告诉你它意味着什么——你可能不喜欢我接下来要说的话:这意味着你对“是一种”的直觉导致你做出了糟糕的继承决策。你的直觉在欺骗你关于好的继承的真正含义——停止相信那些谎言。

听着,我收到了并回答了几十封关于这个主题的热情电子邮件。我已经在各地向成千上万的软件专业人员教授过数百次。我知道这与你的直觉相悖。但相信我;你的直觉是错误的,“错误”意味着“将导致你在 OO 设计/编程中做出糟糕的继承决策。”

以下是如何在 OO 设计/编程中做出好的继承决策:认识到派生类对象必须能够替代基类对象。这意味着派生类对象必须以符合基类契约中承诺的方式行为。一旦你相信这一点(我完全承认你可能还没有相信,但如果你以开放的心态努力,你就会相信),你会发现 setSize(x,y) 违反了这种可替代性。

解决这个问题有三种方法:

  1. 软化基类 EllipsesetSize(x,y) 所做的承诺,或者完全移除该方法,但这可能导致调用 setSize(x,y) 的现有代码中断。
  2. 加强派生类 CirclesetSize(x,y) 所做的承诺,这实际上意味着允许 Circle 具有与宽度不同的高度——一个非对称的圆;嗯。
  3. 放弃继承关系,可能完全取消 Circle 类(在这种情况下,圆性将只是 Ellipse 的一种临时状态,而不是对对象的永久约束)。

抱歉,但除了这三种选择,别无他法。

你必须让基类变得更弱(弱化 Ellipse 到它不再保证你可以将其宽度和高度设置为不同值的程度),让派生类变得更强(赋予 Circle 既能对称又能,嗯,不对称的能力),或者承认 Circle 不能替代 Ellipse

重要提示:除了以上三种选择,真的没有其他选择了。特别是:

  1. 请不要写信告诉我第四种选择是从第三个共同基类派生 CircleEllipse。那不是第四种解决方案。那只是解决方案 #3 的重新包装:它之所以有效,正是因为它移除了 CircleEllipse 之间的继承关系。
  2. 请不要写信告诉我第四个选项是阻止用户更改“椭圆”的尺寸。那不是第四个解决方案。那只是解决方案 #1 的重新包装:它之所以有效,正是因为它取消了 setSize(x,y) 确实设置宽度和高度的保证。
  3. 请不要写信告诉我你决定这三种方案中的一种是“最好的”方案。这样做会表明你错过了这个 FAQ 的重点,具体来说,糟糕的继承是微妙的,但幸运的是,你有三种(不是一种;不是两种;而是三种)可能的方法来摆脱困境。所以,当你遇到糟糕的继承时,请尝试所有这三种技术,并选择最好的,也许是“最不坏的”那一种。不要提前抛弃其中的两种工具:尝试所有。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

(注意:有些人正确地指出,一个常量 Circle 可以替代一个常量 Ellipse。这是事实,但这并不是第四个选项:它实际上只是选项 #1 的一个特例,因为它之所以有效,正是因为一个常量 Ellipse 没有 setSize(x,y) 方法。)

也许 Ellipse 应该继承自 Circle

如果 Circle 是基类而 Ellipse 是派生类,那么你就会遇到一系列全新的问题。例如,假设 Circle 有一个 radius() 方法。那么 Ellipse 也需要有一个 radius() 方法,但这没什么意义:一个可能不对称的椭圆拥有半径是什么意思?

如果你克服了这个障碍,例如通过让 Ellipse::radius() 返回主轴和次轴的平均值或其他什么,那么 radius()area() 之间就会出现问题。假设 Circle 有一个 area() 方法,承诺返回 pi 乘以 radius() 返回值的平方。那么 Ellipse::area() 要么不会返回椭圆的真实面积,要么你将不得不倒立着让 radius() 返回符合上述公式的值。

即使你通过了那一个,例如让 Ellipse::radius() 返回椭圆面积除以圆周率的平方根,你也会被 circumference() 方法卡住。假设 Circle 有一个 circumference() 方法,承诺返回两倍圆周率乘以 radius() 返回的值。现在你被卡住了:没有办法让所有这些约束对 Ellipse 都有效:Ellipse 类将不得不谎报其面积、周长或两者。

归根结底:你可以让任何东西继承自任何东西,前提是派生类中的方法遵守基类中做出的承诺。但是你不应该仅仅因为你喜欢它,或者仅仅因为你想要代码重用而使用继承。你应该使用继承 (a) 只有当派生类的方法能够遵守基类中做出的所有承诺时,以及 (b) 只有当你认为你不会混淆你的用户时,以及 (c) 只有当使用继承可以获得一些东西时——一些真实、可衡量的时间、金钱或风险的改进。

但我的问题与圆形和椭圆形无关,所以那个愚蠢的例子对我有什么用?

啊哈,问题就在这里。你认为圆形/椭圆形例子只是一个愚蠢的例子。但实际上,你的问题是该例子的同构体。

我不在乎你的继承问题是什么,但所有——是的,所有——糟糕的继承都归结为“圆形不是一种椭圆形”的例子。

原因如下:糟糕的继承总是有一个基类,它拥有派生类无法满足的额外能力(通常是一个或两个额外的成员函数;有时是一个或多个成员函数组合所做的额外承诺)。你必须要么削弱基类,要么增强派生类,要么消除提议的继承关系。我见过大量这些糟糕的继承提议,相信我,它们都归结为圆形/椭圆形这个例子。

因此,如果你真正理解了圆形/椭圆形这个例子,你就能识别出所有糟糕的继承。如果你不理解圆形/椭圆形问题,那么你很可能会犯一些非常严重且代价高昂的继承错误。

悲哀但真实。

(注意:此 FAQ 与 public 继承有关;privateprotected 继承是不同的。)

怎么会“视情况而定”呢?“圆形”和“椭圆形”这样的术语不是数学上定义的吗?

这些术语是否在数学上定义是无关紧要的。正是这种无关性才导致了“视情况而定”。

任何理性讨论的第一步都是定义术语。在这种情况下,第一步是定义术语 CircleEllipse。信不信由你,关于类 Circle 是否应该继承自类 Ellipse 的大多数激烈争论都是由这些术语的不兼容定义引起的。

关键的见解是忘掉数学和“现实世界”,而是接受唯一与回答问题相关的定义:类本身。以 Ellipse 为例。你创建了一个以此名称命名的类,因此你对该术语的唯一最终仲裁者就是你的类。那些试图将“现实世界”混入讨论的人会彻底困惑,并且经常陷入激烈的(而且,可悲的是,毫无意义的)争论。

由于很多人就是不明白,这里举个例子。假设你的程序写着 class Foo : public Bar { ... }。这定义了你所说的 Foo 的含义:Foo 的唯一、最终、明确、精确的定义是通过将 Foo 的公共部分与其基类 Bar 的公共部分联合起来给出的。现在假设你决定将 Bar 重命名为 Ellipse,将 Foo 重命名为 Circle。这意味着你(是的,就是你;不是“数学”;不是“历史”;不是“先例”,也不是欧几里得、欧拉或任何其他著名数学家;就是你这个小小的你)已经定义了你的程序中术语 Circle 的含义。如果你以一种不符合人们对圆形直观概念的方式定义它,那么你可能应该为你的类选择一个更好的标签,但尽管如此,你的定义仍然是你的程序中术语 Circle 的唯一、最终、明确、精确的定义。如果程序之外的其他人以不同的方式定义了相同的术语,那么即使“其他人”是欧几里得,该其他定义与关于你的程序的问题也无关。在你的程序中,你定义术语,而术语 Circle 则由你命名的类 Circle 定义。

简单来说,当我们提出关于你的程序中定义的词语的问题时,我们必须使用你的这些术语的定义,而不是欧几里得的。这就是为什么问题的最终答案是“视情况而定”。之所以视情况而定,是因为你的程序中被称为 Circle 的东西是否能够恰当地替代你的程序中被称为 Ellipse 的东西,取决于你的程序究竟是如何定义这些术语的。当试图回答关于你的程序中类的问题时,使用欧几里得的定义是荒谬且具有误导性的;我们必须使用你的定义。

当有人为此感到激动时,我总是建议将标签更改为没有预设含义的术语,例如 FooBar。由于这些术语不会引发任何数学关系,人们自然会去看类定义,以准确了解程序员的想法。但一旦我们将类从 Foo 重命名为 Circle,有些人就会突然认为他们可以控制该术语的含义;他们是错的,也很可笑。该术语的定义仍然完全由类本身决定,而不是任何外部实体。

下一个见解:继承意味着“可以替代”。它意味着“是一个”(因为这定义不清),它也意味着“是一种”(同样定义不清)。可替代性是明确定义的:为了可替代,派生类被允许(非必需)添加(非移除)公共方法,并且对于从基类继承的每个公共方法,派生类被允许(非必需)弱化前置条件和/或强化后置条件(反之则不行)。此外,派生类可以拥有完全不同的构造函数、静态方法和非公共方法。

回到 EllipseCircle如果你将术语 Ellipse 定义为可以不对称调整大小的东西(例如,它的方法让你能够独立改变宽度和高度,并且保证宽度和高度确实会改变到指定值),那么这就是术语 Ellipse 的最终精确定义。如果你将名为 Circle 的东西定义为不能不对称调整大小的东西,那么这也是你的特权,并且它是术语 Circle 的最终精确定义。如果你以这种方式定义了这些术语,那么显然你称之为 Circle 的东西不能替代你称之为 Ellipse 的东西,因此继承将是不恰当的。证毕。

所以答案总是“视情况而定”。特别是,它取决于基类和派生类的行为。它不取决于基类和派生类的名称,因为那些是任意标签。(我并不是提倡随意命名;但是,我是在说你绝不能使用你对名称的直观联想来假设你知道一个类做什么。一个类做什么就是做什么,而不是你根据它的名称认为它应该做什么。)

有人(一些人)感到困扰,认为你称之为 Circle 的东西可能无法替代你称之为 Ellipse 的东西,对于这些人我只有两点要说:(a)接受它,和(b)如果这让你感觉更舒服,请更改类的标签。例如,将 Ellipse 重命名为 ThingThatCanBeResizedAssymetrically,将 Circle 重命名为 ThingThatCannotBeResizedAssymetrically

不幸的是,我真诚地相信,那些重命名后感觉更好的人并没有抓住要点。要点是:在 OO 中,事物是由它的行为定义的,而不是由用来命名它的标签定义的。显然,选择好的名字很重要,但即便如此,所选择的名字并不能定义事物。事物的定义是由公共方法指定的,包括这些方法的契约(前置条件和后置条件)。继承的恰当与否是基于类的行为,而不是它们的名称。

如果 SortedList 的公共接口与 List 完全相同,那么 SortedList 是一种 List 吗?

可能不行。

最重要的见解是,答案取决于基类契约的细节。仅仅知道公共接口/方法签名兼容是不够的;还需要知道契约/行为是否兼容。

前一句中重要的词是“契约/行为”。这个短语远远超出了公共接口 = 方法签名 = 方法名称和参数类型以及 const 性。一个方法的契约意味着它宣传的行为 = 宣传的需求和承诺 = 宣传的前置条件和后置条件。因此,如果基类有一个方法 void insert(const Foo& x),该方法的契约包括签名(意味着名称 insert 和参数 const Foo&),但远远超出了这些,还包括该方法宣传的前置条件和后置条件。

另一个重要的词是宣传的。这里的目的是区分方法内部的代码(假设基类的方法代码;即,假设它不是未实现的纯虚函数)和方法外部做出的承诺。这就是事情变得棘手的地方。假设 List::insert(const Foo& x)this List 的末尾插入 x 的一个副本,而 SortedList 中该方法的覆盖以正确的排序顺序插入 x。即使覆盖的行为与基类的代码不兼容,如果基类做出了“弱”或“可适应”的承诺,那么继承可能仍然是恰当的。例如,如果 List::insert(const Foo& x) 的宣传承诺是模糊的,例如“承诺 x 的一个副本将插入到 this List 的某个位置”,那么继承可能是可以的,因为覆盖遵守了宣传的行为,即使它与实现的行为不兼容。

派生类必须执行基类所承诺的,而不是它实际执行的。

关键在于我们将宣传的行为(“规范”)与实现的行为(“实现”)分离开来,并且我们依赖于规范而不是实现。这非常重要,因为在很大一部分情况下,基类的方法是一个未实现的纯虚函数——唯一可以依赖的是规范——根本没有可以依赖的实现。

回到 SortedListList:看起来 List 很可能有一个或多个方法具有保证顺序的契约,因此 SortedList 很可能不是 List 的一种。例如,如果 List 有一个方法允许你重新排序、预置、追加或更改第 i 个元素,并且如果这些方法做出了典型的宣传承诺,那么 SortedList 将需要违反该宣传行为,并且继承将是不恰当的。但这完全取决于基类宣传了什么——取决于基类的契约。