题 喜欢构成而不是继承?


为什么更喜欢构图而不是继承?每种方法都有哪些权衡取舍?什么时候应该选择继承而不是作文?


1349
2017-09-08 01:58


起源


也可以看看 哪种类设计更好 - maccullt
有一篇关于这个问题的好文章 这里。我个人认为,设计没有“更好”或“更糟”的原则。具体任务有“适当”和“不充分”的设计。换句话说 - 我根据情况使用继承或组合。目标是生成更小的代码,更易于阅读,重用并最终进一步扩展。 - m_pGladiator
在一个句子中,如果你有一个公共方法,继承是公共的,你改变它就会改变已发布的api。如果你有合成并且组成的对象已经改变,你不必更改已发布的api。 - Tomer Ben David
在维基百科上看到 继承的构成。 - DavidRR


答案:


首选组合而不是继承,因为它更具延展性/易于修改,但不使用compose-always方法。 通过组合,可以使用依赖注入/设置器轻松更改行为。继承更加严格,因为大多数语言都不允许您从多个类型派生。因此,一旦你从TypeA派生,鹅就会或多或少地被煮熟。

我对上述的酸测试是:

  • TypeB是否希望公开TypeA的完整接口(所有公共方法都不少),以便在需要TypeA的地方使用TypeB?指示 遗产

例如如果不是更多,塞斯纳双翼飞机将暴露飞机的完整界面。因此,它适合从飞机派生。

  • TypeB是否只需要TypeA公开的部分/部分行为?表示需要 组成。 

例如鸟可能只需要飞机的飞行行为。在这种情况下,将它作为接口/类/两者提取出来并将其作为两个类的成员是有意义的。

更新: 刚回到我的答案,现在看来,如果没有特别提到Barbara Liskov的话,这是不完整的 利斯科夫替代原则 作为'我应该继承这种类型吗?'的测试?


1013
2017-09-10 03:04



第二个例子直接来自Head First Design Patterns(amazon.com/First-Design-Patterns-Elisabeth-Freeman/dp/...)书:)我强烈推荐那个用Google搜索这个问题的人。 - Jeshurun
它非常清楚,但它可能会遗漏一些东西:“TypeB是否希望暴露TypeA的完整接口(所有公共方法都不少),以便TypeB可以在预期的TypeA中使用?”但是,如果这是真的,TypeB还会公开TypeC的完整接口?如果还没有建模TypeC怎么办? - Tristan
你暗示我认为应该是最基本的测试:“这个对象是否可以被需要(基本类型)对象的代码使用”。如果答案是肯定的,那么该对象 必须 继承。如果不是,那么它可能不应该。如果我有我的druthers,语言将提供一个关键字来引用“这个类”,并提供一种方法来定义一个类应该像另一个类一样行为,但不能替代它(这样一个类将具有所有“这个class“引用替换为自身”。 - supercat
@Alexey - 重点是'我可以将Cessna双翼飞机传递给所有期望飞机的客户而不会让他们感到惊讶吗?'如果是,那么你很可能想要继承。 - Gishu
我实际上很难想到继承本来是我的答案的任何例子,我经常发现聚合,组合和接口会产生更优雅的解决方案。使用这些方法可以更好地解释上述许多例子...... - Stuart Wakefield


把遏制想象成一个 有一个 关系。汽车“有一个”引擎,一个人“有一个”的名字等。

将继承视为一种 是一个 关系。汽车“是”汽车,人是“哺乳动物”等。

我不相信这种方法。我从中直接拿走了 第二版代码完成 通过 史蒂夫麦康奈尔第6.3节


349
2017-09-08 02:09



这并不总是一个完美的方法,它只是一个很好的指导方针。 Liskov替换原则更准确(失败更少)。 - Bill K
“我的车有车。”如果你认为这是一个单独的句子,而不是在编程环境中,那就完全没有意义了。这就是这种技术的重点。如果听起来很尴尬,那可能就错了。 - Nick Zalutskiy
@Nick当然,但“我的车有一个VehicleBehavior”更有意义(我猜你的“车辆”类可以命名为“VehicleBehavior”)。所以你不能把你的决定建立在“有一个”vs“是一个”的比较上,你必须使用LSP,否则你会犯错误 - Tristan
而不是“是一个”想到“表现得像”。继承是关于继承行为,而不仅仅是语义。 - ybakos
@ybakos“Behaves like”可以通过接口实现,无需继承。 来自维基百科: “组合优于继承的实现通常从创建表示系统必须展示的行为的各种接口开始......因此,系统行为在没有继承的情况下实现。” - DavidRR


如果您了解其中的差异,则更容易解释。

程序代码

这方面的一个例子是不使用类的PHP(特别是在PHP5之前)。所有逻辑都在一组函数中编码。您可以包含包含辅助函数等的其他文件,并通过在函数中传递数据来执行业务逻辑。随着应用程序的增长,这可能非常难以管理。 PHP5试图通过提供更多面向对象的设计来解决这个问题。

遗产

这鼓励使用课程。继承是OO设计的三个原则之一(继承,多态,封装)。

class Person {
   String Title;
   String Name;
   Int Age
}

class Employee : Person {
   Int Salary;
   String Title;
}

这是工作中的继承。员工“是”人或继承人。所有继承关系都是“is-a”关系。 Employee还会从Person中隐藏Title属性,这意味着Employee.Title将返回Employee的Title而不是Person。

组成

组合比继承更受青睐。简单地说,你会有:

class Person {
   String Title;
   String Name;
   Int Age;

   public Person(String title, String name, String age) {
      this.Title = title;
      this.Name = name;
      this.Age = age;
   }

}

class Employee {
   Int Salary;
   private Person person;

   public Employee(Person p, Int salary) {
       this.person = p;
       this.Salary = salary;
   }
}

Person johnny = new Person ("Mr.", "John", 25);
Employee john = new Employee (johnny, 50000);

组合通常“具有”或“使用”关系。这里Employee类有一个Person。它不会从Person继承,而是将Person对象传递给它,这就是它“拥有”Person的原因。

继承的组合

现在假设您要创建一个Manager类型,以便最终得到:

class Manager : Person, Employee {
   ...
}

这个例子可以正常工作,但是,如果Person和Employee都声明了 Title? Manager.Title应该返回“运营经理”还是“先生”?在构图下,这种歧义可以更好地处理:

Class Manager {
   public Title;
   public Manager(Person p, Employee e)
   {
      this.Title = e.Title;
   }
}

Manager对象由Employee和Person组成。标题行为取自员工。这种明确的构图消除了其他事物的歧义,你会遇到更少的错误。


175
2018-05-21 07:54



对于继承:没有歧义。您正在根据要求实现Manager类。因此,如果这是您的要求,您将返回“操作管理器”,否则您将只使用基类的实现。您还可以使Person成为一个抽象类,从而确保下游类实现Title属性。 - Raj Rao
重要的是要记住,有人可能会说“构成超过继承”,但这并不意味着“构成永远超过继承”。 “是一种”意味着继承并导致代码重用。员工是一个人(员工没有人)。 - Raj Rao
这个例子令人困惑。员工是一个人,所以它应该使用继承。您不应该为此示例使用合成,因为它在域模型中是错误的关系,即使在技术上您可以在代码中声明它。 - Michael Freidgeim
我不同意这个例子。一个员工 是-A 人,这是正确使用继承的教科书案例。我也认为“问题”重新定义Title字段没有意义。 Employee.Title阴影Person.Title这一事实表明编程不佳。毕竟,是“先生”和“操作经理”真的指的是一个人的同一方面(小写)?我会重命名Employee.Title,因此能够引用Employee的Title和JobTitle属性,这两个属性在现实生活中都有意义。此外,经理没有理由(续......) - Radon Rosborough
(...继续)从Person和Employee继承 - 毕竟,Employee已经从Person继承。在更复杂的模型中,一个人可能是一个管理器和一个代理,确实可以使用多重继承(小心!),但在许多环境中最好有一个抽象的Role类,Manager(包含Employees)他/她管理)和代理(包含合同和其他信息)继承。然后,员工是具有多个角色的人。因此,正确使用组合和继承。 - Radon Rosborough


由于继承提供了所有不可否认的好处,这里有一些缺点。

