“偏爱组成而不是继承”的概念从何而来?


143

在过去的几个月中,“偏爱继承而不是继承”的口号似乎无处不在,并成为编程社区中的某种模因。每次看到它,我都会有些迷惑。就像有人在说“青睐锤子”。以我的经验,组合和继承是具有不同用例的两个不同工具,并且将它们视为可互换,并且一个在本质上优于另一个是没有意义的。

另外,我从来没有看到过真正的解释,说明为什么继承不好而组成很好,这让我更加怀疑。是否应该只凭信心接受?Liskov替换和多态性具有众所周知的明显好处,而IMO构成了使用面向对象编程的全部要点,而且从来没有人解释为什么应该丢弃它们以利于组合。

有谁知道这个概念的来历,其背后的原理是什么?


45
正如其他人指出的那样,已经存在了很长时间-我很惊讶您现在才听到它。对于任何时间使用Java之类的语言构建大型系统的人来说,它都是直观的。这是我进行的任何面试的核心,当候选人开始谈论继承时,我开始怀疑他们的技能水平和经验量。这里有一个很好的介绍,为什么继承是一个脆弱的解决方案(有很多很多其他人):artima.com/lejava/articles/designprinciples4.html
Janx

6
@Janx:也许就是这样。我不会使用Java之类的语言来构建大型系统。我在Delphi中构建它们,没有Liskov替代和多态性,我们将一事无成。它的对象模型在某些方面与Java或C ++有所不同,在Delphi中,这个准则似乎要解决的许多问题实际上并不存在,或者没有那么多问题。我想从不同的角度有不同的看法。
梅森惠勒

14
我花了几年时间在一个团队中用Delphi构建相对较大的系统,而高大的继承树肯定会给我们的团队带来痛苦,并给我们造成了极大的痛苦。我怀疑您对SOLID原则的关注正在帮助您避免出现问题区域,而不是避免使用Delphi。
Bevan

8
最近几个月?!?
Jas,

6
恕我直言,这个概念从未完全适应支持接口继承(即,使用纯接口进行子类型化)和实现继承的多种语言。太多的人遵循此原则,并且没有使用足够的界面。
Uri

Answers:


136

尽管我认为我早在GoF之前就已经听说过关于合成vs继承的讨论,但是我不能指责特定的来源。无论如何,可能是Booch。

<rant>

啊,但是像许多咒语一样,这一咒语已经按照典型的方式退化了:

  1. 备受尊敬的消息来源对它进行了详细的解释和论证,他将流行语创造出来,以提醒人们最初的复杂讨论。
  2. 通常,在评论n00b错误时,会在一段时间内与一些会知的人共享一个已知的俱乐部眨眼信息
  3. 很快,成千上万的人不加思索地重复了一次,他们从未读过解释,但是喜欢以它为借口不去思考,并且以一种便宜又容易的方式感觉到超越他人
  4. 最终,没有任何合理的揭穿可以阻止“模因”浪潮-范式退化为宗教和教条。

该模因原本旨在使n00bs启蒙,但现在被用作俱乐部,以使他们失去知觉。

组合和继承是完全不同的事物,不应相互混淆。确实可以通过很多额外的工作来使用合成来模拟继承,但这不会使继承成为二等公民,也不会使合成成为喜欢的儿子。许多n00b尝试将继承用作快捷方式这一事实不会使该机制无效,并且几乎所有n00b都会从错误中学习并因此得到改善。

想一想你的设计,并停止喷出口号。

</ rant>


16
Booch认为实现继承会在类之间引入高度耦合。另一方面,他认为继承是将OOP与过程编程区分开的关键概念。
Nemanja Trifunovic

13
这种事情应该有一个词。早产吗?
约尔格W¯¯米塔格

