继承与聚合


151

关于如何在面向对象的系统中最好地扩展,增强和重用代码,有两种思路:

  1. 继承:通过创建子类来扩展类的功能。覆盖子类中的超类成员以提供新功能。使方法抽象/虚拟化,以在超类需要特定接口但对其实现不可知时,强制子类“填入空白”。

  2. 聚合:通过采用其他类并将它们组合为新类来创建新功能。将公共接口附加到此新类,以与其他代码互操作。

两者的收益,成本和后果是什么?还有其他选择吗?

我看到这个辩论是定期进行的,但是我认为还没有在Stack Overflow上被要求(尽管有一些相关的讨论)。令人惊讶的是,谷歌也缺乏良好的搜索结果。


9
好问题,很遗憾,我现在没有足够的时间。
Toon Krijthe

4
一个好答案比一个更快的答案要好...我将看我自己的问题,所以我至少会投票给你:-P
Craig Walker

Answers:


181

最好的不是什么,而是什么时候使用什么的问题。

在“正常”情况下,一个简单的问题就足以确定我们是否需要继承或聚合。

  • 如果新的类或多或少原始类。使用继承。现在,新类是原始类的子类。
  • 如果新类必须具有原始类。使用聚合。现在,新班级已成为原始班级的成员。

但是,有一个很大的灰色区域。因此,我们还需要其他一些技巧。

  • 如果我们使用了继承(或计划使用它),但是我们仅使用部分接口,或者我们被迫重写许多功能以保持关联逻辑。然后,我们有一种很讨厌的气味,表明我们必须使用聚合。
  • 如果我们使用了聚合(或计划使用它),但发现需要复制几乎所有功能。然后,我们有一种指向继承方向的气味。

简而言之。如果不使用或必须更改部分接口以避免不合逻辑的情况,则应使用聚合。如果我们需要几乎所有功能而无需进行重大更改,则仅需要使用继承。如有疑问,请使用聚合。

在我们拥有一个需要原始类功能一部分的类的情况下,另一种可能性是将原始类分为一个根类和一个子类。并让新类从根类继承。但是您应该注意这一点,不要造成不合逻辑的分离。

让我们添加一个例子。我们有一个带有方法的“狗”类:“吃”,“走”,“树皮”,“玩”。

class Dog
  Eat;
  Walk;
  Bark;
  Play;
end;

现在,我们需要一个“猫”类,该类需要“吃”,“走”,“冲浪”和“玩耍”。因此,首先尝试从Dog扩展它。

class Cat is Dog
  Purr; 
end;

看起来不错,但请稍等。这只猫可以吠叫(爱猫的人会因此而杀死我)。吠猫违反了宇宙原理。因此,我们需要重写Bark方法,使其不执行任何操作。

class Cat is Dog
  Purr; 
  Bark = null;
end;

好的,这可以,但是闻起来很不好。因此,让我们尝试一个聚合:

class Cat
  has Dog;
  Eat = Dog.Eat;
  Walk = Dog.Walk;
  Play = Dog.Play;
  Purr;
end;

好的,这很好。这只猫不再吠叫,甚至没有沉默。但是它仍然有一只想要出来的内部狗。因此,让我们尝试解决方案三:

class Pet
  Eat;
  Walk;
  Play;
end;

class Dog is Pet
  Bark;
end;

class Cat is Pet
  Purr;
end;

这更干净。没有内部的狗。猫和狗处于同一水平。我们甚至可以引入其他宠物来扩展模型。除非是鱼,否则不会走路。在这种情况下,我们再次需要重构。但这是另一回事了。


5
“重用一个类的几乎所有功能”是我确实更喜欢继承的那一次。我真正想要的是一种能够轻松地说出“针对这些特定方法委托给此聚合对象的语言”;这是两全其美。
Craig Walker,

我不确定其他语言,但是Delphi有一种机制,可以让成员实现部分接口。
Toon Krijthe

2
目标C使用的协议可以让您确定功能是必需的还是可选的。
cantfindaname88

1
一个实际的例子很好的答案。我将使用一种称为的方法设计Pet类,makeSound并让每个子类实现自己的声音。然后,这将在您有很多宠物并且只做宠物的情况下有所帮助 for_each pet in the list of pets, pet.makeSound
blongho


27

该差异通常表示为“是”和“具有”之间的差异。Liskov替代原则很好地总结了“是一个”关系的继承。聚合(“具有”关系)就是这样-它表明聚合对象具有一个聚合对象。

还存在进一步的区别-C ++中的私有继承表示“根据”实现的关系,也可以通过(未公开的)成员对象的聚集来建模。


另外,您应该回答的问题也很好地总结了差异。我希望人们在开始翻译之前先阅读问题。你们会盲目地促进设计模式成为精英俱乐部的一员吗?
2013年

我不喜欢这种方法,更多的是关于构图的问题
Alireza Rahmani Khalili

15

这是我最常见的论点:

在任何面向对象的系统中,任何类都有两个部分:

  1. 它的界面:对象的“公开面孔”。这是它向世界其他地区宣布的功能。在许多语言中,集合被很好地定义为“类”。通常,这些是对象的方法签名,尽管它随语言而有所不同。

  2. 它的实现:对象为满足其界面并提供功能而进行的“幕后”工作。这通常是对象的代码和成员数据。

OOP的基本原理之一是将实现封装在类中(即隐藏)。局外人唯一应该看到的就是界面。

当一个子类继承自一个子类,它继承了典型的实现和接口。反过来,这意味着您被迫接受这两种限制。

通过聚合,您可以选择实现或接口,或两者都选择,但是您不必选择两者。对象的功能由对象本身决定。它可以随其喜欢而移交给其他对象,但最终由它自己负责。以我的经验,这会导致系统更灵活:更易于修改。

因此,每当我开发面向对象的软件时,我几乎总是更喜欢聚合而不是继承。


我认为您可以使用一个抽象类来定义一个接口,然后对每个具体的实现类使用直接继承(使其非常浅)。任何汇总都在具体实现中。
orcmid

如果您还需要其他接口,请通过主级抽象接口的接口上的方法将其返回,冲洗重复。
orcmid

您认为在这种情况下聚合更好吗?书是一个SellingItem,A DigitalDisc是SelliingItem codereview.stackexchange.com/questions/14077/...
LCJ

11

我对“是”与“有”给出了答案:哪个更好?

基本上,我同意其他人的看法:仅当派生类确实您要扩展的类型时才使用继承,而不仅仅是因为它包含相同的数据。请记住,继承意味着子类获得方法和数据。

对于您的派生类拥有超类的所有方法是否有意义?还是只是默默地向自己保证,在派生类中应该忽略这些方法?还是您发现自己是超类中的重载方法,使它们无操作,所以没有人会无意间调用它们?还是给您的API文档生成工具提示以从文档中省略该方法?

这些有力的线索表明,在这种情况下,聚合是更好的选择。


6

对于这个问题和相关问题,我看到很多“是-相对于-有-在概念上是不同的”回答。

我从经验中发现的一件事是,尝试确定关系是“是-是”还是“具有-是”注定会失败。即使您现在可以正确地确定对象,更改需求也意味着您将来可能会错了。

我发现另一件事是,这是非常难以从继承转换为聚集一旦周围出现继承层次写了很多的代码。仅仅从超类切换到接口就意味着更改系统中几乎每个子类。

而且,正如我在本文的其他地方提到的那样,聚合的灵活性往往不如继承。

因此,每当您必须选择一个或另一个时,您都会遇到反对继承的完美争论:

  1. 您的选择有时可能是错误的选择
  2. 做出选择后,很难改变选择。
  3. 继承往往是一个更糟糕的选择,因为它更具约束力。

因此,我倾向于选择聚合-即使看起来存在很强的is-a关系。


不断变化的需求意味着您的OO设计不可避免地会出错。继承与聚合只是冰山一角。您不能为所有可能的未来设计架构师,所以请遵循XP的想法:解决当今的需求并接受您可能需要重构。
Bill Karwin

我喜欢这种询问和质疑的方式。担心模糊是-a,has-a和uses-a。我从接口开始(以及确定我想要接口实现的抽象)。间接的附加级别是无价的。不要遵循您的汇总结论。
orcmid

这几乎是我的意思。继承和聚合都是解决问题的方法。当您明天必须解决问题时,其中一种方法会遭受各种处罚。
Craig Walker,

在我看来,聚合+接口是继承/子类化的替代方法;您不能仅基于聚合来实现多态。
Craig Walker,


3

我想对原始问题进行评论,但要咬300个字符[; <)。