继承的缺点:

  1. 您无法在运行时更改从超类继承的实现(显然,因为继承是在编译时定义的)。
  2. 继承将子类暴露给其父类的实现的细节,这就是为什么它经常被称为继承破坏了封装(在某种意义上,你真的需要专注于接口而不是实现,因此通过子类的重用并不总是首选)。
  3. 由继承提供的紧密耦合使得子类的实现与超类的实现非常相关,父实现中的任何更改都将强制子类更改。
  4. 通过子类过度重用可以使继承栈非常深入并且非常混乱。

另一方面 对象组成 在运行时通过获取对其他对象的引用的对象来定义。在这种情况下,这些对象将永远无法访问彼此的受保护数据(没有封装中断),并且将被迫尊重彼此的接口。在这种情况下,实现依赖性将比继承的情况少得多。


91
2018-05-21 08:34



在我看来,这是一个更好的答案 - 我将补充一点,根据我的经验,尝试重新考虑你的问题,构成方面往往会导致更小,更简单,更自包含,更可重复使用的类,具有更清晰,更小,更集中的责任范围。通常这意味着对依赖注入或模拟(在测试中)的需求较少,因为较小的组件通常能够独立存在。只是我的经验。 YMMV :-) - mindplay.dk
这篇文章的最后一段真的点击了我。谢谢。 - Salx
最好的答案,放下手 - bertie


另一个非常实用的原因是,优先考虑组合而不是继承,这与域模型有关,并将其映射到关系数据库。将继承映射到SQL模型真的很难(最终会遇到各种各样的hacky变通方法,比如创建不常用的列,使用视图等)。一些ORML试图解决这个问题,但它总是很快变得复杂。可以通过两个表之间的外键关系轻松地对组合进行建模,但继承更加困难。


77
2017-09-08 02:48





虽然简而言之,我同意“更喜欢继承作品”,但对我而言,这听起来像“喜欢土豆而不是可口可乐”。有继承的地方和组成的地方。你需要了解差异,然后这个问题就会消失。它对我来说真正意味着“如果你要继承 - 再想一想,你可能需要合成”。

当你想吃的时候,你应该更喜欢土豆而不是可口可乐,而当你想要喝的时候,你可以选择土豆可乐。

创建子类不仅仅意味着调用超类方法的方便方法。当子类“is-a”超类在结构和功能上都应该使用继承,当它可以用作超类时,你将使用它。如果不是这种情况 - 它不是继承,而是其他东西。组合是指您的对象由另一个组成,或与它们有某种关系。

所以对我而言,如果有人不知道他是否需要继承或成分,真正的问题是他不知道他是想喝酒还是吃饭。更多地考虑您的问题域,更好地理解它。


64
2018-05-08 22:29



适合正确工作的合适工具。敲击锤子可能比扳手更好,但这并不意味着人们应该将扳手视为“劣质锤子”。当添加到子类的内容是对象作为超类对象运行所必需的东西时,继承会很有用。例如,考虑一个基类 InternalCombustionEngine 与派生类 GasolineEngine。后者增加了像基本类缺乏的火花塞之类的东西,但是使用了这个东西 InternalCombustionEngine 会导致火花塞被使用。 - supercat


继承是非常诱人的,特别是来自程序性的土地,它往往看起来很优雅。我的意思是我需要做的就是将这一点功能添加到其他类中,对吧?嗯,其中一个问题是

继承可能是你可以拥有的最糟糕的耦合形式

您的基类通过以受保护成员的形式将实现细节暴露给子类来打破封装。这使您的系统变得僵硬和脆弱。然而,更具悲剧性的缺陷是新的子类带来了继承链的所有包袱和意见。

这篇文章, 继承是邪恶的:DataAnnotationsModelBinder的Epic Fail,在C#中查看此示例。它显示了在应该使用组合时如何使用继承以及如何重构它。


54
2018-01-23 19:55



继承不好或坏,它只是一个特殊的组合案例。实际上,子类正在实现与超类类似的功能。如果您提议的子类不是重新实现而仅仅是 运用 超类的功能,然后您错误地使用了继承。这是程序员的错误,而不是对继承的反思。 - iPherian