8
@Jörg:你知道像早泄这样的词会怎样?上面已经解释了。:)(顺便说一下,什么
时候不早产

6
@Nemanja:对。问题是这种耦合是否真的不好。如果这些类在概念上是强耦合的,并且在概念上形成了超类型-子类型的关系,并且即使在语言级别上进行了正式的解耦,也无法在概念上解耦,那么强耦合就可以了。
dsimcha 2011年

5
口头禅很简单,“只是因为您可以将play-doh塑造成看起来并不像什么,但这并不意味着您应该从play-doh中做出一切。” ;)
Evan Plaice 2012年

73

经验。

就像您说的那样,它们是用于不同工作的工具,但之所以出现这个短语,是因为人们没有以这种方式使用它。

继承主要是一种多态工具,但是有些人在以后的危险中会尝试使用它作为重用/共享代码的方式。基本原理是“如果我继承,那么我将免费获得所有方法”,但忽略了以下事实:这两个类可能没有多态关系。

那么为什么偏重于继承而不是继承-恰恰是因为类之间的关系通常不是多态的。它的存在只是为了帮助提醒人们不要通过继承来屈膝回应。


7
因此,基本上,您不能说“如果您不了解Liskov替换,就不应该首先使用OOP”,因此您要说“倾向于继承而不是继承”,以此来限制因能力不足而造成的损害。编码员?
梅森惠勒

13
@梅森:像任何口头禅一样,“偏爱组成而不是继承”是针对初学者的。如果您已经知道何时使用继承以及何时使用合成,那么重复这样的咒语毫无意义。
Dean Harding

7
@Dean-我不确定初学者是否与不了解继承最佳实践的人相同。我认为还有更多。继承不佳是我工作中很多头疼的原因,而不是程序员的代码,他们被认为是“初学者”。
妮可

6
@Renesis:听到的第一件事是,“有些人有十年的工作经验,有些人有十年的经验,重复了十次。”
梅森惠勒

8
在第一个“获取” OO之后,我就立即设计了一个相当大的项目,并大量依赖它。尽管效果很好,但我不断发现设计很脆弱-到处或到处造成轻微的刺激,有时还会使某些重构成为一个bit子。很难解释整个体验,但是短语“继承时的偏爱”准确地描述了它。请注意,它没有说甚至暗示“避免继承”,只是在选择不明显时稍微推了一下。
Bill K

43

这不是一个新主意,我相信它实际上是在1994年出版的GoF设计模式书中引入的。

继承的主要问题是它是白盒。根据定义,您需要知道要继承的类的实现细节。另一方面,对于组合,您只在乎要编写的类的公共接口。

从GoF书中:

继承向子类公开其父级实现的详细信息,通常说“继承破坏封装”

GoF书中的维基百科文章有不错的介绍。


14
我不同意。您不需要知道要继承的类的实现细节;只有在课堂上公开的公众受保护成员。如果您必须了解实现细节,那么您或编写基类的任何人都在做错事,并且如果该缺陷存在于基类中,那么组合将无法帮助您解决/解决它。
梅森惠勒

18
您如何不同意尚未阅读的内容?GoF上有一个坚实的页面和一半的讨论,您对此只是一丁点的看法。
philosodad 2011年

7
@pholosodad:我不是不同意我没有读过的东西。我不同意Dean所写的内容,即“根据定义,您需要了解您要从中继承的类的实现细节”,我已经阅读过。
梅森惠勒

10
我写的只是GoF书中所描述内容的摘要。我可能措辞有点过强(您不需要了解所有实现细节),但这就是GoF表示赞成组合而不是继承的一般原因。
Dean Harding

2
如果我错了,请纠正我,但我认为这是“在隐式(即继承)上主张显式类关系(即接口)”。前者告诉您您需要什么,却不告诉您如何去做。后者不仅告诉您如何做,而且会让您后悔。
Evan Plaice 2012年

26

为了回答您的部分问题,我相信这个概念首先出现在1994年首次出版的《 GOF 设计模式:可重用的面向对象软件的元素》一书中。该短语出现在第20页的顶部:

优先考虑对象组成而不是继承

他们在此声明的开头对继承与构成作了简要比较。他们没有说“从不使用继承”。


23

“在继承上构成”是一种简短的(显然是误导性的)说法,“当感觉到某个类的数据(或行为)应被合并到另一个类中时,请务必在盲目应用继承之前考虑使用复合”。

为什么会这样呢?因为继承会在两个类之间创建紧密的编译时耦合。相反,组合是松散耦合,其中包括使关注点清晰分离,在运行时切换依赖项的可能性以及更容易,更独立的依赖项可测试性。

这仅意味着继承应谨慎处理,因为它要付出一定的代价,而不是没有用。实际上,“组成继承”通常最终是“组成+继承继承”,因为您通常希望组合的依赖关系是抽象的超类而不是具体的子类本身。它允许您在运行时在依赖项的不同具体实现之间进行切换。

出于这个原因(除其他外),您可能会看到继承比普通继承更经常以接口实现或抽象类的形式使用。

一个(比喻)示例可以是:

“我有一个Snake类,我想将Snake叮咬时发生的事情作为该类的一部分。我很想让Snake继承一个具有Bite()方法的BiterAnimal类,并重写该方法以反映毒蛇咬伤但是,继承之上的合成警告我应该改为使用合成...就我而言,这可能会转化为具有Bite成员的Snake。Bite类可以是抽象的(或接口),具有多个子类。我有一些不错的事情,例如拥有VenomousBite和DryBite子类,并且能够随着蛇的年龄增长而在同一Snake实例上更改叮咬,另外在单独的类中处理Bite的所有影响,可以使我在该Frost类中重用它,因为冻伤但不是BiterAnimal,依此类推...”


4
蛇很好的例子。我最近在自己的课堂上遇到了类似的案例
Maksee'4

22

一些可能的组合参数:

组合稍微与语言/框架无关,
并且继承/强制/要求/启用在语言之间会有所不同,就子/超类可访问的内容以及它可能对虚拟方法等产生的性能影响而言。组合是非常基本的并且需要很少的语言支持,因此跨不同平台/框架的实现可以更轻松地共享组成模式。

合成是一种非常简单且触觉好的建筑对象方式
继承相对容易理解,但在现实生活中仍然不那么容易证明。现实生活中的许多对象都可以分解为多个部分并组成。假设可以使用两个轮子,框架,座椅,链条等来制造自行车。在继承隐喻中,您可以说自行车延伸了单轮脚踏车,虽然可行,但与实际构图相比还远不及构图(显然,这不是一个很好的继承示例,但要点仍然相同)。甚至“继承”一词(至少我希望大多数美国英语使用者会想到)也会自动按照“已故亲戚传来的东西”的方式来调用含义,该含义与其在软件中的含义相关联,但仍然不太合适。

合成几乎总是更灵活
使用合成,您始终可以选择定义自己的行为,或简单地暴露合成部分中的该部分。这样,您就不会面临继承层次结构可能施加的任何限制(虚拟与非虚拟等)。

因此,可能是因为“组合”自然是一个更简单的隐喻,其理论约束比继承要少。此外,这些特殊原因在设计时可能更明显,或者在处理继承的某些痛点时可能会突出。

免责声明:
显然它不是这个明确/单向的街道。每个设计都应评估几种模式/工具。继承被广泛使用,具有很多好处,而且很多时候比合成更优雅。这些只是人们偏爱构图时可能会使用的一些可能原因。


1
“显然,这不是一条清晰的路/一条路。” 什么时候组成不比继承灵活(或同等)?我会说那一条单向街。继承只是特殊情况下的语法糖。
weberc2

当使用通过显式实现的接口实现合成的语言时,合成通常灵活,因为不允许这些接口随着时间的过去以向后兼容的方式发展。#jmtcw
MikeSchinkel

11

也许您刚刚注意到人们在最近几个月中一直在说这句话,但是对于优秀的程序员而言,知道它的时间要长得多。我肯定已经说了大约十年了。

这个概念的重点是继承有很大的概念开销。当您使用继承时,则每个单个方法调用中都隐含有一个调度。如果您有深层继承树,或者有多个调度,或者两者都有(甚至更糟),那么弄清楚特定方法在任何特定调用中将调度到的位置都可以成为皇家PITA。这使得对代码的正确推理更加复杂,并且使调试更加困难。

让我举一个简单的例子来说明。假设在继承树的深处,有人命名为method foo。然后其他人出现并添加foo到树的顶部,但是做了一些不同的事情。(这种情况在多重继承中更为常见。)现在,在根类中工作的人破坏了晦涩的子类,并且可能没有意识到。您可能具有100%的单元测试覆盖率,而不会注意到这种破坏,因为最顶层的人不会考虑测试子类,而针对子类的测试不会考虑对顶层创建的新方法进行测试。(诚​​然,有一些方法可以编写单元测试来解决此问题,但是在某些情况下,您不能轻易地以这种方式编写测试。)

相比之下,当您使用合成时,通常在每次呼叫时都将呼叫分配到的对象更加清楚。(好吧,如果您正在使用控制反转,例如通过依赖项注入,那么弄清楚调用的位置也可能会遇到问题。但是通常更容易弄清楚。)这使得推理起来更加容易。作为奖励,组成会导致方法彼此分离。上面的示例不应该在那里发生,因为子类将移至一些晦涩的组件,并且从来没有问题要调用foo的对象是晦涩的组件还是主对象。

现在您绝对正确,继承和组合是服务两种不同类型事物的两个非常不同的工具。当然,继承会带来概念上的开销,但是,当继承是工作的正确工具时,继承所带来的概念上的开销要小于不使用继承并手动执行为您带来的工作。没有人知道他们在做什么,不会说您永远不要使用继承。但是请确保这是正确的做法。

不幸的是,许多开发人员学习了面向对象的软件,了解了继承,然后出去尽可能多地使用他们的新斧头。这意味着他们在组合是正确的工具的情况下尝试使用继承。希望他们会及时学习,但是经常不会发生这种情况,除非肢体移开一些,等等。事先告诉他们这是一个坏主意,这会加快学习过程并减少伤害。


3
好吧,我想从C ++的角度来看是有意义的。这是我从未想到的,因为在Delphi中这不是问题,这是我大部分时间使用的问题。(没有多重继承,并且如果您在基类中有一个方法,而在派生类中有一个同名的方法又没有覆盖该基方法,则编译器会发出警告,因此您不会意外结束这类问题。)
Mason Wheeler

1
@Mason:在脆弱的基类继承问题上,Ander的Object Pascal(又名Delphi)版本优于C ++和Java。与C ++和Java不同,继承的虚拟方法的重载不是隐式的。
bit-twiddler 2011年

2
@ bit-twiddler,关于C ++和Java的内容可以说成是Smalltalk,Perl,Python,Ruby,JavaScript,Common Lisp,Objective-C以及我所学到的其他任何可提供任何形式的OO支持的语言。就是说,谷歌搜索表明C#遵循Object Pascal的领导。
btilly 2011年

3
这是因为Anders Hejlsberg设计了Borland的Object Pascal(又名Delphi)和C#风格。
bit-twiddler 2011年

8

这是对OO初学者在不需要时倾向于使用继承的观察结果的回应。继承当然不是一件坏事,但是它可能会被滥用。如果一个类只需要另一个类的功能,那么组合可能会起作用。在其他情况下,继承将起作用,而合成将无效。

从类继承意味着很多事情。这意味着派生类型是底数的一种(有关血腥细节,请参见Liskov替换原理),因为每当您使用基数时,都应该使用“派生”。它使派生访问Base的受保护成员和成员功能。这是一个紧密的关系,这意味着它具有很高的耦合度,对一个进行更改很可能需要对另一个进行更改。

