继承 — 多重继承和虚继承
本节内容如何组织?
本节涵盖了广泛的问题/答案,从高层次/策略/设计问题,一直到低层次/战术/编程问题。我们按照这个顺序进行介绍。
请务必理解高层次/策略/设计问题。太多的程序员在没有首先决定他们是否真的需要“它”的情况下,就担心如何让“它”编译。所以,在担心最后几个常见问题中(重要的)机械细节之前,请阅读本节的最初几个常见问题。
我们真的需要多重继承吗?
不尽然。我们可以通过变通方法来避免多重继承,就像我们可以通过变通方法来避免单继承一样。我们甚至可以通过变通方法来避免使用类。C 语言就是这一论点的证明。然而,每个具有静态类型检查和继承的现代语言都提供某种形式的多重继承。在 C++ 中,抽象类通常充当接口,一个类可以有许多接口。其他语言——通常被认为“不是多重继承”——只是对其纯抽象类的等价物有一个单独的名称:一个接口。语言提供继承(包括单继承和多重继承)的原因是,语言支持的继承在编程便利性、逻辑问题检测、可维护性以及通常在性能方面都优于变通方法(例如,使用转发函数到子对象或单独分配的对象)。
我听说我永远不应该使用多重继承。这是对的吗?
真是……
当人们认为他们知道什么最适合你的问题,即使他们从未见过你的问题时,这让我非常恼火!!在不知道你的目标的情况下,任何人怎么可能知道多重继承不会帮助你实现你的目标?!???!!!
下次有人告诉你永远不应该使用多重继承时,直视他们的眼睛,说:“一刀切的解决方案不适合所有情况。”如果他们回应的是他们项目上的他们的糟糕经历,直视他们的眼睛,这次放慢语速重复一遍:“一刀切的解决方案不适合所有情况。”
那些宣扬一刀切规则的人,在不了解你的需求的情况下,就 presuming 替你做设计决策。他们不知道你要去哪里,却知道你应该怎么去。
不要相信一个不了解问题的人的答案。
所以多重继承有时不是坏事?!?
当然有!
你不会一直使用它。你甚至可能不会经常使用它。但在某些情况下,使用多重继承的解决方案比不使用多重继承的解决方案在构建、调试、测试、优化和维护方面成本更低。如果多重继承能降低你的成本、缩短你的日程、降低你的风险并表现良好,那么请使用它。
另一方面,仅仅因为它存在,并不意味着你就应该使用它。像任何工具一样,为工作选择合适的工具。如果 MI(多重继承)有帮助,就使用它;如果没有,就不要使用。如果你在使用它时遇到了不好的经历,不要责怪工具。为你的错误承担责任,说:“我为这项工作使用了错误的工具;这是我的错。”不要说:“因为它没有解决我的问题,所以它对所有行业的所有问题在任何时候都是不好的。”优秀的工匠从不责怪他们的工具。
使用多重继承有哪些规范?
多重继承经验法则 #1: 仅在能从调用者代码中移除 if
/ switch
语句时才使用继承。原理:这能引导人们避免“无谓的”继承(无论是单继承还是多重继承),这通常是一件好事。有少数情况下你会在没有动态绑定的情况下使用继承,但请注意:如果你经常这样做,你可能已经被错误的思维感染了。特别是,继承不是为了代码复用。你有时会通过继承获得一些代码复用,但继承的主要目的是动态绑定,而这是为了灵活性。组合是为了代码复用,继承是为了灵活性。这条经验法则不特定于多重继承,而是适用于所有继承用法。
多重继承经验法则 #2: 使用多重继承时,特别努力地使用抽象基类 (ABCs)。特别是,连接类(通常也包括连接类本身)之上的大多数类都应该是 ABC。在此语境中,“ABC”不仅仅指“至少有一个纯虚函数的类”;它实际上是指一个纯 ABC,意味着数据尽可能少(通常没有),并且大多数(通常是所有)方法都是纯虚函数。理由:这种规范有助于你避免需要通过两条路径继承数据或代码的情况,而且它鼓励你正确使用继承。第二个目标微妙但极其强大。特别是,如果你习惯于将继承用于代码重用(充其量是可疑的;见上文),这条经验法则将引导你远离多重继承,并可能(希望!)首先远离为了代码重用而使用继承。换句话说,这条经验法则倾向于将人们推向为了接口可替代性而使用继承,这总是安全的,并远离为了帮助我在派生类中少写代码而使用继承,这通常(并非总是)不安全的。
多重继承经验法则 #3: 考虑“桥接”模式或嵌套泛化作为多重继承的可能替代方案。这并不意味着多重继承有什么“问题”;它只是意味着至少有三种替代方案,明智的设计师在选择最佳方案之前会检查所有替代方案。
你能提供一个示例来演示上述指导方针吗?
假设你有陆地车辆、水上车辆、空中车辆和太空车辆。(在这个例子中,忘掉两栖车辆这个概念;假装它们不存在。)假设我们也有不同的动力来源:燃气动力、风力动力、核动力、脚踏动力等。我们可以使用多重继承将所有东西连接起来,但在这样做之前,我们应该问一些棘手的问题:
LandVehicle
的用户是否需要一个指向LandVehicle
对象的Vehicle&
?特别是,用户是否会调用Vehicle
引用上的方法,并期望这些方法的实际实现特定于LandVehicle
?GasPoweredVehicle
也一样:用户是否需要一个指向GasPoweredVehicle
对象的Vehicle
引用,特别是他们是否想调用该Vehicle
引用上的方法,并期望实现被GasPoweredVehicle
覆盖?
如果两个答案都是“是”,那么多重继承可能是最佳选择。但在你关闭替代方案的大门之前,这里还有一些“决策标准”。假设有 N 种地理环境(陆地、水、空中、太空等)和 M 种动力来源(燃气、核能、风能、脚踏等)。总体设计至少有三种选择:桥接模式、嵌套泛化和多重继承。每种都有其优缺点:
- 使用桥接模式,你创建两个不同的层次结构:ABC
Vehicle
有派生类LandVehicle
、WaterVehicle
等,以及 ABCEngine
有派生类GasPowered
、NuclearPowered
等。然后Vehicle
有一个Engine*
(即,一个Engine
指针),用户在运行时混合和匹配车辆和引擎。这有一个优点,你只需要编写 N+M 个派生类,这意味着当你添加一个新的地理环境(N 增加)或引擎类型(M 增加)时,你只需要添加一个新派生类,事情就能非常优雅地扩展。然而,你也有几个缺点:你只有 N+M 个派生类,这意味着你最多只有 N+M 个重写,因此只有 N+M 个具体的算法/数据结构。如果你最终想要在 N×M 种组合中拥有不同的算法和/或数据结构,你将不得不努力实现这一点,你可能最好使用纯桥接模式以外的东西。桥接模式没有为你解决的另一个问题是消除无意义的选择,例如脚踏式太空飞行器。你可以通过在用户在运行时组合车辆和引擎时添加额外检查来解决这个问题,但这需要一些小伎俩,而桥接模式不会免费提供。桥接模式也限制了用户,因为虽然所有地理环境之上有一个共同的基类(这意味着用户可以将任何类型的车辆作为Vehicle&
传递),但例如,所有燃气动力车辆之上并没有一个共同的基类,因此用户不能将任何燃气动力车辆作为GasPoweredVehicle&
传递。最后,桥接模式的优点是它在例如水上车辆组和例如燃气动力车辆组之间共享代码。换句话说,各种燃气动力车辆共享派生类GasPoweredEngine
中的代码。 - 使用嵌套泛化,你选择其中一个层次结构作为主要,另一个作为次要,并拥有一个嵌套层次结构。例如,如果你选择地理作为主要,
Vehicle
将有派生类LandVehicle
、WaterVehicle
等,而这些类又将有进一步的派生类,每种动力源类型一个。例如,LandVehicle
将有派生类GasPoweredLandVehicle
、PedalPoweredLandVehicle
、NuclearPoweredLandVehicle
等;WaterVehicle
也将有一组类似的派生类,等等。这要求你编写大约 N×M 个不同的派生类,这意味着当 N 或 M 增加时,事情不会优雅地扩展,但它比桥接模式的优势在于你可以拥有 N×M 个不同的算法和数据结构。它还为你提供了精细的控制,因为用户不能选择无意义的组合,例如脚踏式太空飞行器,因为用户只能选择程序员认为合理的组合。不幸的是,嵌套泛化并没有改善将任何燃气动力车辆作为共同基类传递的问题,因为次要层次结构之上没有共同基类,例如,没有GasPoweredVehicle
基类。最后,如何在所有使用相同动力源的车辆之间共享代码(例如,所有燃气动力车辆之间)并不明显。 - 使用多重继承,你有两个独立的层次结构,就像桥接模式一样,但你从桥接中移除了
Engine*
,取而代之的是在地理和动力来源两个层次结构之下创建了大约 N×M 个派生类。这并不那么简单,因为你需要改变Engine
类的概念。特别是,你需要将该层次结构中的类重命名,例如,从GasPoweredEngine
重命名为GasPoweredVehicle
;此外,你还需要对层次结构中的方法进行相应的更改。无论如何,类GasPoweredLandVehicle
将多重继承自GasPoweredVehicle
和LandVehicle
,GasPoweredWaterVehicle
、NuclearPoweredWaterVehicle
等也类似。像嵌套泛化一样,你必须编写大约 N×M 个类,这不会优雅地扩展,但它确实为你提供了对各种派生类中使用的算法和数据结构以及哪些组合被认为是“合理”的精细控制,这意味着你只需不创建像PedalPoweredSpaceVehicle
这样荒谬的选择。它解决了桥接和嵌套泛化共有的一个问题,即它允许用户使用一个公共基类传递任何燃气动力车辆。最后,它为代码共享问题提供了一个解决方案,这个解决方案至少与桥接解决方案一样好:它允许所有燃气动力车辆在需要时共享公共代码。我们说这“至少与桥接解决方案一样好”,因为与桥接不同,派生类可以在燃气动力车辆内部共享公共代码,而且与桥接不同,在共享代码不理想的情况下,可以覆盖和替换该代码。
最重要的一点:没有普遍“最佳”的答案。也许你曾希望我会告诉你总是使用上述选择中的一种或另一种。我很乐意这样做,但有一个小细节:那将是谎言。如果上述恰好有一种总是最佳的,那么“一刀切”就适用于所有情况,而我们知道它并非如此。
所以,你要做的就是:思 考。你必须做出决定。我会给你一些指导方针,但最终你必须决定什么最适合(或者也许是“最不坏”)你的情况。
有没有一种简单的方法来直观地展示所有这些权衡?
以下是一些“优良标准”,即你可能希望拥有的品质。在这个描述中,N是地理区域的数量,M是动力源的数量。
- 优雅扩展:当你添加新的地理区域或动力源时,代码库的大小是否会优雅地增长?如果你添加一个新的地理区域(从N到N+1),你需要添加一个新的代码块(最佳),M个新的代码块(最差),还是介于两者之间?
- 低代码量:代码量是否合理地小?这通常与持续维护成本成正比——在其他条件相同的情况下,代码越多,成本越高。它也通常与“优雅扩展”标准相关:除了框架本身的整体代码量,最佳情况下将有N+M个代码块,最差情况下将有N×M个代码块。
- 精细控制:你是否对算法和数据结构拥有精细的粒度控制?例如,你是否可以选择为N×M种可能性中的任何一种使用不同的算法和/或数据结构,还是你必须对所有(比如说)燃气动力车辆使用相同的算法和/或数据结构?
- 静态检测不良组合:你能否静态地(“在编译时”)检测并阻止无效组合?例如,假设目前没有脚踏式太空飞行器。如果有人试图创建一个脚踏式太空飞行器,这能在编译时(好)检测到,还是我们需要在运行时检测到?
- 两端多态:它是否允许用户对任一基类进行多态处理?换句话说,你能否创建一些用户代码
f()
,它接受所有(比如说)陆地车辆(你可以在不修改f()
的情况下添加新型陆地车辆),并创建一些其他用户代码g()
,它接受所有(比如说)燃气动力车辆(你可以在不修改g()
的情况下添加新型燃气动力车辆)? - 共享通用代码:它是否允许新组合共享来自任一侧的通用代码?例如,当你创建一种新型燃气动力陆地车辆时,该新类是否可以选择性地共享许多燃气动力车辆共有的代码,并选择性地共享许多陆地车辆共有的代码?
此矩阵显示技术为行,“优良标准”为列。表示该行技术具备该列的优良标准,“—”表示不具备。
优雅扩展? | 低代码量? | 精细控制? | 静态检测不良组合? | 两端多态? | 共享通用代码? | |
---|---|---|---|---|---|---|
桥接模式 | ![]() |
![]() (N+M 个代码块) |
— | — | — | ![]() |
嵌套泛化 | — | — (N×M 个代码块) |
![]() |
![]() |
— | — |
多重继承 | — | — (N×M 个代码块) |
![]() |
![]() |
![]() |
![]() |
重要提示:不要天真。不要简单地把 的数量加起来,然后根据优点最多或缺点最少来选择。思考!!
- 第一步是思考你的特定情况是否还有其他设计选项,即额外的行。
- 使用上述矩阵的第二步是思考哪一列对你的特定情况最重要。这将让你能够为每一列赋予一个“权重”或“重要性”。
- 例如,在你的特定情况下,必须编写的代码量(第二列)可能比对算法/数据结构的精细控制更重要或不那么重要。不要试图在抽象的、通用的、一刀切的世界观中弄清楚哪一列更重要,因为一刀切不适用于所有情况!!
- 问题:代码量(以及因此的维护成本)是否比精细控制更重要或不那么重要?答案:是的,代码量(以及因此的维护成本)要么比精细控制更重要,要么不那么重要。这是个笑话;轻松点。
- 但这一部分不是玩笑:不要相信任何认为他们知道代码量(以及因此的维护成本)总是比精细控制更重要或不那么重要的人。在查看你的特定情况的所有要求和约束之前,无法知道答案!太多程序员在熟悉情况之前就认为他们知道答案。这比愚蠢更糟糕;这是不专业和危险的。他们一刀切的答案有时会是对的。他们一刀切的答案可能在他们有限经验范围内的所有情况下都是对的。但如果他们过去的成功使他们对未来的棘手问题视而不见,他们将对你的项目构成危险,应该在头上“敲”一下(“敲”和“头”是高度专业化的术语)。
你的最终选择将通过找出哪种方法最适合你的情况来做出。一刀切不适用于所有情况——不要期望一个项目中的答案与另一个项目中的答案相同。如果你不小心,你过去的成功可能会成为你未来失败的种子。仅仅因为“它”在你的上一个项目中是最好的,并不意味着“它”在你的下一个项目中也会是最好的。
你能再举一个例子来说明上述规范吗?
第二个例子与前一个略有不同,因为它更明显地是对称的。这种对称性使天平略微倾向于多重继承解决方案,但在某些情况下,其他解决方案仍然可能是最佳选择。
在这个例子中,我们只有两种车辆:陆地车辆和水上车辆。然后有人指出我们需要两栖车辆。现在我们进入了好的部分:问题。
- 我们甚至需要一个单独的
AmphibiousVehicle
类吗?使用其他类,并用一个“位”来表示该车辆既能在水中也能在陆地上,是否可行?仅仅因为“现实世界”有两栖车辆,并不意味着我们需要在软件中模拟它。 LandVehicle
的用户是否需要使用一个指向AmphibiousVehicle
对象的LandVehicle&
?他们是否需要调用LandVehicle&
上的方法,并期望这些方法的实际实现特定于(“在”)AmphibiousVehicle
中被覆盖?- 水上车辆也是如此:用户是否需要一个可能引用
AmphibiousVehicle
对象的WaterVehicle&
,特别是要调用该引用上的方法,并期望实现会被AmphibiousVehicle
覆盖?
如果得到三个“是”的答案,多重继承可能是正确的选择。为确保万无一失,你还应该问其他问题,例如优雅增长问题、控制粒度问题等。
什么是“可怕的菱形”?
“可怕的菱形”指的是一种类结构,其中一个特定类在一个类的继承层次结构中出现不止一次。例如,
class Base {
public:
// ...
protected:
int data_;
};
class Der1 : public Base { /*...*/ };
class Der2 : public Base { /*...*/ };
class Join : public Der1, public Der2 {
public:
void method()
{
data_ = 1; // Bad: this is ambiguous; see below
}
};
int main()
{
Join* j = new Join();
Base* b = j; // Bad: this is ambiguous; see below
}
请原谅我的 ASCII 艺术,但继承层次结构看起来像这样:
Base
/ \
/ \
/ \
Der1 Der2
\ /
\ /
\ /
Join
在我们解释为什么可怕的菱形可怕之前,重要的是要注意 C++ 提供了处理每种“可怕之处”的技术。换句话说,这种结构通常被称为可怕的菱形,但它实际上并不可怕;它更像是一些需要注意的事情。
关键在于,Base
被继承了两次,这意味着在 Base
中声明的任何数据成员(例如上面的 data_
)将在一个 Join
对象中出现两次。这可能会产生歧义:你想改变哪个 data_
?出于同样的原因,从 Join*
到 Base*
,或从 Join&
到 Base&
的转换也是模糊的:你想要哪个 Base
类子对象?
C++ 允许您解决这些歧义。例如,您可以不说 data_ = 1
,而说 Der2::data_ = 1
,或者您可以从 Join*
转换为 Der1*
,然后再转换为 Base*
。然而,请,请,请在这样做之前三思。这几乎从不是最佳解决方案。最佳解决方案通常是告诉 C++ 编译器在 Join
对象中只应出现一个 Base
子对象,这将在下一节中描述。
在层次结构中我应该在哪里使用虚继承?
就在菱形顶部下方,而不是在连接类处。
为了避免“可怕的菱形”中出现的重复基类子对象,您应该在直接从菱形顶部派生的类的继承部分使用 virtual
关键字
class Base {
public:
// ...
protected:
int data_;
};
class Der1 : public virtual Base {
↑↑↑↑↑↑↑ // This is the key
public:
// ...
};
class Der2 : public virtual Base {
↑↑↑↑↑↑↑ // This is the key
public:
// ...
};
class Join : public Der1, public Der2 {
public:
void method()
{
data_ = 1; // Good: this is now unambiguous
}
};
int main()
{
Join* j = new Join();
Base* b = j; // Good: this is now unambiguous
}
由于 Der1
和 Der2
的基类部分中的 virtual
关键字,Join
的实例将只有一个 Base
子对象。这消除了歧义。这通常优于上一个常见问题中描述的完全限定名称用法。
需要强调的是,virtual
关键字位于 Der1
和 Der2
之上的层次结构中。在 Join
类本身中放置 virtual
关键字是无济于事的。换句话说,在创建类 Der1
和 Der2
时,您必须知道连接类将会存在。
Base
/ \
/ \
virtual / \ virtual
Der1 Der2
\ /
\ /
\ /
Join
通过虚继承“委托给姐妹类”是什么意思?
考虑以下例子:
class Base {
public:
virtual void foo() = 0;
virtual void bar() = 0;
};
class Der1 : public virtual Base {
public:
virtual void foo();
};
void Der1::foo()
{ bar(); }
class Der2 : public virtual Base {
public:
virtual void bar();
};
class Join : public Der1, public Der2 {
public:
// ...
};
int main()
{
Join* p1 = new Join();
Der1* p2 = p1;
Base* p3 = p1;
p1->foo();
p2->foo();
p3->foo();
}
信不信由你,当 Der1::foo()
调用 this->bar()
时,它最终会调用 Der2::bar()
。是的,没错:一个 Der1
一无所知的类将提供 Der1::foo()
调用的虚函数的重写。这种“交叉委托”是定制多态类行为的强大技术。
使用虚继承时,我需要了解哪些特殊注意事项?
通常,虚基类最适用于那些派生自虚基类,尤其是虚基类本身是纯抽象类的情况。这意味着“连接类”之上的类数据很少,甚至没有。
注意:即使虚基类本身是一个没有成员数据的纯抽象类,你仍然可能不想在 Der1
和 Der2
类中移除虚继承。你可以使用完全限定名来解决出现的任何歧义,在某些情况下你甚至可以榨取一些周期,但是对象的地址有些模糊(Join
对象中仍然有两个 Base
类子对象),所以像尝试找出两个指针是否指向同一个实例这样的简单事情可能会很棘手。只是要小心——非常小心。
当我从使用虚继承的类继承时,我需要了解哪些特殊注意事项?
最派生类的构造函数的初始化列表直接调用虚基类的构造函数。
由于虚基类子对象在实例中只出现一次,因此有特殊规则来确保虚基类的构造函数和析构函数每个实例只被调用一次。C++ 规则规定虚基类在所有非虚基类之前构造。作为程序员,你需要知道的是:你的类继承层次结构中任何地方的虚基类构造函数都由“最派生”类的构造函数调用。
实际上,这意味着当你创建一个具有虚基类的具体类时,你必须准备好传递调用虚基类构造函数所需的任何参数。当然,如果你的类层次结构中任何地方有几个虚基类,你必须准备好调用它们所有的构造函数。这可能意味着最派生类的构造函数需要的参数比你原来想象的要多。
然而,如果虚基类的作者遵循了上一个常见问题中的指南,那么虚基类的构造函数可能不接受任何参数,因为它没有任何数据需要初始化。这意味着(幸运的是!)最终从虚基类继承的具体类的作者不需要担心接受额外参数来传递给虚基类的构造函数。
当我使用一个使用虚继承的类时,我需要了解哪些特殊注意事项?
不要使用 C 风格的向下转型;改用 dynamic_cast
。
(其余待写。)
再问一次:在多重和/或虚继承情况下,构造函数的确切顺序是什么?
首先执行的构造函数是层次结构中任意位置的虚基类。它们按照基类图的深度优先、从左到右遍历的顺序执行,其中从左到右指的是基类名称的出现顺序。
所有虚基类构造函数完成后,构造顺序通常是从基类到派生类。如果你想象编译器在派生类构造函数中做的第一件事就是隐式调用其非虚基类的构造函数(提示:许多编译器实际上就是这样做的),那么细节最容易理解。因此,如果类 D 多重继承自 B1 和 B2,则 B1 的构造函数首先执行,然后是 B2 的构造函数,然后是 D 的构造函数。此规则递归应用;例如,如果 B1 继承自 B1a 和 B1b,并且 B2 继承自 B2a 和 B2b,则最终顺序是 B1a、B1b、B1、B2a、B2b、B2、D。
请注意,B1 然后 B2(或 B1a 然后 B1b)的顺序由基类在类声明中出现的顺序决定,而不是由初始化器在派生类的初始化列表中出现的顺序决定。
在多重和/或虚继承情况下,析构函数的精确顺序是什么?
简短回答:与构造函数顺序完全相反。
长答案:假设“最派生”的类是 D,这意味着最初创建的实际对象是类 D 的,并且 D 多重(且非虚)继承自 B1 和 B2。对应于最派生类 D 的子对象首先运行,然后是其非虚基类的析构函数,按声明顺序的反向。因此,析构函数顺序将是 D、B2、B1。此规则递归应用;例如,如果 B1 继承自 B1a 和 B1b,并且 B2 继承自 B2a 和 B2b,则最终顺序是 D、B2、B2b、B2a、B1、B1b、B1a。
完成所有这些之后,处理层次结构中出现的虚基类。这些虚基类的析构函数按照它们在基类图的深度优先、从左到右遍历中出现的反向顺序执行,其中从左到右指的是基类名称的出现顺序。例如,如果该遍历顺序中的虚基类是 V1、V1、V1、V2、V1、V2、V2、V1、V3、V1、V2,则唯一的虚基类是 V1、V2、V3,最终的最终顺序是 D、B2、B2b、B2a、B1、B1b、B1a、V3、V2、V1。
提醒您将基类的析构函数设为 virtual
,至少在一般情况下是这样。如果您没有彻底理解为什么您将基类的析构函数设为虚函数的规则,那么要么学习其原理,要么就相信我并将其设为 virtual
。