我认为我们需要小心。首先,比问题中两个非常具体的例子有更多的风味。

另外,我建议不要将目标与工具混淆是很有价值的。人们想确保所选择的技术或方法论支持实现主要目标,但是我不认为最好的讨论是非常有用的。确实有助于了解不同方法的陷阱以及它们的明显优点。

例如,您要完成什么,可以从什么开始,以及有哪些限制?

您是否正在创建一个组件框架,甚至是一个专用框架?接口是与编程系统中的实现可分离的,还是通过使用另一种技术的实践来实现的?您可以将接口的继承结构(如果有)与实现它们的类的继承结构分开吗?从依赖于实现所提供的接口的代码中隐藏实现的类结构是否重要?是否存在多个可同时使用的实现方式,还是由于维护和增强功能而导致的变化会随着时间的推移而增加?在着眼于工具或方法论之前,需要考虑这一点和更多内容。

最后,将抽象中的区别以及您如何将其(面向is-a与has-a)锁定为OO技术的不同功能是否重要?如果这样可以使您和其他人保持概念结构的一致性和可管理性,那么也许可以。但是明智的是,不要因此而束缚自己,而最终可能会产生扭曲。也许最好退后一步,不要太僵硬(但要保持良好的叙述,以便其他人可以说出最新情况)。[我寻找使程序的特定部分可解释的原因,但是有时候,当赢得更大的胜利时,我会追求优雅。并非总是最好的主意。]

我是一个接口纯粹主义者,无论是构建Java框架还是组织一些COM实现,我都会被适合使用接口纯粹主义的各种问题和方法所吸引。即使我发誓,这也不适合所有事物,甚至不适合所有事物。(我有几个项目似乎提供了针对界面纯粹主义的严肃反例,因此,看看如何应对将很有趣。)


2

我将介绍可能在哪里应用的部分。这是在游戏场景中两者的示例。假设有一个游戏包含不同类型的士兵。每个士兵可以有一个可以容纳不同东西的背包。

在这里继承? 有一个海洋绿色贝雷帽和狙击手。这些是士兵的类型。因此,有一个基础类的士兵,海军陆战队,绿色贝雷帽和狙击手是派生类

聚集在这里? 背包可以包含手榴弹,枪支(不同类型的),刀,medikit等。士兵可以在任何给定的时间点配备任何一种,此外,他还可以拥有防弹背心,在受到攻击时可以充当装甲,伤害降低到一定百分比。士兵类包含防弹背心类和背包类的对象,其中包含对这些物品的引用。


2

我认为这不是一个非此即彼的辩论。就是这样:

  1. is-a(继承)关系的发生频率比has-a(组成)关系少。
  2. 即使适当使用继承,也很难做到正确,因此必须进行尽职调查,因为它可能破坏封装,通过公开实现鼓励紧密耦合等等。

两者都有自己的位置,但继承风险更大。

尽管当然有一个类Shape'having-a Point'和Square类是没有意义的。这里继承是到期的。

人们在尝试设计可扩展的东西时往往会首先考虑继承,这是错误的。


1

当两个候选人都符合条件时,就会发生青睐。A和B是选项,您偏爱A。原因是合成比泛化提供了更多的扩展/灵活性可能性。这种扩展/灵活性主要是指运行时/动态灵活性。

好处不是立即可见的。要查看好处,您需要等待下一个意外更改请求。因此,与那些接受合成的人相比,大多数坚持通用化的人都失败了(后面提到的一个明显的情况除外)。因此,规则。从学习的角度来看,如果您可以成功实现依赖项注入,那么您应该知道哪一个以及何时支持。该规则也有助于您做出决定;如果不确定,则选择合成。

摘要:组成:通过将一些较小的东西插入更大的物体中,可以减少耦合,而较大的对象只是将较小的对象回调。泛化:从API的角度来看,定义一个方法可以被覆盖比定义一个可以调用的方法更有力。(在“通用化”获胜时很少出现这种情况)。而且永远不要忘记,通过组合,您也使用了接口,而不是大型类的继承


0

两种方法都用于解决不同的问题。从一个类继承时,您不必总是聚合两个或多个类。

有时您必须聚合一个类,因为该类是密封的,或者需要拦截其他非虚拟成员,因此您创建的代理层显然在继承方面无效,但只要您要代理的类有一个可以订阅的界面,可以很好地解决这个问题。

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.