耦合是一件坏事。它使程序更难以理解和修改。在其他条件相同的情况下,应始终选择耦合较少的选项。

因此,如果合成或继承都能有效地完成工作,请选择合成,因为它的耦合度较低。如果合成不能有效地完成工作,而继承会有效,则选择继承,因为您必须这样做。


“在其他情况下,继承将起作用,而组成将不起作用。” 什么时候?可以使用合成和接口多态性对继承进行建模,因此如果在任何情况下都无法使用合成,我会感到惊讶。
weberc2

8

这是我的两分钱(超出已经提出的所有优点):

恕我直言,这归结为这样一个事实,即大多数程序员实际上并没有真正获得继承,而最终却过度继承了,部分原因是该概念的教授方法。这个概念的存在是试图阻止他们过度使用它的一种方式,而不是专注于教他们如何正确地做。

任何花了很多时间进行教学或指导的人都知道这是经常发生的事情,特别是对于具有其他范例经验的新开发人员而言:

这些开发人员最初认为继承是一个令人恐惧的概念。因此,它们最终会创建具有接口重叠(例如,没有通用子类型的相同指定行为)以及用于实现通用功能的全局变量的类型。

然后(通常是由于过度热心的教学),他们了解了继承的好处,但通常将其作为重用的万能解决方案进行教授。他们最终以为任何共享行为都必须通过继承来共享。这是因为重点通常是实现继承而不是子类型化。

在80%的情况下就足够了。但是另外20%是问题开始的地方。为了避免重写并确保他们利用了共享实现的优势,他们开始围绕预期的实现而不是底层抽象设计其层次结构。“堆栈从双向链接列表继承”就是一个很好的例子。

大多数老师在这一点上并不坚持引入接口的概念,因为它是另一个概念,或者因为(在C ++中)您必须使用抽象类和多重继承来伪造它们,而这在现阶段还没有讲授。在Java中,许多老师没有从接口的重要性中区分出“没有多重继承”或“多重继承是邪恶的”。

所有这些都因以下事实而变得更加复杂:我们已经学会了不必编写带有实现继承的多余代码的种种美感,以至于大量直接的委托代码看起来是不自然的。因此,组合看起来很混乱,这就是为什么我们需要这些经验法则来促使新程序员无论如何都要使用它们(它们也会过分使用)。


那就是教育的真正组成部分。您必须修读更高的课程才能获得剩下的20%,但是作为学生,您只是被教过基础课程,甚至是中级课程。最后,您会觉得自己受过良好的教育,因为您的课程做得很好(只是看不到它只是阶梯上的一针而已)。这只是现实的一部分,我们最好的选择是了解它的副作用,而不是攻击编码人员。
2012年

8

梅森在其中一项评论中提到,有一天我们将谈论被认为有害的继承

希望如此。

继承问题既简单又致命,它不尊重一个概念应该具有一种功能的想法。

在大多数OO语言中,从基类继承时,您:

  • 从其接口继承
  • 从其实现继承(数据和方法)

麻烦就来了。

这是不是唯一的方法,虽然00国语言大多用它卡住了。幸运的是,这些接口中存在接口/抽象类。

此外,缺乏便利性会导致使其大量使用:坦率地说,即使知道这一点,您是否也将从接口继承并按组成嵌入基类,从而委托大多数方法调用?但是,这样做会好得多,如果在界面中突然弹出一种新方法,并且不得不自觉选择如何实现它,甚至会被警告。

相对而言,Haskell仅当从纯接口(称为概念)“派生”时才允许使用Liskov原理(1)。您不能从现有的类派生,只有合成允许您嵌入其数据。

(1)概念可能为实现提供合理的默认值,但是由于它们没有数据,因此只能根据该概念提出的其他方法或常量来定义此默认值。


7

