使用多重继承是一个好概念还是我可以做其他事情?
15 回答
多重继承(缩写为 MI)有味道,这意味着通常,它是出于不好的原因而完成的,并且会在维护者面前反击。
概括
- 考虑特征的组合,而不是继承
- 警惕恐惧钻石
- 考虑继承多个接口而不是对象
- 有时,多重继承是正确的。如果是,则使用它。
- 准备好在代码审查中捍卫您的多重继承架构
1. 也许作文?
这对于继承来说是正确的,因此对于多重继承来说更是如此。
您的对象真的需要从另一个对象继承吗?ACar
不需要从 a 继承Engine
来工作,也不需要从 a继承Wheel
。ACar
有一个Engine
和四个Wheel
。
如果您使用多重继承而不是组合来解决这些问题,那么您做错了。
2. 恐惧钻石
通常,你有一个类A
,然后B
和C
两者都继承自A
. 并且(不要问我为什么)然后有人决定必须从和D
继承。B
C
八八年来我遇到过两次这种问题,很有趣,因为:
- 从一开始就犯了多大的错误(在这两种情况下,
D
都不应该从两者继承B
andC
),因为这是糟糕的架构(事实上,C
根本不应该存在......) - 维护人员为此付出了多少代价,因为在 C++ 中,父类
A
在其孙类中出现了两次D
,因此,更新一个父字段A::field
意味着要么更新它两次(通过B::field
和C::field
),要么稍后出现静默错误并崩溃(在 中新建一个指针B::field
,然后删除C::field
...)
如果这不是你想要的,在 C++ 中使用关键字 virtual 来限定继承可以避免上述双重布局,但无论如何,根据我的经验,你可能做错了什么......
在对象层次结构中,您应该尝试将层次结构保持为树(节点有一个父节点),而不是图。
更多关于钻石的信息(编辑 2017-05-03)
C++ 中的 Diamond of Dread 的真正问题(假设设计是合理的 - 审查您的代码!),是您需要做出选择:
- 该类是否希望
A
在您的布局中存在两次,这是什么意思?如果是,那么一定要从它继承两次。 - 如果它应该只存在一次,那么虚拟地继承它。
这种选择是问题所固有的,并且在 C++ 中,与其他语言不同,您实际上可以做到这一点,而无需教条强制您在语言级别进行设计。
但与所有权力一样,这种权力伴随着责任:审查你的设计。
3. 接口
零个或一个具体类的多重继承,以及零个或多个接口通常是可以的,因为你不会遇到上面描述的恐惧钻石。事实上,这就是 Java 中的工作方式。
通常,当 C 继承自A
并且B
用户可以C
像A
使用B
.
在 C++ 中,接口是一个抽象类,它具有:
它的所有方法都声明为纯虚拟(后缀= 0)(删除了2017-05-03)- 没有成员变量
零到一个真实对象以及零个或多个接口的多重继承不被认为是“臭”(至少,不是那么多)。
有关 C++ 抽象接口的更多信息(编辑 2017-05-03)
首先,NVI 模式可用于生成接口,因为真正的标准是没有状态(即没有成员变量,除了this
)。您的抽象接口的重点是发布合同(“您可以这样称呼我,这样称呼我”),仅此而已。只有抽象虚拟方法的限制应该是一种设计选择,而不是一种义务。
其次,在 C++ 中,从抽象接口虚拟继承是有意义的(即使有额外的成本/间接)。如果你不这样做,并且接口继承在你的层次结构中出现多次,那么你就会有歧义。
第三,面向对象很棒,但它不是C++ 中的 The Only Truth Out There TM。使用正确的工具,并始终记住您在 C++ 中还有其他范式提供不同类型的解决方案。
4. 你真的需要多重继承吗?
有时候是的。
通常,您的C
类继承自A
and B
,A
andB
是两个不相关的对象(即不在同一层次结构中,没有共同点,不同的概念等)。
例如,您可以拥有一个Nodes
具有 X、Y、Z 坐标的系统,能够进行大量几何计算(可能是一个点,几何对象的一部分),并且每个节点都是一个自动代理,能够与其他代理进行通信。
也许您已经可以访问两个库,每个库都有自己的名称空间(使用名称空间的另一个原因......但是您使用名称空间,不是吗?),一个是geo
,另一个是ai
所以你有你自己的own::Node
派生自ai::Agent
和geo::Point
。
这是您应该问自己是否不应该改用构图的时候。如果own::Node
真的真的是 aai::Agent
和 a geo::Point
,那么组合就不行了。
然后你需要多重继承,让你own::Node
根据他们在 3D 空间中的位置与其他代理进行通信。
(您会注意到ai::Agent
并且geo::Point
完全、完全、完全不相关......这大大降低了多重继承的危险)
其他情况(编辑 2017-05-03)
还有其他情况:
- 使用(希望是私有的)继承作为实现细节
- 一些像策略这样的 C++ 习惯用法可以使用多重继承(当每个部分都需要通过 与其他部分进行通信时
this
) - 来自 std::exception 的虚拟继承(异常需要虚拟继承吗?)
- 等等
有时你可以使用组合,有时 MI 更好。关键是:你有选择。负责任地做(并审查您的代码)。
5. 那么,我应该做多重继承吗?
大多数时候,根据我的经验,不会。MI 不是正确的工具,即使它看起来很有效,因为它可以被懒惰的人用来在没有意识到后果的情况下将功能堆在一起(比如Car
同时制作 anEngine
和 a Wheel
)。
但有时,是的。到那时,没有什么比 MI 更有效了。
但是因为 MI 很臭,准备好在代码审查中捍卫你的架构(捍卫它是一件好事,因为如果你不能捍卫它,那么你不应该这样做)。
人们非常正确地说你不需要多重继承,因为你可以用多重继承做任何事情,你也可以用单一继承做。您只需使用我提到的委托技巧。此外,您根本不需要任何继承,因为您对单继承所做的任何事情都可以通过类转发而无需继承。实际上,您也不需要任何类,因为您可以使用指针和数据结构来完成这一切。但是你为什么要这样做呢?什么时候方便使用语言设施?您什么时候更喜欢解决方法?我见过多重继承很有用的案例,我什至见过相当复杂的多重继承很有用的案例。通常,我更喜欢使用该语言提供的功能来解决问题
没有理由避免它,它在某些情况下非常有用。但是,您需要注意潜在的问题。
最大的是死亡钻石:
class GrandParent;
class Parent1 : public GrandParent;
class Parent2 : public GrandParent;
class Child : public Parent1, public Parent2;
您现在在 Child 中有两个 GrandParent 的“副本”。
C++ 已经想到了这一点,并允许您进行虚拟继承来解决这些问题。
class GrandParent;
class Parent1 : public virtual GrandParent;
class Parent2 : public virtual GrandParent;
class Child : public Parent1, public Parent2;
始终检查您的设计,确保您没有使用继承来节省数据重用。如果你可以用组合来表示相同的东西(通常你可以),这是一个更好的方法。
请参阅 w:多重继承。
多重继承受到了批评,因此并没有在许多语言中实现。批评包括:
- 复杂性增加
- 语义歧义常常被概括为菱形问题。
- 无法从单个类显式继承多次
- 继承顺序改变类语义。
具有 C++/Java 风格构造函数的语言中的多重继承加剧了构造函数和构造函数链的继承问题,从而在这些语言中产生了维护和可扩展性问题。在构造函数链范式下,具有极大不同构造方法的继承关系中的对象很难实现。
解决此问题的现代方法是使用 COM 和 Java 接口等接口(纯抽象类)。
我可以做其他事情来代替这个吗?
是的你可以。我要从GoF偷东西。
- 编程到接口,而不是实现
- 更喜欢组合而不是继承
公共继承是一种 IS-A 关系,有时一个类会是几个不同类的一个类型,有时反映这一点很重要。
“混合”有时也很有用。它们通常是小类,通常不继承任何东西,提供有用的功能。
只要继承层次结构相当浅(几乎总是应该如此)并且管理良好,您就不太可能获得可怕的菱形继承。菱形并不是所有使用多重继承的语言都存在的问题,但是 C++ 对它的处理常常很尴尬,有时甚至令人费解。
虽然我遇到过多重继承非常方便的情况,但它们实际上相当罕见。这可能是因为当我并不真正需要多重继承时,我更喜欢使用其他设计方法。我确实更喜欢避免混淆语言结构,并且很容易构建继承案例,您必须仔细阅读手册才能弄清楚发生了什么。
您不应该“避免”多重继承,但您应该注意可能出现的问题,例如“钻石问题”(http://en.wikipedia.org/wiki/Diamond_problem),并谨慎对待赋予您的权力,正如你应该拥有的所有权力。
你应该小心使用它,在某些情况下,比如钻石问题,事情会变得复杂。
(来源:learncpp.com)
冒着有点抽象的风险,我发现在范畴论的框架内思考继承是很有启发性的。
如果我们认为我们所有的类和它们之间的箭头表示继承关系,那么像这样
A --> B
表示class B
源自class A
。请注意,给定
A --> B, B --> C
我们说 C 派生自 B,B 派生自 A,因此 C 也称为派生自 A,因此
A --> C
此外,我们说对于从 派生的每个类A
,因此我们的继承模型满足类别的定义。在更传统的语言中,我们有一个类别,其中包含所有类和态射的继承关系。A
A
Class
这是一些设置,但让我们来看看我们的Diamond of Doom:
C --> D
^ ^
| |
A --> B
这是一个看起来很阴暗的图表,但它会做。所以D
继承自所有A
,B
和C
. 此外,为了更接近解决 OP 的问题,它D
也继承自A
. 我们可以画个图
C --> D --> R
^ ^
| |
A --> B
^
|
Q
C
现在,与死亡钻石相关的问题是何时B
共享一些属性/方法名称,并且事情变得模棱两可;但是,如果我们将任何共享行为移入A
其中,那么歧义就会消失。
用分类术语来说,我们希望A
,B
和C
使得 ifB
和C
inherit from Q
thenA
可以重写为 的子类Q
。这就产生A
了一种叫做pushout的东西。
还有一个对称的结构,D
称为回调。这本质上是您可以构造的最通用的有用类,它继承自B
和C
。也就是说,如果您有任何其他类R
乘以继承自B
and C
,那么D
是一个R
可以重写为 的子类的类D
。
确保您的钻石尖端是回调和推出,这为我们提供了一种很好的方式来处理可能出现的名称冲突或维护问题。
注意 Paercebal的回答激发了这一点,因为上述模型暗示了他的警告,因为我们在所有可能类的完整类别 Class 中工作。
我想将他的论点概括为一些东西,它表明复杂的多重继承关系既强大又没有问题。
TL;DR将程序中的继承关系视为一个类别。然后,您可以通过将多重继承的类推出和对称地制作一个共同的父类来避免Diamond of Doom 问题,这是一个回调。
We use Eiffel. We have excellent MI. No worries. No issues. Easily managed. There are times to NOT use MI. However, it useful more than people realize because they are: A) in a dangerous language that does not manage it well -OR- B) satisfied with how they've worked around MI for years and years -OR- C) other reasons (too numerous to list I am quite sure--see answers above).
对我们来说,使用 Eiffel,MI 和其他任何东西一样自然,是工具箱中的另一个好工具。坦率地说,我们并不担心没有其他人在使用 Eiffel。不用担心。我们对我们拥有的东西很满意,并邀请您来看看。
在您查看时:特别注意 Void 安全性和消除 Null 指针取消引用。当我们都在 MI 周围跳舞时,您的指针正在丢失!:-)
每种编程语言对面向对象编程的处理方式略有不同,各有优缺点。C++ 的版本将重点放在性能上,并有一个缺点,那就是编写无效代码非常容易令人不安——多重继承也是如此。因此,有一种趋势是引导程序员远离这个特性。
其他人已经解决了多重继承不适合的问题。但我们看到不少评论或多或少暗示避免它的原因是因为它不安全。嗯,是的,也不是。
正如在 C++ 中经常发生的那样,如果您遵循基本准则,您可以安全地使用它,而不必经常“回头看”。关键思想是区分一种特殊的类定义,称为“混入”;如果类的所有成员函数都是虚拟的(或纯虚拟的),则该类是一个混合。然后,您可以从单个主类和任意数量的“mix-ins”继承 - 但您应该使用关键字“virtual”继承 mixins。例如
class CounterMixin {
int count;
public:
CounterMixin() : count( 0 ) {}
virtual ~CounterMixin() {}
virtual void increment() { count += 1; }
virtual int getCount() { return count; }
};
class Foo : public Bar, virtual public CounterMixin { ..... };
我的建议是,如果您打算将一个类用作混合类,您还可以采用命名约定,以便任何阅读代码的人都可以轻松查看正在发生的事情并验证您是否按照基本指南的规则进行游戏. 如果你的 mix-ins 也有默认构造函数,你会发现它会更好,这只是因为虚拟基类的工作方式。并且记得让所有的析构函数也变成虚拟的。
请注意,我在这里使用的“混合”一词与参数化模板类不同(请参阅此链接以获得良好的解释),但我认为这是对术语的合理使用。
现在我不想给人的印象是这是安全使用多重继承的唯一方法。这只是一种相当容易检查的方法。
这篇文章很好地解释了继承,这是危险的。
除了菱形模式之外,多重继承往往使对象模型更难理解,这反过来又增加了维护成本。
作文本质上很容易理解、理解和解释。编写代码可能会很乏味,但是一个好的 IDE(我使用 Visual Studio 已经有几年了,但 Java IDE 肯定都有很好的组合快捷自动化工具)应该可以帮助你克服这个障碍。
此外,在维护方面,“钻石问题”也出现在非文字继承实例中。例如,如果你有 A 和 B 并且你的类 C 扩展了它们,并且 A 有一个 'makeJuice' 方法来制作橙汁,然后你扩展它来制作带有酸橙汁的橙汁:当 ' 的设计师B'添加了一个'makeJuice'方法来产生电流?“A”和“B”现在可能是兼容的“父母” ,但这并不意味着他们永远都是这样!
总体而言,倾向于避免继承,尤其是多重继承的准则是合理的。正如所有格言一样,有例外,但您需要确保有一个闪烁的绿色霓虹灯指示您编码的任何异常(并训练您的大脑,以便任何时候看到这样的继承树时,您都可以在自己闪烁的绿色霓虹灯中绘制签名),并且您每隔一段时间检查一下以确保一切都有意义。
具体对象的 MI 的关键问题是,您很少有一个合法地应该“成为 A 并且成为 B”的对象,因此从逻辑上讲它很少是正确的解决方案。更常见的是,您有一个对象 C 遵循“C 可以充当 A 或 B”,您可以通过接口继承和组合来实现。但是不要误会——多个接口的继承仍然是 MI,只是它的一个子集。
特别是对于 C++,该功能的主要弱点不是多重继承的实际存在,而是它允许的一些几乎总是格式错误的构造。例如,继承同一对象的多个副本,例如:
class B : public A, public A {};
is malformed BY DEFINITION. Translated into English this is "B is an A and an A". So, even in human language there's a severe ambiguity. Did you mean "B has 2 As" or just "B is an A"?. Allowing such pathological code, and worse making it a usage example, did C++ no favors when it came to making a case for keeping the feature in successor languages.
您可以优先使用组合而不是继承。
总体感觉是构图比较好,讨论的很好。
每个涉及的类需要 4/8 个字节。(每个类一个 this 指针)。
这可能永远不会成为问题,但如果有一天你有一个被实例化数十亿次的微数据结构,它将会是。