第 21 章 类层级结构(Class Hierarchies)
Abstraction is selective ignorance.
Andrew Koenig
目录
21.2 类层级结构的设计(Design of Class Hierarchies)
21.2.1 实现继承(Implementation Inheritance)
21.2.2 接口继承(Interface Inheritance)
21.2.3 替代实现(Alternative Implementations)
21.2.4 局部对象创建(Localizing Object Creation)
21.3 多继承(Multiple Inheritance)
21.3.1 多接口(Multiple Interfaces)
21.3.2 多实现类(Multiple Implementation Classes)
21.3.3 歧义消除(Ambiguity Resolution)
21.3.4 基类复用(Repeated Use of a Base Class)
21.3.5 虚基类(Virtual Base Classes)
21.3.5.1 构建虚基类(Constructing Virtual Bases)
21.3.5.2 仅调用一次虚类成员(Calling a Virtual Class Member Once Only)
21.3.6 复制对比虚基类(Replicated vs. Virtual Bases)
21.3.6.1 构建虚基类(Constructing Virtual Bases)
21.1 引言(Introduction)
本章主要关注设计技术,而不是语言特性。这些示例取自用户界面设计,但我避免讨论事件驱动编程的主题,因为该主题通常用于图形用户界面 (GUI) 系统。讨论屏幕上的操作如何转换为成员函数的调用对类层级结构的设计问题几乎没有帮助,而且很有可能分散注意力:它本身就是一个有趣且重要的主题。要了解 GUI,请查看众多 C++ GUI 库之一。
21.2 类层级结构的设计(Design of Class Hierarchies)
考虑一个简单的设计问题:为程序(“应用程序”)提供一种从用户那里获取整数值的方法。这可以通过多种方式实现。为了使我们的程序免受这种多样性的影响,同时也有机会探索可能的设计选择,让我们首先定义这个简单输入操作的程序模型。
这个思想是拥有一个 Ival_box类(“整数值输入框”),它知道它将接受什么范围的输入值。程序可以向 Ival_box 询问其值,并要求它在必要时提示用户。此外,程序可以询问 Ival_box,自程序上次查看该值以来,用户是否更改了该值:
因为有很多种方法可以实现这个基本思想,所以我们必须假设会有许多不同种类的 Ival_box,例如滑块、用户可以输入数字的普通框、拨号盘和语音交互过程。
通用方法是构建一个“虚拟用户界面系统”供应用程序使用。该系统提供现有用户界面系统提供的一些服务。它可以在各种各样的系统上实现,以确保应用程序代码的可移植性。当然,还有其他方法可以将应用程序与用户界面系统隔离开来。我之所以选择这种方法,是因为它很通用,因为它允许我展示各种技术和设计权衡,因为这些技术也是用于构建“真实”用户界面系统的技术,而且——最重要的是——因为这些技术适用于远远超出界面系统狭窄领域的问题。
除了忽略如何将用户操作(事件)映射到库调用的主题之外,我还忽略了多线程 GUI 系统中锁定的需要。
21.2.1 实现继承(Implementation Inheritance)
我们的第一个解决方案是使用实现继承的类层级结构(在旧程序中很常见)。
Ival_box 类定义了所有 Ival_box 的基本接口,并指定了默认实现,更多特定类型的 Ival_box 可以用自己的版本覆盖该实现。此外,我们声明了实现基本概念所需的数据:
class Ival_box {
protected:
int val;
int low, high;
bool chang ed {false}; // 由用户使用set_value() 改变
public:
Ival_box(int ll, int hh) :val{ll}, low{ll}, high{hh} { }
virtual int get_value() { chang ed = false; return val; } // 面向应用
virtual void set_value(int i) { chang ed = true; val = i; } // 面向用户
virtual void reset_value(int i) { chang ed = false; val = i; } //面向应用
virtual void prompt() { }
virtual bool was_chang ed() const { return chang ed; }
virtual ˜Ival_box() {};
};
这些函数的默认实现相当粗糙,这里提供这些函数主要是为了说明预期的语义。例如,一个实际的类会提供一些范围检查。
程序员可能会像这样使用这些“ival_ 类”:
void interact(Ival_box∗ pb)
{
pb−>prompt(); // 提示用户
// ...
int i = pb−>get_value();
if (pb−>was_chang ed()) {
// ... 新值; 做某事 ...
}
else {
// ... 做别外一些事 ...
}
}
void some_fct()
{
unique_ptr<Ival_box> p1 {new Ival_slider{0,5}}; // Ival_slider 派生于 Ival_box
interact(p1.get());
unique_ptr<Ival_box> p2 {new Ival_dial{1,12}};
interact(p2.get());
}
大多数应用程序代码都是以普通 Ival_box(指针)的形式编写的,就像 interact() 一样。这样,应用程序就不必知道 Ival_box 概念的潜在大量变体。此类专门类的知识被隔离在创建此类对象的相对较少的函数中。这将用户与派生类实现的变化隔离开来。大多数代码可能没有意识到存在不同类型的 Ival_box。
我使用 unique_ptr (§5.2.1, §34.3.1)以避免忘记 delete掉众 Ival_box 对象。
为了简化讨论,我不讨论程序如何等待输入的问题。也许程序确实在 get_value() 中等待用户(例如,在future使用 get();§5.3.5.1),也许程序将 Ival_box 与事件关联并准备响应回调,或者也许程序为 Ival_box 生成一个线程并稍后查询该线程的状态。这样的决定在用户界面系统的设计中至关重要。但是,在这里详细讨论它们只会分散对编程技术和语言工具的介绍。这里描述的设计技术和支持它们的语言工具并不特定于用户界面。它们适用于更广泛的问题。
不同种类的 Ival_box 被定义为从 Ival_box 派生的类。例如:
class Ival_slider : public Ival_box {
private:
// ... 图形元素来定义滑块的外观, 等等. ...
public:
Ival_slider(int, int);
int get_value() override; // 从用户处获取价值并将其存入 val
void prompt() override;
};
Ival_box的数据成员被声明为protected,以允许从派生类访问。因此,Ival_slider::get_value() 可以在 Ival_box::val 中存入一个值。protected的成员可从类自己的成员和派生类的成员访问,但一般用户不能访问(参见 §20.5)。
除了 Ival_slider,我们还将定义 Ival_box 概念的其他变体。这些变体可能包括 Ival_dial,它允许您通过转动旋钮来选择一个值;Flashing_ival_slider,当您要求它提示时会调用prompt();以及 Popup_ival_slider,它响应 prompt() 并出现在某个显眼的位置,从而使用户难以忽略。
我们从哪里获得图形内容?大多数用户界面系统都提供了一个类,定义屏幕上实体的基本属性。因此,如果我们使用“Big Bucks Inc.”的系统,我们必须将每个 Ival_slider、Ival_dial 等类都变成一种 BBwidget。最简单的方法是重写我们的 Ival_box,使其从 BBwidget 派生。这样,我们所有的类都继承了 BBwidget 的所有属性。例如,每个 Ival_box 都可以放在屏幕上,遵守图形样式规则,调整大小,拖动等,根据 BBwidget 系统设置的标准。我们的类层级结构如下所示:
class Ival_box : public BBwidget { /* ... */ }; // 重写使用BBwidget
class Ival_slider : public Ival_box { /* ... */ };
class Ival_dial : public Ival_box { /* ... */ };
class Flashing_ival_slider : public Ival_slider { /* ... */ };
class Popup_ival_slider : public Ival_slider { /* ... */ };
或者图形化为:
21.2.1.1 评注(Critique)
这种设计在很多方面都行之有效,对于很多问题来说,这种层级结构是一个很好的解决方案。然而,有些尴尬的细节可能会让我们寻找替代设计。
我们改造了 BBwidget 作为 Ival_box 的基础。这并不完全正确(即使这种风格在现实世界的系统中很常见)。BBwidget 的使用不是我们对 Ival_box 的基本概念的一部分;它是一个实现细节。从 BBwidget 派生 Ival_box 将实现细节提升为第一级设计决策。这可能是正确的。例如,使用“BigBucks Inc.”定义的环境可能是基于我们组织如何开展业务的关键决策。但是,如果我们还想为“Imperial Bananas”、“Liberated Software”和“Compiler Whizzes”的系统实现我们的 Ival_box,该怎么办?我们必须维护我们程序的四个不同版本:
class Ival_box : public BBwidget { /* ... */ }; // BB version
class Ival_box : public CWwidget { /* ... */ }; // CW version
class Ival_box : public IBwidget { /* ... */ }; // IB version
class Ival_box : public LSwindow { /* ... */ }; // LS version
拥有过多的版本可能会导致版本控制的噩梦。
实际上,我们不太可能找到一个简单、连贯的双字母前缀方案。更有可能的是,来自不同供应商的库会位于不同的命名空间中,并使用不同的术语来表示类似的概念,例如 BigBucks::Widget、Wizzies::control 和 LS::window。但这不会影响我们的类层级结构设计讨论,因此为了简化,我忽略了命名和命名空间问题。
另一个问题是,每个派生类都共享在 Ival_box 中声明的基本数据。当然,这些数据是实现细节,也潜入了我们的 Ival_box 接口。从实际的角度来看,在许多情况下,它也是错误的数据。例如,Ival_slider 不需要专门存储值。当有人执行 get_value() 时,可以根据滑块的位置轻松计算出值。一般来说,保留两组相关但不同的数据是自找麻烦。迟早有人会让它们不同步。此外,经验表明,新手程序员往往会以不必要的方式弄乱受保护的数据,从而导致维护问题。数据成员最好保持私有,以便派生类的编写者无法弄乱它们。更好的是,数据应该在派生类中,在那里可以定义它以完全匹配要求,并且不会使不相关的派生类的生活复杂化。在几乎所有情况下,受保护的接口都应该只包含函数、类型和常量。
从 BBwidget 派生的好处是,Ival_box 用户可以使用 BBwidget 提供的功能。不幸的是,这也意味着对 BBwidget 类的更改可能会迫使用户重新编译甚至重写代码以从此类更改中恢复。特别是,大多数 C++ 实现的工作方式意味着基类大小的更改需要重新编译所有派生类。
最后,我们的程序可能必须在混合环境中运行,其中不同用户界面系统的窗口共存。发生这种情况可能是因为两个系统以某种方式共享一个屏幕,或者因为我们的程序需要与不同系统上的用户通信。将我们的用户界面系统“连接”作为我们唯一的 Ival_box 接口的唯一基础,这不足以灵活地处理这些情况。
21.2.2 接口继承(Interface Inheritance)
所以,让我们重新开始,建立一个新的类层级结构,以解决对传统层级结构的评述中提出的问题:
[1] 用户界面系统应该是隐藏在用户不想知道的实现细节中。
[2] Ival_box 类不应包含任何数据。
[3] 用户界面系统更改后,无需重新编译使用 Ival_box 系列类的代码。
[4] 不同界面系统的 Ival_box 应该能够在我们的程序中共存。
有几种替代方法可以实现这一点。这里,我介绍一种可以干净地映射到 C++ 语言的方法。
首先,我将 Ival_box 类指定为纯接口:
class Ival_box {
public:
virtual int get_value() = 0;
virtual void set_value(int i) = 0;
virtual void reset_value(int i) = 0;
virtual void prompt() = 0;
virtual bool was_chang ed() const = 0;
virtual ˜Ival_box() { }
};
这比 Ival_box 的原始声明干净得多。数据消失了,成员函数的简单实现也消失了。构造函数也消失了,因为没有数据可以初始化。相反,我添加了一个虚析构函数,以确保正确清理将在派生类中定义的数据。
Ival_slider的定义可能看起来像这样:
class Ival_slider : public Ival_box, protected BBwidget {
public:
Ival_slider(int,int);
˜Ival_slider() override;
int get_value() override;
void set_value(int i) override;
// ...
protected:
// ... 函数重载 BBwidget virtual 函数
// 例如, BBwidget::draw(), BBwidget::mouse1hit() ...
private:
// ... slider需要的数据 ...
};
派生类 Ival_slider 继承自抽象类 (Ival_box),该抽象类要求它实现基类的纯虚函数。它还继承自 BBwidget,BBwidget 为它提供了实现此目的的方法。由于 Ival_box 为派生类提供了接口,因此它是使用 public 派生的。由于 BBwidget 只是一种实现辅助,因此它是使用 protected (§20.5.2) 派生的。这意味着使用 Ival_slider 的程序员不能直接使用 BBwidget 定义的功能。Ival_slider 提供的接口是从 Ival_box 继承的接口,加上 Ival_slider 明确声明的内容。我使用protected的派生,而不是更严格(通常更安全)的private派生,以使 BBwidget 可用于从 Ival_slider 派生的类。我使用显式覆盖,因为这种“小部件层级结构”正是那种大型、复杂的层级结构,显式覆盖可以帮助最大限度地减少混淆。
直接从多个类派生通常称为多重继承(§21.3)。请注意,Ival_slider 必须覆盖 Ival_box 和 BBwidget 中的函数。因此,它必须直接或间接地从两者派生。如 §21.2.1.1 所示,通过将 BBwidget 设为 Ival_box 的基类,间接从 BBwidget 派生 Ival_slider 是可能的,但这样做会产生不良的副作用。同样,将“实现类”BBwidget 设为 Ival_box 的成员也不是一个解决方案,因为类不能覆盖其成员的虚函数。用 Ival_box 中的 BBwidget∗ 成员表示窗口会导致完全不同的设计,并需要一组单独的权衡。
对于某些人来说,“多重继承”一词意味着某种复杂而可怕的东西。然而,使用一个基类来表示实现细节,使用另一个基类来表示接口(抽象类)是所有支持继承和编译时检查接口的语言的共同点。特别是,抽象类 Ival_box 的使用几乎与 Java 或 C# 中接口的使用相同。
有趣的是,Ival_slider 的这个声明允许应用程序代码以与之前完全相同的方式编写。我们所做的只是以更合乎逻辑的方式重构实现细节。
许多类在对象消失之前都需要对其进行某种形式的清理。由于抽象类 Ival_box 无法知道派生类是否需要此类清理,因此它必须假设它确实需要清理。我们通过在基类中定义虚析构函数 Ival_box::˜Ival_box() 并在派生类中适当地覆盖它来确保正确的清理。例如:
void f(Ival_box∗ p)
{
// ...
delete p;
}
delete 运算符明确销毁了 p 指向的对象。我们无法确切知道 p 指向的对象属于哪个类,但得益于 Ival_box 的虚析构函数,将按照该类的析构函数(可选)的定义进行适当的清理。
现在 Ival_box 的层级结构可以像这样定义:
class Ival_box { /* ... */ };
class Ival_slider
: public Ival_box, protected BBwidget { /* ... */ };
class Ival_dial
: public Ival_box, protected BBwidget { /* ... */ };
class Flashing_ival_slider
: public Ival_slider { /* ... */ };
class Popup_ival_slider
: public Ival_slider { /* ... */ };
或用图表表示为:
我使用虚线来表示受保护的继承(§20.5.1)。一般用户无法访问受保护的基类,因为它们(正确地)被视为实现的一部分。
21.2.3 替代实现(Alternative Implementations)
这种设计比传统设计更简洁、更易于维护,而且效率不减。然而,它仍然无法解决版本控制问题:
class Ival_box { /* ... */ }; // common
class Ival_slider
: public Ival_box, protected BBwidget { /* ... */ }; // for BB
class Ival_slider
: public Ival_box, protected CWwidget { /* ... */ }; // for CW
// ...
即使两个用户界面系统本身可以共存,也无法让 BBwidgets 的 Ival_slider 与 CWwidgets 的 Ival_slider 共存。显而易见的解决方案是定义几个具有不同名称的不同 Ival_slider 类:
class Ival_box { /* ... */ };
class BB_ival_slider
: public Ival_box, protected BBwidget { /* ... */ };
class CW_ival_slider
: public Ival_box, protected CWwidget { /* ... */ };
// ...
或者用图表表示为:
为了进一步将面向应用的 Ival_box 类与实现细节隔离开来,我们可以从 Ival_box 派生出一个抽象的 Ival_slider 类,然后从中派生出系统特定的 Ival_sliders:
class Ival_box { /* ... */ };
class Ival_slider
: public Ival_box { /* ... */ };
class BB_ival_slider
: public Ival_slider, protected BBwidget { /* ... */ };
class CW_ival_slider
: public Ival_slider, protected CWwidget { /* ... */ };
// ...
或者用图表表示为:
通常,通过在实现层级结构中使用更具体的类,我们可以做得更好。例如,如果“Big Bucks Inc.”系统有一个滑块类,我们可以直接从 BBslider 派生我们的 Ival_slider:
class BB_ival_slider
: public Ival_slider, protected BBslider { /* ... */ };
class CW_ival_slider
: public Ival_slider, protected CWslider { /* ... */ };
或者用图表表示为:
这种改进意义重大,因为我们的抽象与用于实现的系统提供的抽象没有太大区别,这并不罕见。在这种情况下,编程就简化为相似概念之间的映射。从通用基类(如 BBwidget)派生的情况很少发生。
完整的层级结构将由我们原始的面向应用程序的接口概念级次结构组成,这些接口以派生类的形式表示:
class Ival_box { /* ... */ };
class Ival_slider
: public Ival_box { /* ... */ };
class Ival_dial
: public Ival_box { /* ... */ };
class Flashing_ival_slider
: public Ival_slider { /* ... */ };
class Popup_ival_slider
: public Ival_slider { /* ... */ };
接下来是针对各种图形用户界面系统的此层级结构的实现,以派生类的形式表示:
class BB_ival_slider
: public Ival_slider, protected BBslider { /* ... */ };
class BB_flashing_ival_slider
: public Flashing_ival_slider, protected BBwidget_with_bells_and_whistles { /* ... */ };
class BB_popup_ival_slider
: public Popup_ival_slider, protected BBslider { /* ... */ };
class CW_ival_slider
: public Ival_slider, protected CWslider { /* ... */ };
// ...
使用明显的缩写,这个层级结构可以像这样以图形方式表示:
原始 Ival_box 类层级结构看起来没有变化,被实现类包围。
21.2.3.1 评注(Critique)
抽象类设计灵活,处理起来几乎和依赖于定义用户界面系统的公共基础的等效设计一样简单。在后一种设计中,窗口类是树的根。在前一种设计中,原始应用程序类层级结构作为提供其实现的类的根出现,没有发生变化。从应用程序的角度来看,这些设计在强意义上是等效的,因为在这两种情况下,几乎所有代码都以相同的方式工作,并且没有变化。在这两种情况下,您都可以查看 Ival_box 类系列,而大多数时候不必担心与窗口相关的实现细节。例如,如果我们从一个类层级结构切换到另一个类层级结构,我们就不需要重写 §21.2.1 中的 interact()。
在任何一种情况下,当用户界面系统的公共接口发生变化时,必须重写每个 Ival_box 类的实现。但是,在抽象类设计中,几乎所有用户代码都受到保护,不会对实现层级结构产生影响,并且在发生此类更改后无需重新编译。当实现层级结构的供应商发布新的“几乎兼容”版本时,这一点尤其重要。此外,与传统层级结构的用户相比,抽象类层级结构的用户被锁定在专有实现中的危险较小。Ival_box 抽象类应用程序层级结构的用户不会意外使用实现中的设施,因为只有 Ival_box 层级结构中明确指定的设施才可访问;没有任何东西是从特定于实现的基类隐式继承的。
这种思路的逻辑结论是,系统以抽象类的层级结构呈现给用户,并由经典层级结构实现。换句话说:
• 使用抽象类来支持接口继承(§3.2.3、§20.1)。
• 使用具有虚函数实现的基类来支持实现继承(§3.2.3、§20.1)。
21.2.4 局部对象创建(Localizing Object Creation)
大多数应用程序都可以使用 Ival_box 接口编写。此外,如果派生接口能够提供比普通 Ival_box 更多的功能,那么大多数应用程序都可以使用 Ival_box、Ival_slider 等接口编写。但是,对象的创建必须使用特定实现的名称(例如 CW_ival_dial 和 BB_flashing_ival_slider)。我们希望尽量减少出现此类特定名称的地方,除非系统地进行,否则很难定位对象创建。
像往常一样,解决方案是引入间接寻址。这可以通过多种方式实现。一个简单的方法是引入一个抽象类来表示一组创建操作:
class Ival_maker {
public:
virtual Ival_dial∗ dial(int, int) =0; // 创建dial
virtual Popup_ival_slider∗ popup_slider(int, int) =0; // 创建弹出slider
// ...
};
对于用户应该了解的 Ival_box 系列类中的每个接口,Ival_maker 类都提供了一个创建对象的函数。这样的类有时被称为工厂,其函数有时(有点误导)被称为虚构造函数(§20.3.6)。
我们现在用从 Ival_maker 派生的类来表示每个用户界面系统:
class BB_maker : public Ival_maker { // 创建 BB 版本
public:
Ival_dial∗ dial(int, int) override;
Popup_ival_slider∗ popup_slider(int, int) override;
// ...
};
class LS_maker : public Ival_maker { // 创建LS 版本
public:
Ival_dial∗ dial(int, int) override;
Popup_ival_slider∗ popup_slider(int, int) override;
// ...
};
每个函数都会创建所需接口和实现类型的对象。例如:
Ival_dial∗ BB_maker::dial(int a, int b)
{
return new BB_ival_dial(a,b);
}
Ival_dial∗ LS_maker::dial(int a, int b)
{
return new LS_ival_dial(a,b);
}
有了 Ival_maker,用户现在可以创建对象,而不必确切知道使用哪个用户界面系统。例如:
void user(Ival_maker& im)
{
unique_ptr<Ival_box> pb {im.dial(0,99)}; // 创建合适的 dial
// ...
}
BB_maker BB_impl; // 对BB 用户
LS_maker LS_impl; // 对LS 用户
void driver()
{
user(BB_impl); // 使用BB
user(LS_impl); // 使用LS
}
将参数传递给此类“虚构造函数”有点棘手。特别是,我们不能在不同的派生类中用不同的参数的函数覆盖表示接口的基类函数。这意味着需要卓越的远见来设计工厂类的接口。
21.3 多继承(Multiple Inheritance)
如 §20.1 所述,继承旨在提供以下两个好处之一:
• 共享接口(接口继承):使用类可以减少代码重复,使此类代码更加统一。这通常称为运行时多态性或接口继承。
• 共享实现(实现继承):可以减少代码,使实现代码更加统一。这通常称为实现继承。
一个类可以结合这两种风格的各个方面。
在这里,我们探索多个基类的更多一般用途,并研究与组合和访问多个基类的功能相关的更多技术问题。
21.3.1 多接口(Multiple Interfaces)
抽象类(例如,Ival_box;§21.2.2)是显而易见的表示接口的方式。对于没有可变状态的抽象类,在类层级结构中,基类的单一使用和多重使用之间几乎没有区别。§21.3.3、§21.3.4 和 §21.3.5 讨论了潜在歧义的解决。事实上,任何没有可变状态的类都可以用作多重继承格中的接口,而不会产生显著的复杂性和开销。关键的观察是,可以复制没有可变状态的类(若有必要),或者共享没有可变状态的类(若需要)。
在面向对象设计中(在任何具有接口概念的语言中),使用多个抽象类作为接口几乎是普遍的。
21.3.2 多实现类(Multiple Implementation Classes)
考虑一个围绕地球运行的物体的模拟,其中轨道物体表示为 Satellite 类的对象。Satellite 对象将包含轨道、大小、形状、反照率、密度参数等,并提供轨道计算、修改属性等操作。卫星的例子包括岩石、旧航天器的碎片、通信卫星和国际空间站。这些类型的卫星将是 Satellite 派生类的对象。这些派生类将添加数据成员和函数,并将覆盖 Satellite 的一些虚函数以适当调整其含义。
现在假设我想以图形方式显示这些模拟的结果,并且我有一个可用的图形系统,该系统使用(并不少见的)策略,即从保存图形信息的公共基类派生要显示的对象。此图形类将提供在屏幕上放置、缩放等操作。为了通用性、简单性以及隐藏实际图形系统的细节,我将引用提供图形(或实际上非图形)输出显示的类。
我们现在可以定义一类模拟通信卫星,类 Comm_sat:
class Comm_sat : public Satellite, public Displayed {
public:
// ...
};
或者用图表示为:
除了专门为 Comm_sat 定义的操作外,还可以应用 Satellite 和 Displayed 上的操作的联合。例如:
void f(Comm_sat& s)
{
s.draw(); //Displayed::draw()
Pos p = s.center(); // Satellite::center()
s.transmit(); // Comm_sat::transmit()
}
类似地,Comm_sat 可以传递给需要 Satellite 的函数和需要 Displayed 的函数。例如:
void highlight(Displayed∗);
Pos center_of_gravity(const Satellite∗);
void g(Comm_sat∗ p)
{
highlight(p); //传递一个指针给Comm_sat的Displayed部分
Pos x = center_of_gravity(p); // 传递一个指针给Comm_sat的Satellite部分
}
显然,实现该功能需要一些(简单的)编译器技术,以确保期望 Satellite 的函数看到的 Comm_sat 部分与期望 Displayed 的函数看到的 Comm_sat 部分不同。虚函数照常工作。例如:
class Satellite {
public:
virtual Pos center() const = 0; // 重心
// ...
};
class Displayed {
public:
virtual void draw() = 0;
// ...
};
class Comm_sat : public Satellite, public Displayed {
public:
Pos center() const override; // 重写 Satellite::center()
void draw() override; // 重写 Displayed::draw()
// ...
};
这确保了对于被视为Comm_sat 和 Displayed 的 Comm_sat,将分别调用 Comm_sat::center() 和 Displayed::draw()。
为什么我不将 Comm_sat 的 Satellite 和 Displayed 部分完全分开?我可以将 Comm_sat 定义为具有 Satellite 成员和 Displayed 成员。或者,我可以将 Comm_sat 定义为具有 Satellite∗ 成员和 Displayed∗ 成员,并让其构造函数设置适当的连接。对于许多设计问题,我都会这样做。但是,启发此示例的系统是基于具有虚函数的 Satellite 类和具有虚函数(单独设计的)Displayed 类的理念构建的。您通过派生提供自己的 Satellite 和自己的显示对象。特别是,您必须覆盖 Satellite 虚成员函数和 Displayed 虚成员函数来指定您自己的对象的行为。在这种情况下,很难避免具有状态和实现的基类的多重继承。解决方法可能很痛苦,并且难以维护。
使用多重继承将两个原本不相关的类“粘合”在一起作为第三个类实现的一部分,这种做法粗糙、有效且相对重要,但并不十分有趣。基本上,它使程序员免于编写大量转发函数(以弥补我们只能覆盖在基类中定义的函数这一事实)。这种技术不会对程序的整体设计产生重大影响,并且有时会与隐藏实现细节的愿望相冲突。然而,一种技术并不一定非要非常巧妙才有用。
我通常更喜欢有一个单一的实现层级结构和(在需要时)几个提供接口的抽象类。这通常更灵活,并且会导致系统更容易发展。但是,你并不总是能做到这一点——特别是当你需要使用你不想修改的现有类时(例如,因为它们是其他人的库的一部分)。
请注意,如果仅使用单继承,程序员在实现 Displayed、Satellite 和 Comm_sat 类时的选择将受到限制。Comm_sat 可以是 Satellite 或 Displayed,但不能同时是两者(除非 Satellite 派生自 Displayed 或反之亦然)。任何一种选择都会失去灵活性。
为什么有人想要一个 Comm_sat 类?与某些人的猜测相反,Satellite 示例是真实的。确实存在(也许仍然有)一个按照此处用于描述多个实现继承的思路构建的程序。它用于研究涉及卫星、地面站等的通信系统的设计。事实上,Satellite 源自并发任务的早期概念。通过这样的模拟,我们可以回答有关通信流量的问题,确定对被暴雨阻挡的地面站的适当响应,考虑卫星连接和地球连接之间的权衡等。
21.3.3 歧义消除(Ambiguity Resolution)
两个基类可能具有相同名称的成员函数。例如:
class Satellite {
public:
virtual Debug_info get_debug();
// ...
};
class Displayed {
public:
virtual Debug_info get_debug();
// ...
};
当使用 Comm_sat 时,必须消除这些函数的歧义。只需用类名限定成员名称即可完成此操作:
void f(Comm_sat& cs)
{
Debug_info di = cs.g et_debug(); // 错: 歧义
di = cs.Satellite::get_debug(); // OK
di = cs.Displayed::g et_debug(); // OK
}
但是,显式消除歧义比较麻烦,因此,解决此类问题的最佳方式通常是在派生类中定义一个新函数:
class Comm_sat : public Satellite, public Displayed {
public:
Debug_info get_debug() //重写Comm_sat::get_debug()和Displayed::get_debug()
{
Debug_info di1 = Satellite::get_debug();
Debug_info di2 = Displayed::g et_debug();
return merge_info(di1,di2);
}
// ...
};
派生类中声明的函数会覆盖其基类中所有同名同类型的函数。通常,这是完全正确的做法,因为在单个类中对具有不同语义的操作使用相同的名称通常可取。virtual的理想情况是,无论使用哪个接口查找函数,调用都具有相同的效果(§20.3.2)。
在实现重写函数时,通常需要明确限定名称以从基类中获取正确的版本。限定名称(例如 Telstar::draw)可以引用在 Telstar 或其基类中声明的 draw。例如:
class Telstar : public Comm_sat {
public:
void draw()
{
Comm_sat::draw(); //查找 Displayed::draw
// ... own stuff ...
}
// ...
};
或者用图表示为:
如果 Comm_sat::draw 无法解析 Comm_sat 中声明的 draw,则编译器会递归查找其基类;也就是说,它会查找 Satellite::draw 和 Displayed::draw,并在必要时查找它们的基类。如果找到一个匹配项,则将使用该名称。否则,Comm_sat::draw 要么未找到,要么不明确。
如果在 Telstar::draw() 中(我所称的普通 draw()),则结果将是 Telstar::draw() 的“无限”递归调用。
我本可以说 Displayed::draw(),但如果有人添加 Comm_sat::draw(),现在代码就会被巧妙地破坏;通常引用直接基类比引用间接基类更好。我本可以说 Comm_sat::Displayed::draw(),但那样就太冗余了。如果我说 Satellite::draw(),结果就会出错,因为绘制是在类层级结构的 Displayed 分支上完成的。
get_debug() 示例基本上假设 Satellite 和 Displayed 的至少某些部分是一起设计的。偶然获得名称、返回类型、参数类型和语义的精确匹配是极不可能的。更有可能的是,类似的功能以不同的方式提供,因此需要花费精力将其合并为可以一起使用的东西。我们最初可能有两个类 SimObj 和 Widget,我们无法修改,它们没有完全提供我们需要的东西,而它们确实提供了我们需要的东西,是通过不兼容的接口提供的。在这种情况下,我们可能将 Satellite 和 Displayed 设计为我们的接口类,为我们的更高级别的类提供一个“映射层(mapping layer)”:
class Satellite : public SimObj {
// 将 SimObj 设施映射到更易于卫星模拟的地方
public:
virtual Debug_info get_debug(); // 调用SimObj::DBinf() 并提取信息
// ...
};
class Displayed : public Widget {
//将Widget设施映射到更易于使用以显示卫星模拟结果的地方
public:
virtual Debug_info get_debug(); // 读Widget数据并组成Debug_info
// ...
};
或者用图表表示为:
有趣的是,这正是我们在不太可能出现的情况下用来消除歧义的技术,即两个基类提供的操作具有完全相同的名称,但具有不同的语义:添加一个接口层。考虑一个经典的(但主要是假设/理论的)示例,即一个涉及牛仔的视频游戏中的 draw() 成员函数类:
class Window {
public:
void draw(); // 显然图像
// ...
};
class Cowboy {
public:
void draw(); //从枪套中拔出枪
// ...
};
class Cowboy_window : public Cowboy, public Window {
// ...
};
我们如何覆盖 Cowboy::draw() 和 Window::draw()?这两个函数的含义(语义)截然不同,但名称和类型相同;我们需要用两个单独的函数覆盖它们。这个(奇特的)问题没有直接的语言解决方案,但添加中间类即可:
struct WWindow : Window {
using Window::Window; // 继承构造
virtual void win_draw() = 0; // 强制派生类覆盖
void draw() override final { win_draw(); } // 显然图像
};
struct CCowboy : Cowboy{
using Cowboy::Cowboy; // 继承构造
virtual void cow_draw() = 0; // 强制派生类覆盖
void draw() override final { cow_draw(); } //从枪套中拔出枪
};
class Cowboy_window : public CCowboy, public WWindow {
public:
void cow_draw() override;
void win_draw() override;
// ...
};
或者用图表表示为:
如果 Window 的设计者再小心一点,将 draw() 指定为 const,整个问题就会消失。我觉得这很典型。
21.3.4 基类复用(Repeated Use of a Base Class)
当每个类只有一个直接基类时,类层级结构将是一棵树,并且一个类只能在树中出现一次。当一个类可以有多个基类时,一个类可以在生成的层级结构中出现多次。考虑一个类,它提供将状态存储在文件中(例如,用于断点、调试信息或持久性)并在以后恢复它的功能:
struct Storable { // 持久化存储
virtual string get_file() = 0;
virtual void read() = 0;
virtual void write() = 0;
virtual ˜Storable() { }
};
这样一个有用的类自然会在类层级结构中的多个地方使用。例如:
class Transmitter : public Storable {
public:
void write() override;
// ...
};
class Receiver : public Storable {
public:
void write() override;
// ...
};
class Radio : public Transmitter, public Receiver {
public:
string get_file() override;
void read() override;
void write() override;
// ...
};
鉴于此,我们可以想象两种情况:
[1] 一个 Radio 对象有两个 Storable 类的子对象(一个用于 Transmitter,一个用于 Receiver)。
[2] 一个 Radio 对象有一个 Storable 类的子对象(由 Transmitter 和 Receiver 共享)。
示例中默认提供两个子对象。除非另有说明,否则每次涉及某个类作为基类时,您都会获得一个副本。我们可以用图形方式表示如下:
复制基类的虚函数可以被派生类中的(单个)函数覆盖。通常,覆盖函数会调用其基类版本,然后执行派生类特有的工作:
void Radio::write()
{
Transmitter::write();
Receiver::write();
// ...写入无线电特定信息 ...
}
§22.2 讨论了从复制基类到派生类的转换。有关使用派生类中的单独函数覆盖每个 write() 函数的技术,请参阅 §21.3.3。
21.3.5 虚基类(Virtual Base Classes)
上一小节中的 Radio 示例之所以有效,是因为 Storable 类可以安全、方便且高效地复制。原因很简单,Storable 是一个提供纯接口的抽象类。Storable 对象不保存自己的数据。这是最简单的情况,也是接口和实现问题最佳分离的情况。事实上,一个类无法毫不费力地确定 Radio 上有两个 Storable 子对象。
如果 Storable 确实保存了数据,而且数据很重要,不能被复制,该怎么办?例如,我们可以定义 Storable 来保存用于存储对象的文件的名称:
class Storable {
public:
Storable(const string& s); // 存储在命名文件 s 中
virtual void read() = 0;
virtual void write() = 0;
virtual ˜Storable();
protected:
string file_name;
Storable(const Storable&) = delete;
Storable& operator=(const Storable&) = delete;
};
鉴于对 Storable 的这个看似微小的更改,我们必须更改 Radio 的设计。对象的所有部分必须共享 Storable 的单个副本。否则,我们可以使用不同的文件多次从 Storable 派生出两个部分。我们通过声明基类为virtual来避免复制:派生类的每一个virtual基类都由同一个(共享)对象表示。例如:
class Transmitter : public virtual Storable {
public:
void write() override;
// ...
};
class Receiver : public virtual Storable {
public:
void write() override;
// ...
};
class Radio : public Transmitter, public Receiver {
public:
void write() override;
// ...
};
或者图表化表示为:
将此图与 §21.3.4 中的 Storable 对象图进行比较,以查看普通继承和虚继承之间的区别。在继承图中,每个指定为virtual的给定名称的基类都将由该类的单个对象表示。另一方面,每个未指定为virtual的基类都将有自己的子对象来表示它。
为什么有人想要使用包含数据的虚基类呢?我能想到类层级结构中的两个类共享数据的三种明显方式:
[1] 使数据非局部化(在类之外作为全局或命名空间变量)。
[2] 将数据放入基类中。
[3] 在某处分配一个对象,并为这两个类分别提供一个指针。
选项 [1]之非局部数据,通常是一个糟糕的选择,因为我们无法控制哪些代码访问数据以及如何访问。它破坏了所有封装和局部性的概念。
选项 [2]之将数据放在基类中,通常是最简单的。但是,对于单继承,该解决方案使有用的数据(和函数)“浮到”到公共基类;通常它会一直“浮到”到继承树的根。这意味着类层级结构的每个成员都可以访问。这在逻辑上与使用非局部数据非常相似,并且会遇到相同的问题。因此,我们需要一个不是树根的公共基类——即虚基类。
选项 [3],即共享通过指针访问的对象,是有意义的。但是,构造函数需要为该共享对象留出内存,对其进行初始化,并向需要访问的对象提供指向共享对象的指针。这大致就是构造函数实现虚基类所做的事情。
如果不需要共享,则可以不使用虚基类,这样您的代码通常更好,也更简单。但是,如果您确实需要在一般类层级结构中共享,您基本上可以选择使用虚基类,或者费力地构建您自己的变体。
我们可以用虚基来表示一个类的对象,如下所示:
指向代表虚基类的共享对象的“指针”Storable 将是偏移量(offsets),并且通常可以通过将 Storable 放置在相对于Receiver或Transmitter子对象的固定位置来优化其中一个。每个虚基类的存储开销预计为一个字(译注:即16个二进制位)。
21.3.5.1 构建虚基类(Constructing Virtual Bases)
使用虚基类你可以创建复杂的网格类。当然,我们希望保持网格简单,但无论我们使它们变得多么复杂,语言都会确保虚基类的构造函数只被调用一次。此外,基的构造函数(无论是否是虚的)在其派生类之前被调用。任何其他情况都会引起混乱(即,对象可能在初始化之前被使用)。为了避免这种混乱,每个虚基类的构造函数都从完整对象的构造函数(最深派生类的构造函数)调用(隐式或显式)。特别是,这确保虚基类即使在类层级结构中的许多地方涉及,也只被构造一次。例如:
struct V {
V(int i);
// ...
};
struct A {
A(); // default constructor
// ...
};
struct B : virtual V, vir tual A {
B() :V{1} { /* ... */ }; // 默认构造函数 ; 必须初始化基类 V
// ...
};
class C : virtual V {
public:
C(int i) : V{i} { /* ... */ }; // 必须初始化基类 V
// ...
};
class D : virtual public B, virtual public C {
//从B和C显式获取虚基类 V
// 隐式从B获得虚基类 A
public:
D() { /* ... */ } // 错: C 或 V 没有默认构造函数
D(int i) :C{i} { /* ... */ }; // 错: V 没有默认构造函数
D(int i, int j) :V{i}, C{j} { /* ... */ } // OK
// ...
};
请注意,D 可以且必须为 V 提供初始化程序。V 没有被明确提及为D 的基类这一事实无关紧要。虚基类的知识和初始化它的义务“浮到”到最深派生的类。虚拟基类始终被视为其最派生的类的直接基类。B 和 C 都初始化了 V 这一事实无关紧要,因为编译器不知道这两个初始化程序中的哪一个更合适。因此,只使用最深派生的类提供的初始化程序。
虚基类的构造函数在其派生类的构造函数之前被调用。
实际上,这并不像我们希望的那样局部化。特别是,如果我们从 D 派生另一个类 DD,那么 DD 必须做一些工作来初始化虚基类。除非我们可以简单地继承 D 的构造函数(§20.3.5.1),否则这可能会很麻烦。这应该鼓励我们不要过度使用虚基类。
构造函数的这个逻辑问题在析构函数中不存在。它们只是按照构造的相反顺序调用(§20.2.2)。特别是,虚基类的析构函数只被调用一次。
21.3.5.2 仅调用一次虚类成员(Calling a Virtual Class Member Once Only)
当为具有虚基类的类定义函数时,程序员通常无法知道该基类是否会与其他派生类共享。当实现要求每次调用派生函数时都恰好调用一次基类函数的服务时,这可能是一个问题。在需要时,程序员可以通过仅从最深派生的类调用虚基类函数来模拟用于构造函数的方案。例如,假设我们有一个知道如何绘制其内容的基本 Window 类:
class Window {
public:
// basic stuff
virtual void draw();
};
此外,我们还有多种装饰窗体和添加设施的方法:
class Window_with_border : public virtual Window {
// border stuff
protected:
void own_draw(); // 显然边界
public:
void draw() override;
};
class Window_with_menu : public virtual Window {
// menu stuff
protected:
void own_draw(); // 显示菜单
public:
void draw() override;
};
own_draw() 函数不需要是虚的,因为它们应该在虚 draw() 函数中调用,该函数“知道”调用它的对象的类型。
由此,我们可以编写一个合理的 Clock 类:
class Clock : public Window_with_border, public Window_with_menu {
// clock stuff
protected:
void own_draw(); // 显示钟面和指针(hands)
public:
void draw() override;
};
或者用图表表示为:
现在可以使用 own_draw() 函数定义 draw() 函数,这样任何 draw() 的调用者都会只调用一次 Window::draw()。这与调用 draw() 的窗口类型无关:
void Window_with_border::draw()
{
Window::draw();
own_draw(); // 显示边界
}
void Window_with_menu::draw()
{
Window::draw();
own_draw(); // 显示菜单
}
void Clock::draw()
{
Window::draw();
Window_with_border::own_draw();
Window_with_menu::own_draw();
own_draw(); // 显示钟面和指针
}
请注意,合格的调用(例如 Window::draw())不使用虚调用机制。相反,它直接调用显式命名的函数,从而避免令人讨厌的无限递归。
§22.2 中讨论了从虚基类到派生类的转换。
21.3.6 复制对比虚基类(Replicated vs. Virtual Bases)
使用多重继承来提供表示纯接口的抽象类的实现会影响程序的设计方式。 BB_ival_slider 类(§21.2.3)就是一个例子:
class BB_ival_slider
: public Ival_slider, // interface
protected BBslider // implementation
{
//由Ival_slider 和BBsliders要求的函数实现, 使用来自BBslider的实施
};
在这个例子中,两个基类在逻辑上扮演着不同的角色。一个基类是提供接口的公共抽象类,另一个是提供实现“细节”的受保护的具体类。这些角色反映在类的风格和提供的访问控制(§20.5)中。多重继承的使用在这里几乎是必不可少的,因为派生类需要从接口和实现中重写虚函数。
例如,再次考虑 §21.2.1 中的 Ival_box 类。最后(§21.2.2),我将所有 Ival_box 类抽象化,以反映它们作为纯接口的角色。这样做让我能够将所有实现细节放在特定的实现类中。此外,所有实现细节的共享都是在用于实现的 Windows 系统的经典层级结构中完成的。
当使用抽象类(没有任何共享数据)作为接口时,我们可以选择:
• 复制接口类(类层级结构中每个涉及的对象)。
• 将接口类设为virtual,以便在层次结构中涉及它的所有类之间共享一个简单对象。
使用 Ival_slider 作为虚拟基础可以给我们带来:
class BB_ival_slider
: public virtual Ival_slider, protected BBslider { /* ... */ };
class Popup_ival_slider
: public virtual Ival_slider { /* ... */ };
class BB_popup_ival_slider
: public virtual Popup_ival_slider, protected BB_ival_slider { /* ... */ };
或者用图表表示为:
很容易想象从Popup_ival_slider 派生出进一步的接口,以及从这样的类和 BB_popup_ival_slider 派生出进一步的实现类。
但是,我们也可以采用复制的 Ival_slider 对象来替代:
class BB_ival_slider
: public Ival_slider, protected BBslider { /* ... */ };
class Popup_ival_slider
: public Ival_slider { /* ... */ };
class BB_popup_ival_slider
: public Popup_ival_slider, protected BB_ival_slider { /* ... */ };
或者用图表表示为:
令人惊讶的是,一种设计相对于另一种设计没有根本的运行时或空间优势。不过,它们之间存在逻辑差异。在复制的 Ival_slider 设计中,BB_popup_ival_slider 不能隐式转换为 Ival_slider(因为这样会产生歧义):
void f(Ival_slider∗ p);
void g(BB_popup_ival_slider∗ p)
{
f(p);//错: Popup_ival_slider ::Ival_slider 或 BB_ival_slider ::Ival_slider?
}
另一方面,可以构建合理的场景,其中虚基类设计中隐含的共享会导致基类强制转换的歧义(§22.2)。但是,这种歧义很容易处理。
我们如何在虚基类和复制基类之间为我们的接口做出选择?当然,大多数情况下,我们没有选择,因为我们必须遵循现有的设计。当我们确实有选择时,我们可以考虑到(令人惊讶的是)复制基类解决方案往往会导致对象稍微小一些(因为不需要支持共享的数据结构),并且我们经常从“虚构造函数”或“工厂函数”中获取我们的接口对象(§21.2.4)。例如:
Popup_ival_slider∗ popup_slider_factory(args)
{
// ...
return new BB_popup_ival_slider(args);
// ...
}
不需要进行从实现(此处为BB_popup_ival_slider)到其直接接口(此处为 Popup_ival_slider)的显式转换。
21.3.6.1 构建虚基类(Constructing Virtual Bases)
派生类可以重写其直接或间接虚基类的虚函数。具体来说,两个不同的类可能会重写虚基类的不同虚函数。这样,多个派生类可以为虚基类提供的接口提供实现。例如,Window 类可能具有函数 set_color() 和 prompt()。在这种情况下,Window_with_border 可能会重写 set_color() 作为控制配色方案的一部分,而 Window_with_menu 可能会重写 prompt() 作为其控制用户交互的一部分:
class Window {
// ...
virtual void set_color(Color) = 0; // 设置背景色
virtual void prompt() = 0;
};
class Window_with_border : public virtual Window {
// ...
void set_color(Color) override; // 控制背景色
};
class Window_with_menu : public virtual Window {
// ...
void prompt() override; //控制用户交互
};
class My_window : public Window_with_menu, public Window_with_border {
// ...
};
如果不同的派生类重写同一个函数会怎么样?当且仅当某个重写类是从重写该函数的每个其他类派生时,才允许这种情况。也就是说,一个函数必须重写所有其他函数。例如,My_window 可以重写 prompt() 以改进 Window_with_menu 提供的功能:
class My_window : public Window_with_menu, public Window_with_border {
// ...
void prompt() override; // 不要把用户交互留给基类
};
或者用图表表示为:
如果两个类重写基类函数,但都没有重写另一个,则类层级结构是错误的。原因是,没有一个函数可用于为所有调用提供一致的含义,而不管它们使用哪个类作为接口。或者,使用实现术语,无法构造虚函数表,因为对完整对象调用该函数会产生歧义。例如,如果 §21.3.5 中的 Radio 没有声明 write(),则在定义 Radio 时,Receiver 和 Transmitter 中 write() 的声明会导致错误。与 Radio 一样,这种冲突可以通过向最深派生的类添加重写函数来解决。
为虚基类提供部分(但不是全部)实现的类通常称为混合类(mixin)。
21.4 建议(Advice)
[1] 使用 unique_ptr 或 shared_ptr 避免忘记删除使用 new 创建的对象;§21.2.1。
[2] 避免在用作接口的基类中使用日期成员;§21.2.1.1。
[3] 使用抽象类来表达接口;§21.2.2。
[4] 为抽象类提供虚拟析构函数以确保正确清理;§21.2.2。
[5] 使用 override 在大型类层级结构中明确覆盖;§21.2.2。
[6] 使用抽象类支持接口继承;§21.2.2。
[7] 使用带有数据成员的基类来支持实现继承;§21.2.2。
[8] 使用普通的多重继承来表达特性的联合;§21.3。
[9] 使用多重继承将实现与接口分离;§21.3。
[10] 使用虚基来表示层次结构中某些(但不是所有)类所共有的东西;§21.3.5。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup