153

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

  1. 继承:通过创建子类来扩展类的功能。覆盖子类中的超类成员以提供新功能。当超类想要一个特定的接口但不知道它的实现时,使方法抽象/虚拟以强制子类“填充空白”。

  2. 聚合:通过获取其他类并将它们组合成一个新类来创建新功能。为这个新类附加一个通用接口,以实现与其他代码的互操作性。

每种方法的好处、成本和后果是什么?还有其他选择吗?

我看到这个辩论经常出现,但我认为 Stack Overflow 上还没有被问到(尽管有一些相关的讨论)。令人惊讶的是,它缺乏良好的谷歌搜索结果。

4

12 回答 12

194

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

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

  • 如果新类或多或少与原类相同。使用继承。新类现在是原始类的子类。
  • 如果新类必须原类。使用聚合。新类现在具有原始类作为成员。

然而,有一个很大的灰色地带。所以我们需要几个其他的技巧。

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

把它剪短。如果部分接口未使用或必须更改以避免不合逻辑的情况,我们应该使用聚合。我们只需要使用继承,如果我们需要几乎所有的功能而没有大的变化。如有疑问,请使用聚合。

另一种可能性是,如果我们有一个类需要原始类的部分功能,则将原始类拆分为根类和子类。并让新类继承根类。但是你应该注意这一点,不要造成不合逻辑的分离。

让我们添加一个示例。我们有一个类 'Dog' 的方法:'Eat'、'Walk'、'Bark'、'Play'。

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

我们现在需要一个“Cat”类,它需要“Eat”、“Walk”、“Purr”和“Play”。所以首先尝试从 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;

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

于 2008-11-06T17:28:16.003 回答
40

GOF开始时,他们声明

优先考虑对象组合而不是类继承。

这将在此处进一步讨论

于 2008-11-06T17:28:00.573 回答
30

差异通常表示为“是一个”和“有一个”之间的差异。继承,即“是”关系,在Liskov Substitution Principle中得到了很好的总结。聚合,“具有”关系,就是这样 - 它表明聚合对象具有聚合对象之一。

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

于 2008-11-06T17:25:52.840 回答
15

这是我最常见的论点:

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

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

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

OOP 的基本原则之一是实现被封装(即:隐藏)在类中;外人唯一应该看到的是界面。

当子类从子类继承时,它通常同时继承实现和接口。反过来,这意味着你被迫接受这两者作为你班级的约束。

使用聚合,您可以选择实现或接口,或两者兼而有之——但您不会被迫选择其中任何一个。对象的功能由对象本身决定。它可以随心所欲地服从其他对象,但最终要对自己负责。以我的经验,这导致了一个更灵活的系统:一个更容易修改的系统。

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

于 2008-11-06T17:33:23.903 回答
12

我对“Is a”和“Has a”给出了答案:哪个更好?.

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

派生类拥有超类的所有方法是否有意义?还是你只是悄悄地向自己保证在派生类中应该忽略这些方法?或者您是否发现自己覆盖了超类中的方法,使它们成为无操作的,因此没有人无意中调用它们?或者提示您的 API 文档生成工具以省略文档中的方法?

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

于 2008-11-06T17:48:19.753 回答
6

我看到很多关于这个和相关问题的“is-a vs. has-a;它们在概念上是不同的”回答。

根据我的经验,我发现的一件事是,试图确定一段关系是“is-a”还是“has-a”注定会失败。即使您现在可以正确地确定对象,但不断变化的需求意味着您在未来的某个时候可能会出错。

我发现的另一件事是,一旦围绕继承层次结构编写了大量代码,就很难从继承转换为聚合。仅仅从超类切换到接口就意味着改变系统中的几乎每个子类。

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

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

  1. 在某些时候,您的选择可能是错误的
  2. 一旦你做出改变这个选择是很困难的。
  3. 继承往往是一个更糟糕的选择,因为它更具约束性。

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

于 2008-11-06T18:12:07.083 回答
3

这个问题通常被表述为Composition vs. Inheritance,并且之前已经在这里提出过。

于 2008-11-06T17:31:50.863 回答
3

我想对原始问题发表评论,但有 300 个字符咬 [;<)。

我认为我们需要小心。首先,除了问题中提出的两个相当具体的例子之外,还有更多的口味。

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

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

您是否正在创建一个组件框架,甚至是一个特殊用途的框架?接口是否可以与编程系统中的实现分开,还是通过使用不同类型技术的实践来完成?你能把接口的继承结构(如果有的话)和实现它们的类的继承结构分开吗?从依赖于实现提供的接口的代码中隐藏实现的类结构是否重要?是否有多个实现可以同时使用,或者由于维护和增强,随着时间的推移变化更多?在您专注于工具或方法之前,需要考虑这一点以及更多。

最后,将抽象中的区别以及您如何看待它(如 is-a 与 has-a)锁定到 OO 技术的不同特性是否很重要?也许是这样,如果它使您和其他人的概念结构保持一致且易于管理。但明智的做法是不要被它和你最终可能做出的扭曲所奴役。也许最好退后一步,不要那么死板(但要留下好的叙述,以便其他人知道发生了什么)。[我寻找使程序的特定部分可解释的原因,但有时当有更大的胜利时,我会追求优雅。并不总是最好的主意。]

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

于 2008-11-06T19:24:29.980 回答
2

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

继承在这里? 有海军陆战队、绿色贝雷帽和狙击手。这些是士兵的类型。因此,有一个基类 Soldier,其中 Marine、Green Beret 和 Sniper 作为派生类

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

于 2008-11-06T17:28:19.763 回答
2

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

  1. is-a(继承)关系的发生频率低于 has-a(组合)关系。
  2. 继承更难正确,即使它适合使用它,所以必须尽职尽责,因为它会破坏封装,通过暴露实现等来鼓励紧密耦合。

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

当然,拥有一个 Shape 'have-a' Point 类和一个 Square 类是没有意义的。继承权到此为止。

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

于 2008-11-06T17:31:42.033 回答
1

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

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

总结:组合:只需将一些较小的东西插入更大的东西,就可以减少耦合,而较大的对象只会调用较小的对象。泛化:从 API 的角度来看,定义一个方法可以被覆盖是比定义一个方法可以被调用更强有力的承诺。(泛化获胜的情况很少)。并且永远不要忘记,通过组合,您也使用继承,从接口而不是大类

于 2011-09-20T13:28:23.627 回答
0

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

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

于 2008-11-06T17:28:38.653 回答