简单的答案是:继承比组合具有更大的耦合。给定两个质量相同的选项,请选择耦合性较小的一个。


2
但这就是重点。它们不是 “其他等效项”。继承可实现Liskov替换和多态,这是使用OOP的全部要点。作文没有。
梅森惠勒

4
多态性/ LSP不是OOP的“重点”,它们是功能之一。还有封装,抽象等。继承表示“是”关系,聚合模型表示“有”关系。
史蒂夫

@Steve:您可以在支持创建数据结构和过程/函数的任何范式中进行抽象和封装。但是多态性(在这种情况下,具体指的是虚拟方法分派)和Liskov替换对于面向对象的编程是唯一的。那就是我的意思。
梅森惠勒

3
@梅森:指导方针是“偏向于继承而不是继承”。这绝不意味着不使用甚至避免继承。这说明所有其他条件都相同时,选择组成。但是,如果您需要继承,请使用它!
杰弗里·福斯特

7

我认为这种建议就像说“宁愿驾驶而不愿飞行”。就是说,飞机比汽车具有各种优势,但这带来了一定的复杂性。因此,如果许多人尝试从市中心飞往郊区,他们真正需要听到的建议就是他们不需要飞行,而且从长远来看,飞行只会使事情变得更加复杂,即使在短期内看起来很酷/高效/容易。而当您确实需要飞行时,通常应该很明显。

同样,继承可以完成合成,但是您应该在需要时使用它,而不是在不需要时使用。因此,如果您从未尝试过仅在不需要时就假定需要继承,那么就不需要“更喜欢合成”的建议。但是很多人这样做,并且确实需要这些建议。

应该隐含的是,当您确实确实需要继承时,这很明显,那么您应该使用它。

另外,史蒂文·洛(Steven Lowe)的答案。真的,真的。


1
我喜欢您的类比
barrylloyd 2011年

2

继承并不是天生的坏,组成也不是天生的好。它们仅仅是OO程序员可以用来设计软件的工具。

当您看一堂课时,它做的事情是否超出其绝对应做的范围(SRP)?它是否不必要地复制了功能(DRY),还是对其他类的属性或方法过于感兴趣(Feature Envy)?如果阶级违反了所有这些概念(甚至更多),那么它是否试图成为上帝阶级。这些是在设计软件时可能发生的许多问题,这些问题都不一定是继承问题,但是在使用多态性的地方,它可能很快造成严重的麻烦和脆弱的依赖关系。

问题的根源可能是缺乏对继承的了解,或者更多是要么在设计方面的选择不当,要么是没有意识到与不遵循单一职责原则的类有关的“代码味道” 。 多态性Liskov取代不必为了合成而被丢弃。多态性本身可以在不依赖继承的情况下应用,这些都是相当互补的概念。如果周到地应用。诀窍是为了使您的代码简单,整洁,并且不屈服于过于担心为了创建可靠的系统设计而需要创建的类的数量。

就偏重于继承而不是继承的问题而言,这实际上只是精心设计应用设计元素的另一种情况,这些设计元素对于要解决的问题最有意义。如果您不需要继承行为,那么您可能不应该这样做,因为合成将有助于避免不兼容和以后的重大重构工作。另一方面,如果您发现您正在重复很多代码,使得所有重复都集中在一组相似的类上,则可能是创建一个共同的祖先将有助于减少相同的调用和类的数量。您可能需要在每个班级之间重复。因此,您赞成组合,但是您并没有假设继承永远都不适用。


-1

我相信,实际的报价来自Joshua Bloch的“ Effective Java”,其中是各章之一的标题。他声称继承在包中是安全的,无论是在为扩展专门设计和记录类的任何地方。正如其他人指出的那样,他声称继承会破坏封装,因为子类取决于其超类的实现细节。他说,这导致了脆弱性。

尽管他没有提及,但在我看来Java的单一继承意味着组合比继承给您更大的灵活性。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.