在Java或C#中,对象在实例化后无法更改其类型。

因此,如果您的对象需要显示为不同的对象或根据对象状态或条件而表现不同,请使用 组成:参考  和 战略 设计模式。

如果对象需要是相同的类型,那么使用 遗产 或实现接口。


37
2017-09-08 02:25



+1我发现在大多数情况下继承的工作越来越少。我更喜欢共享/继承的接口和对象的组合....或者它被称为聚合?不要问我,我有EE学位! - kenny
我认为这是最常见的情况,其中“组合超过继承”适用,因为两者都可以在理论上适合。例如,在营销系统中,您可能拥有a的概念 Client。然后,一个新的概念 PreferredClient 稍后弹出。应该 PreferredClient 继承 Client?首选客户'毕竟是'客户',不是吗?好吧,不是那么快......就像你说的那样,对象不能在运行时更改它们的类。你会如何建模? client.makePreferred() 操作?也许答案在于使用具有缺失概念的构图,a Account 也许? - plalx
而不是拥有不同类型的 Client 类,也许只有一个封装了一个概念 Account这可能是一个 StandardAccount 或者a PreferredAccount... - plalx


就个人而言,我学会了总是喜欢构图而不是继承。你可以用继承解决没有程序问题,你无法通过组合来解决;虽然在某些情况下您可能必须使用接口(Java)或协议(Obj-C)。由于C ++不知道任何这样的东西,你将不得不使用抽象基类,这意味着你不能完全摆脱C ++中的继承。

组合通常更符合逻辑,它提供更好的抽象,更好的封装,更好的代码重用(特别是在非常大的项目中),并且不太可能在远处破坏任何东西,因为您在代码中的任何位置进行了单独的更改。这也使得更容易坚持“单一责任原则“,通常概括为”一个班级改变的理由永远不应该是一个原因。“,这意味着每个类都是为了特定的目的而存在的,它应该只有与其目的直接相关的方法。而且,即使项目开始变得非常简单,使用非常浅的继承树也可以更容易地保持概览很多人认为继承代表着我们 真实世界 很好,但这不是事实。现实世界使用的组合比继承更多。你可以握在手中的每一个真实物体都是由其他较小的现实世界物体组成的。

但是,构图有缺点。如果你完全跳过继承而只关注组合,你会发现如果使用了继承,你经常需要编写一些额外的代码行。你有时也被迫重复自己,这违反了 干原则 (DRY =不要重复自己)。组合通常也需要委托,而方法只是调用另一个对象的另一个方法而没有围绕此调用的其他代码。这种“双方法调用”(可能很容易扩展到三次或四次方法调用,甚至比这更远)具有比继承更糟糕的性能,在继承中,您只需继承父级的方法。调用一个继承的方法可能与调用一个非继承的方法同样快,或者它可能稍慢,但通常仍然比两个连续的方法调用快。

您可能已经注意到大多数OO语言不允许多重继承。虽然有几种情况下多重继承可以真正为你买点东西,但这些只是规则的例外。每当你遇到你认为“多重继承将是解决这个问题的一个非常酷的功能”的情况时,你通常都应该重新考虑继承,因为即使它可能需要一些额外的代码行基于构图的解决方案通常会变得更加优雅,灵活和面向未来。

继承真的是一个很酷的功能,但我担心它在过去几年里被滥用了。人们将遗传视为可以钉住所有东西的一把锤子,无论它实际上是钉子,螺钉还是完全不同的东西。


28
2018-01-31 19:34





这里没有找到满意的答案,所以我写了一个新的。

要明白为什么“比较喜欢 继承的构成“,我们首先要回到这个缩短的习语中省略的假设。

继承有两个好处: 子类型和子类化

  1. 分型 表示符合类型(接口)签名,即一组API,并且可以覆盖部分签名以实现子类型多态性。

  2. 子类 表示方法实现的隐式重用。

这两个好处有两个不同的目的:继承:面向子类型和面向代码重用。

如果代码重用是 唯一 目的,子类化可能比他需要的更多,即父类的一些公共方法对子类没有多大意义。在这种情况下,组合不是支持组合而不是继承 要求。这也是“is-a”与“has-a”概念的来源。

因此,只有在用于子类型的情况下,即稍后以多态方式使用新类时,我们才会面临选择继承或组合的问题。这是在讨论中缩短的习语中省略的假设。

要使子类型符合类型签名,这意味着组合总是暴露出同样数量的API类型。现在权衡取舍:

  1. 如果不重写,继承提供直接的代码重用,而组合必须重新编码每个API,即使它只是一个简单的委托工作。

  2. 继承提供了直截了当的 开放递归 通过内部多态站点 this,即调用重写方法(甚至是 类型)在另一个成员函数中,公共或私有(但是 灰心)。打开递归即可 通过组合模拟,但它需要额外的努力,可能并不总是可行(?)。这个 回答 一个重复的问题谈论类似的东西。

  3. 继承暴露 保护 成员。这会破坏父类的封装,如果由子类使用,则会引入子类与其父类之间的另一个依赖关系。

  4. 组合具有控制反转的适应性,并且其依赖性可以动态注入,如图所示 装饰图案 和 代理模式

  5. 组合物有益处 组合子为本 编程,即以类似的方式工作 复合图案

  6. 紧接着是作文 编程到接口

  7. 组合物具有易于使用的优点 多重继承

考虑到上述权衡,我们就这样了 比较喜欢 继承的构成。然而,对于紧密相关的类,即当隐式代码重用确实带来好处,或者需要开放递归的神奇力量时,继承应该是选择。


26
2017-09-14 05:22



很好的答案,值得更多+ 1s。 - markus
我在这里找不到满意的答案,然后你写了一个。 - leokhorn


我的一般经验法则: 在使用继承之前,请考虑组合是否更有意义。

原因: 子类化通常意味着更多的复杂性和连通性,即更难以改变,维护和扩展而不会出错。

一个更完整和具体的 Tim Boudreau回答 太阳报:

我认为使用继承的常见问题是:

  • 无辜的行为会产生意想不到的结果  - 这个经典的例子是调用来自超类的可覆盖方法   构造函数,在子类实例字段之前   初始化。在一个完美的世界里,没有人会这样做。这是   不是一个完美的世界。
  • 它为子类提供了不正确的诱惑,以便对方法调用的顺序等做出假设  - 这种假设往往不会   如果超类可能随着时间的推移而发展,那么就是稳定的。也可以看看 我的烤面包机   和咖啡壶类比
  • 课程变得更重  - 你不一定知道你的超类在它的构造函数中做了什么工作,或者知道它有多少内存   使用。因此,构建一些无辜的轻量级对象可以   比你想象的要贵得多,如果这可能会随着时间的推移而改变   超级进化
  • 它鼓励子类的爆炸。分类加载成本时间,更多类成本记忆。这可能是一个非问题,直到你   处理NetBeans规模的应用程序,但在那里,我们有真实的   例如,由于第一个显示器,菜单很慢   一个菜单触发了大量的类加载。我们通过转移来解决这个问题   更多的声明性语法和其他技术,但这需要花费时间   修好。
  • 这使得以后更改事情变得更加困难  - 如果你公开了一个类,交换超类将打破子类 -   这是一个选择,一旦你公开了代码,你就结婚了   至。所以,如果你没有改变你的真实功能   如果你超级,那么你以后可以更自由地改变事物   使用,而不是扩展你需要的东西。举个例子,   子类化JPanel - 这通常是错误的;如果子类是   在某个地方公开,你永远不会有机会重新审视这个决定。如果   它作为JComponent getThePanel()访问,你仍然可以这样做(提示:   公开API中的组件模型。
  • 对象层次结构不能扩展(或者使它们后续扩展比预先规划要困难得多)  - 这是经典的“太多层次”   问题。我将在下面讨论,以及AskTheOracle模式如何   解决它(虽然它可能冒犯OOP纯粹主义者)。

...

如果你允许继承,我可以做什么   拿一粒盐是:

  • 除了常数之外,永远不会暴露任何字段
  • 方法应为抽象或最终方法
  • 从超类构造函数中调用no方法

...

所有这些对小型项目的适用性要小于大型项目,而且较少   私人课程而非公共课程


19
2018-05-21 01:59