继承与聚合

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

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

  2. 聚合: 通过获取其他类并将它们组合成一个新类来创建新功能。将公共接口附加到这个新类,以便与其他代码进行互操作。

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

我看到这场辩论经常被提起,但我不认为有人问过这个问题 堆栈溢出(尽管有一些相关的讨论)。还有一个令人惊讶的缺乏良好的谷歌搜索结果。

112248 次浏览

差异通常表示为“ is a”和“ has a”之间的差异。继承,即“ is a”关系,在 Liskov代换原则中得到了很好的总结。聚合,即“ has a”关系,就是这样——它显示聚合对象 已经是聚合对象之一。

进一步的区别也同样存在—— C + + 中的私有继承表明了一种“根据”关系实现的关系,这种关系也可以通过聚合(未公开的)成员对象来建模。

GOF的开头他们说

对象组合优于类继承。

这是进一步讨论 给你

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

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

  • 如果新的类 多少作为原来的类。继承遗产。新类现在是原始类的一个子类。
  • 如果新类必须为 ,则为原始类。使用聚合。新类现在将原始类作为成员。

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

  • 如果我们已经使用了继承(或者我们计划使用它) ,但是我们只使用了接口的一部分,或者我们被迫覆盖许多功能来保持相关性的逻辑性。然后,我们有一个大的讨厌的气味,这表明,我们必须使用聚合。
  • 如果我们已经使用了聚合(或者我们计划使用它) ,但是我们发现我们需要复制几乎所有的功能。那么我们就有了一种指向遗传方向的气味。

为了缩短时间。如果接口的一部分没有被使用,或者为了避免不合逻辑的情况,必须对其进行更改,那么我们应该使用聚合。我们只需要使用继承,如果我们需要几乎所有的功能没有重大的变化。如果有疑问,使用聚合。

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

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

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

我们现在需要一个类“猫”,它需要“吃”、“走”、“咕噜”和“玩”。所以首先尝试从狗身上延伸它。

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;

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

我来说说这些可能适用的地方。这里有一个两者都有的例子,在一个游戏场景中。假设,有一个游戏,有不同类型的士兵。每个士兵可以有一个背包,可以装不同的东西。

这里有遗产吗? 有个海军陆战队员,绿色贝雷帽,还有个狙击手。他们是不同类型的士兵。所以,有一个基本类士兵与海军陆战队,绿色贝雷帽和狙击手作为派生类

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

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

有时候你必须聚合一个类,因为这个类是密封的,或者有其他非虚拟成员需要拦截,所以你创建了一个代理层,这个代理层在继承方面显然是无效的,但是只要你所代理的类有一个接口,你可以订阅这个接口,就可以很好地工作。

我认为这不是非此即彼的争论,只是:

  1. Is-a (继承)关系比 has-a (组合)关系出现得少。
  2. 继承是很难做到正确的,即使它适合使用,所以必须尽职调查,因为它可以打破封装,通过公开实现来鼓励紧密耦合等等。

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

当然,如果有一个类的话,那就没有意义了。形状的类有一个点和一个方形的类。继承权到期了。

在设计可扩展的东西时,人们倾向于首先考虑继承,这就是问题所在。

这个问题通常被称为 组合与继承,以前也有人问过这个问题。

以下是我最常见的观点:

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

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

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

OOP 的一个基本原则是,实现是 封装好了(即: 隐藏)在类中; 外人唯一应该看到的是接口。

当子类继承自子类时,它通常继承 都有实现和接口。反过来,这意味着您是 被迫的,可以接受这两个约束作为类的约束。

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

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

我给了 “是一个”和“有一个”: 哪一个更好?一个答案。

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

派生类拥有超类的所有方法合理吗?或者你只是默默地向自己保证这些方法应该在派生类中被忽略?或者您发现自己重写了来自超类的方法,使它们成为禁止操作的方法,这样就不会有人在不经意间调用它们了?或者给你的 API 文档生成工具提示,省略文档中的方法?

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

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

在我的经历中,我发现试图确定一段关系是“是”还是“有”是注定要失败的。即使您现在可以正确地确定对象,但是更改需求意味着您可能在将来的某个时候出错。

我发现的另一个问题是,一旦在继承层次结构中编写了大量代码,就很难将 非常从继承转换为聚合。仅仅从超类切换到接口就意味着要更改系统中的几乎每个子类。

而且,正如我在这篇文章的其他地方提到的,聚合往往没有继承那么灵活。

因此,每当你必须选择一个或另一个时,你就会有一大堆反对遗产继承的理由:

  1. 在某种程度上,你的选择可能是错误的
  2. 一旦你做出了选择,就很难改变了。
  3. 继承往往是一个更糟糕的选择,因为它更具约束性。

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

我想对最初的问题做一个评论,但是300个字符占了上风[ ; <)。

我觉得我们得小心点。首先,与问题中提出的两个相当具体的例子相比,有更多的风格。

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

例如,您想要完成什么,您有什么可以开始,以及有哪些约束?

您是否正在创建一个组件框架,甚至是一个特殊用途的框架?接口是否可以从编程系统的实现中分离出来,或者是否可以通过使用不同技术的实践来实现?您能够将接口的继承结构(如果有的话)与实现它们的类的继承结构分离开来吗?对于依赖于实现交付的接口的代码,隐藏实现的类结构是否重要?是否有多个实现可以同时使用,还是由于维护和增强的原因,变化会更加频繁?在专注于一个工具或方法之前,需要考虑到这一点以及更多。

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

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

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

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

简介: 组合: 通过将一些较小的对象插入到较大的对象中,可以减少耦合,较大的对象只是调用较小的对象。泛化: 从 API 的角度来看,定义方法可以被重写比定义方法可以被调用更有力。(很少有概括获胜的情况)。而且永远不要忘记,在组合中您也使用了继承,从接口而不是大类