ChatGPT解决这个技术问题 Extra ChatGPT

更喜欢组合而不是继承?

为什么更喜欢组合而不是继承?每种方法都有哪些取舍?什么时候应该选择继承而不是组合?

一句话继承是公共的,如果你有一个公共方法并且你改变它,它会改变发布的 api。如果您有组合并且组合的对象已更改,则您不必更改已发布的 api。

I
Inanc Gumus

更喜欢组合而不是继承,因为它更具延展性/以后易于修改,但不要使用始终组合的方法。通过组合,可以很容易地通过依赖注入/设置器来动态改变行为。继承更加严格,因为大多数语言不允许您从一种以上的类型派生。因此,一旦您从 TypeA 派生,鹅或多或少就已经煮熟了。

我对上述内容的酸性测试是:

TypeB 是否想公开 TypeA 的完整接口(所有公共方法不少于),以便 TypeB 可以在预期 TypeA 的地方使用?表示继承。例如,塞斯纳双翼飞机将暴露飞机的完整界面,如果不是更多的话。所以这使它适合从飞机派生。

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

TypeB 是否只需要 TypeA 公开的部分/部分行为?表示需要组合。例如,鸟可能只需要飞机的飞行行为。在这种情况下,将其提取为接口/类/两者并使其成为两个类的成员是有意义的。

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

更新:刚刚回到我的答案,现在看来,如果没有具体提及 Barbara Liskov 的 Liskov Substitution Principle 作为“我应该从这种类型继承吗?”的测试,它是不完整的。


第二个示例直接来自 Head First Design Patterns (amazon.com/First-Design-Patterns-Elisabeth-Freeman/dp/…) 这本书 :) 我强烈推荐这本书给任何在谷歌上搜索这个问题的人。
很清楚,但可能会漏掉一些东西:“TypeB 是否想要公开 TypeA 的完整接口(所有公共方法不少于),以便 TypeB 可以在预期 TypeA 的地方使用?”但是如果这是真的,而且 TypeB 也暴露了 TypeC 的完整接口呢?如果 TypeC 还没有建模呢?
您提到了我认为应该是最基本的测试:“这个对象是否可以被期望(什么是)基本类型的对象的代码使用”。如果答案是肯定的,则该对象必须继承。如果不是,那么它可能不应该。如果我有我的德鲁特人,语言会提供一个关键字来引用“这个类”,并提供一种定义一个类的方法,该类的行为应该像另一个类,但不能替代它(这样一个类将具有所有“这个类”引用替换为自身)。
@Alexey - 关键是“我可以将塞斯纳双翼飞机传递给所有期望飞机而不会让他们感到惊讶的客户吗?”。如果是,那么您很可能想要继承。
实际上,我一直在努力思考继承本应是我的答案的任何示例,我经常发现聚合、组合和接口会产生更优雅的解决方案。使用这些方法可以更好地解释上述许多示例......
J
James Skemp

将遏制视为一种关系。汽车“有”引擎,人“有”名字,等等。

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

我不相信这种方法。我直接取自 Steve McConnellSecond Edition of Code Complete第 6.3 节


这并不总是一个完美的方法,它只是一个很好的指导方针。 Liskov 替换原则更准确(失败更少)。
“我的车有车。”如果您将其视为一个单独的句子,而不是在编程上下文中,那绝对没有意义。这就是这项技术的全部意义所在。如果这听起来很尴尬,那可能是错误的。
@Nick 当然,但“我的车有一个 VehicleBehavior”更有意义(我猜你的“Vehicle”类可以命名为“VehicleBehavior”)。所以你不能把你的决定建立在“有一个”和“是一个”的比较上,你必须使用 LSP,否则你会犯错误
而不是“是”,而是“表现得像”。继承是关于继承行为,而不仅仅是语义。
这不能回答问题。问题是“为什么”而不是“什么”。
a
aleemb

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

程序守则

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

遗产

这鼓励使用类。继承是面向对象设计的三大原则之一(继承、多态、封装)。

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

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

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

作品

组合优于继承。简而言之,您将拥有:

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 string Title;
   public Manager(Person p, Employee e)
   {
      this.Title = e.Title;
   }
}

Manager 对象由 Employee 和 Person 组成。 Title 行为取自员工。这种明确的组合消除了其他方面的歧义,您会遇到更少的错误。


对于继承:没有歧义。您正在根据要求实现 Manager 类。因此,如果这是您指定的要求,您将返回“运营经理”,否则您将只使用基类的实现。您也可以将 Person 设为抽象类,从而确保下游类实现 Title 属性。
重要的是要记住,人们可能会说“组合优于继承”,但这并不意味着“组合总是优于继承”。 “Is a”表示继承,导致代码重用。员工是一个人(员工没有人)。
这个例子很混乱。Employee是一个人,所以它应该使用继承。你不应该在这个例子中使用组合,因为它在领域模型中是错误的关系,即使技术上你可以在代码中声明它。
我不同意这个例子。 An Employee is-a Person,这是一个正确使用继承的教科书案例。我也认为“问题”重新定义 Title 字段没有意义。 Employee.Title 掩盖了 Person.Title 的事实是编程不佳的标志。毕竟都是“先生”。和“运营经理”真的指的是一个人的同一方面(小写)?我将重命名 Employee.Title,从而能够引用 Employee 的 Title 和 JobTitle 属性,这两者在现实生活中都是有意义的。此外,经理没有理由(继续...)
(...继续)从 Person 和 Employee 都继承——毕竟 Employee 已经从 Person 继承了。在更复杂的模型中,一个人可能是经理和代理,确实可以使用多重继承(小心!),但在许多环境中最好有一个抽象的角色类,其中经理(包含员工她/他管理)和代理(包含合同和其他信息)继承。那么,一个员工就是一个拥有多个角色的人。因此,组合和继承都被正确使用。
A
Ahmed

继承提供了所有不可否认的好处,以下是它的一些缺点。

继承的缺点:

您不能在运行时更改从超类继承的实现(显然是因为在编译时定义了继承)。继承将子类暴露给其父类实现的细节,这就是为什么经常说继承破坏了封装(从某种意义上说,您确实需要只关注接口而不是实现,因此通过子类重用并不总是首选)。继承提供的紧密耦合使得子类的实现与超类的实现非常紧密,父实现的任何变化都会迫使子类发生变化。通过子类化过度重用会使继承堆栈变得非常深并且非常混乱。

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


在我看来,这是更好的答案之一——我会补充一点,根据我的经验,试图从组成方面重新思考你的问题,往往会导致更小、更简单、更独立、更可重用的类,具有更清晰、更小、更集中的职责范围。通常这意味着对依赖注入或模拟(在测试中)之类的东西的需求较少,因为较小的组件通常能够独立存在。只是我的经验。 YMMV :-)
这篇文章的最后一段真的很吸引我。谢谢你。
即使你提到了一种很好的技术,我认为你并没有回答这个问题。它不是将运行时与编译时间进行比较,而是将继承与组合进行比较,组合重用来自一个或多个类的代码,并且可以覆盖或添加新逻辑。您所描述的对象组合只是通过在类中分配为属性的对象注入逻辑,这是运行时操作的绝佳替代方案。谢谢!
T
Tim Howland

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


P
Pavel Feldman

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

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

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

所以对我来说,如果有人不知道他是否需要继承或组合,真正的问题是他不知道他是想喝还是想吃。多想想你的问题领域,更好地理解它。


正确工作的正确工具。锤子可能比扳手更擅长敲打东西,但这并不意味着人们应该将扳手视为“劣质锤子”。当添加到子类的东西对于对象作为超类对象的行为是必要的时,继承会很有帮助。例如,考虑具有派生类 GasolineEngine 的基类 InternalCombustionEngine。后者添加了诸如火花塞之类的东西,这是基类所缺少的,但是将其用作 InternalCombustionEngine 将导致火花塞被使用。
C
Community

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

要理解为什么“更喜欢组合而不是继承”,我们首先需要找回在这个缩短的习语中省略的假设。

继承有两个好处:subtyping and subclassing

子类型化意味着符合一个类型(接口)签名,即一组API,并且可以覆盖部分签名来实现子类型化多态性。子类化意味着隐式重用方法实现。

这两个好处带来了进行继承的两个不同目的:面向子类型和面向代码重用。

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

所以只有当子类型是有目的的,即以后以多态方式使用新类时,我们才会面临选择继承或组合的问题。这是在讨论的缩短的成语中被省略的假设。

子类型是为了符合类型签名,这意味着组合必须始终公开不少于该类型的 API。现在开始权衡:

如果没有被覆盖,继承提供了直接的代码重用,而组合必须重新编码每个 API,即使它只是一个简单的委托工作。继承通过内部多态站点 this 提供了直接的开放递归,即在另一个成员函数中调用覆盖方法(甚至类型),无论是公共的还是私有的(尽管不鼓励)。开放递归可以通过组合来模拟,但它需要额外的努力并且可能并不总是可行的(?)。这个对重复问题的回答也有类似的内容。继承公开受保护的成员。这打破了父类的封装,如果被子类使用,则引入了子类与其父类之间的另一个依赖关系。组合具有控制反转的优点,其依赖可以动态注入,如装饰器模式和代理模式所示。组合具有面向组合器的编程的优点,即以类似于组合模式的方式工作。组合紧随对接口的编程。组合具有易于多重继承的好处。

考虑到上述权衡,我们因此更喜欢组合而不是继承。然而对于紧密相关的类,即当隐式代码重用确实有好处,或者需要开放递归的魔力时,继承应该是选择。


我认为您的意思是子类化需要子类型化,就像大多数 OOP 语言一样。换句话说,“子类化意味着隐式重用方法实现并符合类型(接口)签名。
M
Mike Valenty

继承非常诱人,尤其是来自程序领域的继承,而且它通常看起来很优雅。我的意思是我需要做的就是将这一点功能添加到其他类,对吗?嗯,问题之一是

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

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

文章 Inheritance is Evil: The Epic Fail of the DataAnnotationsModelBinder 介绍了 C# 中的一个示例。它显示了在应该使用组合时使用继承以及如何重构它。


继承没有好坏之分,它只是组合的一个特例。实际上,子类正在实现与超类类似的功能。如果您提议的子类没有重新实现,而只是使用超类的功能,那么您错误地使用了继承。那是程序员的错误,而不是对继承的反思。
B
Boris Dalstein

什么时候可以使用合成?

您始终可以使用组合。在某些情况下,继承也是可能的,并且可能导致更强大和/或更直观的 API,但组合始终是一种选择。

什么时候可以使用继承?

常说如果“bar is a foo”,那么类Bar可以继承类Foo。不幸的是,仅此测试并不可靠,请改用以下方法:

bar 是 foo,并且 bar 可以做 foo 可以做的所有事情。

第一个测试确保 Foo 的所有 getterBar(= 共享属性)中有意义,而第二个测试确保 Foo 的所有 setterBar 中有意义(= 共享功能)。

示例:狗/动物

狗是动物,狗可以做动物可以做的一切(例如呼吸、移动等)。因此,类 Dog 可以继承类 Animal

反例:圆/椭圆

圆是椭圆,但圆不能做椭圆能做的一切。例如,圆形不能拉伸,而椭圆可以。因此,类 Circle不能继承类 Ellipse

这称为 Circle-Ellipse problem,这并不是一个真正的问题,但更多地表明“bar is a foo”本身并不是一个可靠的测试。特别是,这个例子强调派生类应该扩展基类的功能,而不是限制它。否则,不能多态地使用基类。添加测试“bars can do everything that foos can do”确保多态使用是可能的,并且等效于Liskov Substitution Principle

使用指向基类的指针或引用的函数必须能够在不知情的情况下使用派生类的对象

什么时候应该使用继承?

即使您可以使用继承并不意味着您应该:使用组合始终是一种选择。继承是一个强大的工具,允许隐式代码重用和动态分派,但它确实有一些缺点,这就是为什么组合通常是首选的原因。继承和组合之间的权衡并不明显,我认为最好在 lcn's answer 中进行解释。

作为一个经验法则,当预计多态使用非常普遍时,我倾向于选择继承而不是组合,在这种情况下,动态调度的强大功能可以带来更具可读性和优雅的 API。例如,在 GUI 框架中使用多态类 Widget 或在 XML 库中使用多态类 Node 允许使用比纯粹基于组合的解决方案更具可读性和直观性的 API .


圆形椭圆和方形矩形场景是不好的例子。子类总是比它们的超类复杂,所以这个问题是人为的。这个问题是通过反转关系来解决的。椭圆源于圆形,矩形源于正方形。在这些场景中使用组合是非常愚蠢的。
@FuzzyLogic如果您对我对Circle-Ellipse的具体情况的主张感到好奇:我主张不实施Circle类。反转关系的问题在于它也违反了 LSP:想象一下函数 computeArea(Circle* c) { return pi * square(c->radius()); }。如果通过 Ellipse 显然会被破坏(radius() 甚至意味着什么?)。椭圆不是圆形,因此不应从圆形派生。
@FuzzyLogic我不同意:您意识到这意味着类Circle预期派生类Ellipse的存在,因此提供了width()height()?如果现在图书馆用户决定创建另一个名为“EggShape”的类怎么办?它也应该源自“Circle”吗?当然不是。蛋形不是圆形,椭圆也不是圆形,因此不应该从 Circle 派生,因为它破坏了 LSP。在 Circle* 类上执行操作的方法对圆是什么做出了强有力的假设,打破这些假设几乎肯定会导致错误。
你太棒了,喜欢这个答案:)
@Nom1fan 谢谢,我很高兴答案很有帮助!
d
dance2die

在 Java 或 C# 中,对象一旦被实例化就不能改变其类型。

因此,如果您的对象需要根据对象状态或条件显示为不同的对象或行为不同,请使用 Composition:请参阅 StateStrategy 设计模式。

如果对象需要属于同一类型,则使用继承或实现接口。


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

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

组合通常更合乎逻辑,它提供更好的抽象、更好的封装、更好的代码重用(尤其是在非常大的项目中),并且不太可能仅仅因为您在代码中的任何地方进行了孤立的更改而在远处破坏任何东西。这也使得维护“单一职责原则”变得更加容易,该原则通常被概括为“一个类改变的原因不应该不止一个。”,这意味着每个类都是为了一个特定的目的而存在的,它应该只有与其目的直接相关的方法。即使您的项目开始变得非常大,也有一个非常浅的继承树可以更容易地保持概述。许多人认为继承很好地代表了我们的现实世界,但事实并非如此。现实世界使用比继承更多的组合。几乎每个你可以握在手中的现实世界对象都是由其他更小的现实世界对象组成的。

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

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

继承确实是一个很酷的功能,但恐怕它在过去几年被过度使用了。人们将继承视为可以钉住一切的一把锤子,无论它实际上是钉子、螺钉还是完全不同的东西。


“许多人认为继承很好地代表了我们的现实世界,但事实并非如此。”这么多!与世界上几乎所有的编程教程相反,从长远来看,将现实世界的对象建模为继承链可能是一个坏主意。只有当存在非常明显的、与生俱来的、简单的 is-a 关系时,才应该使用继承。就像 TextFile 是一个 File
@neonblitzer 即使是 TextFile is-a File 也可以通过其他技术轻松建模。例如,您可以使“File”成为一个接口,具有具体的 TextFile 和 BinaryFile 实现,或者,“File”可能是一个可以用 BinaryFileBehaviors 或 TextFileBehaviors 的实例实例化的类(即使用策略模式)。我已经放弃了 is-a,现在只需遵循“继承是最后的手段,在没有其他选择足够有效时使用它”的口头禅。
P
Peter Tseng

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

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

Sun 的更完整和具体的answer from Tim Boudreau

在我看来,使用继承的常见问题是: 无辜的行为可能会产生意想不到的结果 - 典型的例子是在子类实例字段被初始化之前从超类构造函数调用可覆盖的方法。在一个完美的世界里,没有人会这样做。这不是一个完美的世界。它为子类提供了对方法调用顺序等进行假设的不正当诱惑——如果超类可能随着时间的推移而发展,这种假设往往不稳定。另请参阅我的烤面包机和咖啡壶的类比。类变得越来越重——你不一定知道你的超类在它的构造函数中做了什么工作,或者它将使用多少内存。所以构建一些无辜的轻量级对象可能比你想象的要昂贵得多,而且如果超类进化,这可能会随着时间的推移而改变,它会鼓励子类的爆炸式增长。类加载需要时间,更多的类需要内存。在您处理 NetBeans 规模的应用程序之前,这可能不是问题,但在那里,我们遇到了真正的问题,例如,菜单速度很慢,因为菜单的第一次显示触发了大量的类加载。我们通过转向更具声明性的语法和其他技术来解决此问题,但这也需要花费时间来解决。这让以后更难改变——如果你公开了一个类,交换超类会破坏子类——这是一个选择,一旦你公开了代码,你就结婚了。因此,如果您不更改超类的实际功能,那么您可以在以后使用时更自由地进行更改,而不是扩展您需要的东西。以 JPanel 的子类化为例——这通常是错误的;如果子类在某个地方是公开的,那么您将永远没有机会重新审视该决定。如果它作为 JComponent getThePanel() 访问,您仍然可以这样做(提示:将组件的模型作为您的 API 公开)。对象层次结构不能扩展(或者让它们在以后扩展比提前计划要困难得多)——这是典型的“层太多”问题。我将在下面讨论这个问题,以及 AskTheOracle 模式如何解决它(尽管它可能会冒犯 OOP 纯粹主义者)。 ...如果您确实允许继承,我对要做什么的看法是:除了常量之外,永远不要公开任何字段方法应该是抽象的或最终的 从超类构造函数中调用任何方法......所有这些都适用于小型项目而不是大型项目,并且适用于私人课程而不是公共课程


y
yukondude

继承非常强大,但您不能强求它(参见:circle-ellipse problem)。如果你真的不能完全确定真正的“is-a”子类型关系,那么最好使用组合。


e
egaga

继承在子类和超类之间建立了牢固的关系;子类必须知道超类的实现细节。创建超类要困难得多,当您必须考虑如何扩展它时。您必须仔细记录类不变量,并说明可覆盖方法在内部使用的其他方法。

如果层次结构确实代表一种关系,那么继承有时很有用。它与开闭原则有关,该原则指出类应该对修改关闭,但对扩展开放。这样你就可以拥有多态性;拥有一个处理超类型及其方法的泛型方法,但通过动态调度调用子类的方法。这是灵活的,并且有助于创建间接性,这在软件中是必不可少的(对实现细节知之甚少)。

但是,继承很容易被过度使用,并且会产生额外的复杂性,并且类之间存在硬依赖关系。由于层和方法调用的动态选择,理解程序执行期间发生的事情也变得非常困难。

我建议使用作曲作为默认值。它更加模块化,并提供后期绑定的好处(您可以动态更改组件)。此外,单独测试这些东西也更容易。而且,如果您需要使用类中的方法,则不必采用某种形式(Liskov Substitution Principle)。


值得注意的是,继承并不是实现多态性的唯一方法。装饰器模式通过组合提供了多态性的外观。
@BitMask777:子类型多态只是一种多态,另一种是参数多态,你不需要继承。还有更重要的是:说到继承,一指类继承; .ie 您可以通过为多个类提供公共接口来实现子类型多态性,并且不会遇到继承问题。
@engaga:我将您的评论 Inheritance is sometimes useful... That way you can have polymorphism 解释为将继承和多态性的概念硬链接(在给定上下文的情况下假设子类型)。我的评论旨在指出您在评论中澄清的内容:继承不是实现多态性的唯一方法,实际上在决定组合和继承时不一定是决定因素。
M
Marc Mutz - mmutz

假设一架飞机只有两部分:发动机和机翼。那么有两种方法来设计一个飞机类。

Class Aircraft extends Engine{
  var wings;
}

现在您的飞机可以从固定机翼开始,然后在飞行中将其更改为旋转机翼。它本质上是一个带翅膀的发动机。但是,如果我也想即时更换引擎怎么办?

基类 Engine 公开一个 mutator 以更改其
属性,或者我将 Aircraft 重新设计为:

Class Aircraft {
  var wings;
  var engine;
}

现在,我也可以随时更换我的引擎。


你的帖子提出了一个我以前没有考虑过的问题——继续你对具有多个部件的机械物体的类比,在枪支之类的东西上,通常有一个部件标有序列号,其序列号被认为是枪械整体(对于手枪,通常是框架)。一个人可以更换所有其他零件,仍然拥有相同的枪支,但如果框架破裂并需要更换,那么将原始枪支的所有其他零件组装成新框架的结果将是一把新枪。注意...
...枪支的多个部件上可能标有序列号这一事实并不意味着枪支可以有多个身份。只有框架上的序列号可以识别枪支;任何其他零件上的序列号标识了这些零件是用哪支枪制造的,而这可能不是在任何给定时间组装它们的枪。
在这种飞机的特殊情况下,我绝对不建议“在飞行中更换发动机”
R
Rob W

您需要查看 Bob 大叔的 SOLID 类设计原则中的 The Liskov Substitution Principle。 :)


S
Scott Hannen

为了从不同的角度为新程序员解决这个问题:

当我们学习面向对象编程时,通常会在早期教授继承,因此它被视为解决常见问题的简单方法。

我有三个类都需要一些通用功能。因此,如果我编写一个基类并让它们都继承自它,那么它们都将具有该功能,我只需要在一个地方维护它。

这听起来不错,但在实践中它几乎永远不会奏效,原因如下:

我们发现还有一些我们希望我们的类具有的其他功能。如果我们向类添加功能的方式是通过继承,我们必须决定——我们是否将它添加到现有的基类中,即使不是每个从它继承的类都需要该功能?我们是否创建另一个基类?但是那些已经从另一个基类继承的类呢?

我们发现,对于从我们的基类继承的类中的一个,我们希望基类的行为稍有不同。所以现在我们回过头来修改我们的基类,可能会添加一些虚拟方法,或者更糟糕的是,一些代码说:“如果我继承类型 A,就这样做,但是如果我继承类型 B,就这样做。”这很糟糕,原因有很多。一个是每次我们更改基类时,我们实际上都在更改每个继承的类。因此,我们确实在更改 A、B、C 和 D 类,因为我们需要在 A 类中稍微不同的行为。尽管我们认为自己很小心,但我们可能会因为与这些类无关的原因而破坏其中一个类类。

我们可能知道为什么我们决定让所有这些类相互继承,但对于必须维护我们代码的其他人来说,这可能没有(可能不会)有意义。我们可能会迫使他们做出艰难的选择——我是做一些非常丑陋和混乱的事情来做出我需要的改变(参见前面的要点),还是我只是重写一堆。

最后,我们把我们的代码打成一个个复杂的结,并没有从中得到任何好处,只是我们会说,“酷,我学会了继承,现在我使用了它。”这并不意味着居高临下,因为我们都做到了。但我们都这样做了,因为没有人告诉我们不要这样做。

一旦有人向我解释了“优先组合优于继承”,我每次尝试使用继承在类之间共享功能时都会回想起来,并意识到大多数时候它并不能很好地工作。

解毒剂是Single Responsibility Principle。将其视为一种约束。我的班级必须做一件事。我必须能够给我的班级起一个名字,以某种方式描述它所做的一件事。 (凡事都有例外,但是当我们学习时,绝对规则有时会更好。)因此我不能编写一个名为 ObjectBaseThatContainsVariousFunctionsNeededByDifferentClasses 的基类。无论我需要什么不同的功能都必须在它自己的类中,然后其他需要该功能的类可以依赖于该类,从它继承。

冒着过度简化的风险,这就是组合——组合多个类一起工作。一旦我们养成了这种习惯,我们就会发现它比使用继承更加灵活、可维护和可测试。


类不能使用多个基类并不是对继承的不良反映,而是对特定语言缺乏能力的不良反映。
在写下这个答案的那段时间里,我从“鲍勃叔叔”中读到了this post,它解决了这种能力不足的问题。我从未使用过允许多重继承的语言。但回过头来看,这个问题被标记为“语言不可知论”,我的回答假设 C#。我需要拓宽我的视野。
S
Scotty Jamison

如果您想要自 OOP 兴起以来人们一直在给出的规范的教科书式答案(您会在这些答案中看到很多人给出的答案),那么请应用以下规则:“如果您有 is-a 关系,请使用继承。如果您有一个有关系,使用组合”。

这是传统的建议,如果你满意,你可以停止阅读这里,继续你的快乐之路。对于其他所有人...

is-a/has-a 比较有问题

例如:

正方形是矩形,但是如果您的矩形类具有 setWidth()/setHeight() 方法,那么在不破坏 Liskov 替换原则的情况下,没有合理的方法可以使 Square 从 Rectangle 继承。作为替代示例,想象一个具有 addGas() 方法的 Car 类。你会发现从 Car 继承新的 ElectricCar 类非常困难,即使 ElectricCar 是 Car。

is-a 关系通常可以改写为 has-a 关系。例如,一个雇员是一个人,但一个人也有一个“受雇”的就业状态。

如果您不小心,is-a 关系可能会导致令人讨厌的多重继承层次结构。毕竟,英语中没有规定一个对象完全是一回事。

人们很快就会通过这个“规则”,但有没有人试图支持它,或者解释为什么它是一个很好的启发式遵循?当然,它非常适合 OOP 应该模拟现实世界的想法,但这本身并不是采用原则的理由。

有关此主题的更多信息,请参阅 this StackOverflow 问题。

要知道何时使用继承与组合,我们首先需要了解它们的优缺点。

实现继承的问题

其他答案在解释继承问题方面做得很好,所以我会尽量不在这里深入研究太多细节。但是,这里有一个简短的列表:

继承可以说将一定程度的实现细节暴露给子类(通过受保护的成员)。

可能很难遵循在基类和子类方法之间编织的逻辑。

脆弱的基础问题。更糟糕的是,大多数 OOP 语言默认允许继承 - 没有主动阻止人们从他们的公共类继承的 API 设计者在重构他们的基类时需要格外小心。不幸的是,脆弱的基础问题经常被误解,导致许多人不明白维护一个任何人都可以继承的类需要什么。 (例如,你不能重构一个方法让它开始调用另一个公共方法,即使它不会改变基类的行为,因为子类可能已经覆盖了公共方法)

致命的死亡钻石

组合的问题

有时可能有点冗长。

而已。我是认真的。这仍然是一个真正的问题,有时会与 DRY 原则产生冲突,但它通常并没有那么糟糕,至少与与继承相关的无数陷阱相比。

什么时候应该使用继承?

下次当您为项目绘制精美的 UML 图时(如果您这样做),并且您正在考虑添加一些继承,请遵循以下建议:不要。

至少,现在还没有。

继承是作为实现多态性的工具出售的,但与它捆绑在一起的是这个强大的代码重用系统,坦率地说,大多数代码并不需要它。问题是,一旦你公开了你的继承层次结构,你就会被锁定在这种特殊的代码重用风格中,即使解决你的特定问题是矫枉过正。

为了避免这种情况,我的两分钱就是永远不要公开你的基类。

如果您需要多态性,请使用接口。

如果您需要允许人们自定义您的类的行为,通过策略模式提供明确的挂钩点,这是一种更易读的方式来实现这一点,此外,在您所处的环境中保持这种 API 的稳定性更容易完全控制他们可以改变和不能改变的行为。

如果您试图通过使用继承来遵循开闭原则以避免向类添加急需的更新,那就不要了。更新课程。如果您真正拥有受雇来维护的代码,而不是试图将一些东西附加到它的一边,那么您的代码库将会更加简洁。如果您害怕引入错误,请获取现有代码进行测试。

如果您需要重用代码,请从尝试使用组合或辅助函数开始。

最后,如果您已经确定没有其他好的选择,并且您必须使用继承来实现所需的代码重用,那么您可以使用它,但是,请遵循这四个限制继承的 PAIL 规则以保持理智。

使用继承作为私有实现细节。不要公开你的基类,为此使用接口。这使您可以根据需要自由添加或删除继承,而无需进行重大更改。保持你的基类抽象。它可以更容易地将需要共享的逻辑与不需要共享的逻辑分开。隔离您的基类和子类。不要让您的子类覆盖基类方法(为此使用策略模式),并避免让它们期望属性/方法彼此存在,使用其他形式的代码共享来实现这一点。继承是最后的手段。

特别是孤立规则听起来可能有点难以遵循,但如果你自律,你会得到一些相当不错的好处。特别是,它使您可以自由地避免上面提到的与继承相关的所有主要令人讨厌的陷阱。

您不会通过受保护的成员公开实施细节。如果您的子类努力不知道基类提供的属性,那么您就没有使用 protected。

遵循代码要容易得多,因为它不会编织进出基类/子类。

脆弱的基础问题消失了。脆弱的基础问题源于这样一个事实,即人们可以在您没有意识到/不记得它正在发生的情况下任意覆盖基类上的方法。当继承是隔离和私有的时,基类不会比依赖于另一个通过组合的类更脆弱。

致命的死亡钻石不再是问题,因为根本不需要多层继承。如果您有抽象基类 B 和 C,它们共享很多功能,只需将该功能从 B 和 C 中移出并移到新的抽象基类 D 中。从 B 继承的任何人都应该更新以从两者继承B 和 D,以及从 C 继承的任何人都应该从 C 和 D 继承。由于您的基类都是私有的实现细节,因此要弄清楚谁从什么继承来进行这些更改应该不难。

结论

我的主要建议是在这件事上动动脑筋。比关于何时使用继承的注意事项列表更重要的是对继承及其相关优缺点的直观理解,以及对可以代替继承使用的其他工具的良好理解(组合不是唯一的选择。例如,策略模式是一个令人惊叹的工具,但经常被遗忘)。也许当您对所有这些工具有很好、扎实的了解时,您会选择比我推荐的更频繁地使用继承,这完全没问题。至少,您正在做出明智的决定,而不仅仅是使用继承,因为这是您知道如何去做的唯一方法。

进一步阅读:

我写的一篇关于这个主题的文章,它更深入并提供了例子。

一个网页讨论了继承所做的三种不同工作,以及如何通过 Go 语言中的其他方式完成这些工作。

将您的类声明为不可继承的原因列表(例如,Java 中的“final”)。


A
Anzurio

当您想“复制”/公开基类的 API 时,您使用继承。当您只想“复制”功能时,请使用委托。

一个例子:你想从一个列表中创建一个堆栈。 Stack 只有 pop、push 和 peek。你不应该使用继承,因为你不想在堆栈中使用 push_back、push_front、removeAt 等功能。


@Anzurio ....您如何区分 API 与功能性?根据我公开的类的方法,我们称它们为该类的 api 方法。这些也是类的功能。如果你使用组合,你使用类的公共方法,我们已经将它们称为 API。
@sdindiver 很抱歉造成混乱。我试图说明的一点是,在使用继承时,您将公开父类的完整 API,但是,如果子类不需要公开该完整父类的 API,则使用组合,以便子类将具有在不暴露其完整 API 的情况下访问父类的功能。
T
Tero Tolonen

这两种方式可以很好地生活在一起,实际上是相互支持的。

组合只是将其模块化:您创建类似于父类的接口,创建新对象并将调用委托给它。如果这些对象不需要相互了解,那么组合是非常安全且易于使用的。这里有很多可能性。

但是,如果父类出于某种原因需要访问“子类”为没有经验的程序员提供的功能,那么它可能看起来是使用继承的好地方。父类可以调用它自己的抽象“foo()”,它被子类覆盖,然后它可以将值赋予抽象基。

这看起来是个好主意,但在许多情况下,最好只给类一个实现 foo() 的对象(甚至手动设置 foo() 提供的值),而不是从某个需要的基类继承新类要指定的函数 foo()。

为什么?

因为继承是移动信息的一种糟糕方式。

组合在这里有一个真正的优势:关系可以颠倒:“父类”或“抽象工作者”可以聚合实现特定接口的任何特定“子”对象+任何子对象可以设置在任何其他类型的父对象中,它接受这是类型。并且可以有任意数量的对象,例如 MergeSort 或 QuickSort 可以对实现抽象 Compare 接口的任何对象列表进行排序。或者换一种说法:任何实现“foo()”的对象组和其他可以使用具有“foo()”的对象的对象组可以一起玩。

我可以想到使用继承的三个真正原因:

您有许多具有相同接口的类,您希望节省编写它们的时间您必须为每个对象使用相同的基类您需要修改私有变量,在任何情况下都不能公开

如果这些都是真的,那么可能有必要使用继承。

使用原因 1 并没有什么不好,在你的对象上有一个可靠的接口是非常好的事情。这可以使用组合或继承来完成,没问题 - 如果这个接口很简单并且不会改变。通常继承在这里非常有效。

如果原因是 2 号,那就有点棘手了。你真的只需要使用相同的基类吗?一般来说,仅仅使用同一个基类是不够的,但这可能是你的框架的要求,一个无法避免的设计考虑。

但是,如果你想使用私有变量,情况 3,那么你可能会遇到麻烦。如果您认为全局变量不安全,那么您应该考虑使用继承来访问私有变量也是不安全的。请注意,全局变量并不是那么糟糕——数据库本质上是一大组全局变量。但如果你能应付得来,那就很好了。


J
Jon Limjap

除了是一个/有一个考虑因素之外,还必须考虑您的对象必须经历的继承的“深度”。任何超过 5 或 6 级继承深度的东西都可能导致意外的强制转换和装箱/拆箱问题,在这些情况下,组合您的对象可能是明智之举。


R
Ravindra babu

当你在两个类之间有 is-a 关系时(例如 dog 是犬),你就去继承。

另一方面,当您在两个班级(学生有课程)或(教师学习课程)之间有has-a或一些形容词关系时,您选择了作文。


你说继承和继承。你不是说继承和组合吗?
不,你没有。你也可以定义一个犬类接口,让每只狗都实现它,你最终会得到更多的 SOLID 代码。
Y
Y.S

理解这一点的一种简单方法是,当您需要类的对象与其父类具有相同的接口时,应使用继承,以便将其视为父类的对象(向上转换) .此外,派生类对象上的函数调用在代码中的任何地方都将保持相同,但调用的具体方法将在运行时确定(即低级实现不同,高级接口保持相同)。

当您不需要新类具有相同的接口时,应该使用组合,即您希望隐藏该类的用户不需要知道的类实现的某些方面。因此,组合更多地支持封装(即隐藏实现),而继承旨在支持抽象(即提供某事物的简化表示,在这种情况下,为具有不同内部结构的一系列类型提供相同的接口)。


+1 提及界面。我经常使用这种方法来隐藏现有的类,并通过模拟用于组合的对象来使我的新类可以正确地进行单元测试。这需要新对象的所有者改为将候选父类传递给它。
C
Community

子类型在 invariants can be enumerated 处更合适且更强大,否则使用函数组合来实现可扩展性。


P
Parag

我同意@Pavel,当他说,有组合的地方,也有继承的地方。

如果您对这些问题中的任何一个问题的回答是肯定的,我认为应该使用继承。

您的类是受益于多态性的结构的一部分吗?例如,如果你有一个 Shape 类,它声明了一个名为 draw() 的方法,那么我们显然需要 Circle 和 Square 类是 Shape 的子类,这样它们的客户端类将依赖于 Shape 而不是特定的子类。

您的班级是否需要重用在另一个班级中定义的任何高级交互?如果没有继承,模板方法设计模式将无法实现。我相信所有可扩展的框架都使用这种模式。

但是,如果您的意图纯粹是代码重用,那么组合很可能是更好的设计选择。


E
Enrique Molinari

继承是一种非常强大的代码重用机制。但需要正确使用。如果子类也是父类的子类型,我会说继承是正确的。如上所述,里氏替换原则是这里的重点。

子类与子类型不同。您可能会创建不是子类型的子类(此时您应该使用组合)。为了理解什么是子类型,让我们开始解释什么是类型。

当我们说数字 5 是整数类型时,我们是在说 5 属于一组可能的值(例如,请参阅 Java 原始类型的可能值)。我们还声明了我可以对值执行的一组有效方法,例如加法和减法。最后我们要说明的是,有一组总是满足的属性,例如,如果我将值 3 和 5 相加,我将得到 8 作为结果。

再举一个例子,考虑抽象数据类型,整数集和整数列表,它们可以保存的值仅限于整数。它们都支持一组方法,例如 add(newValue) 和 size()。而且它们都有不同的属性(类不变量),Sets 不允许重复,而 List 确实允许重复(当然还有它们都满足的其他属性)。

子类型也是一种类型,它与另一种类型有关系,称为父类型(或超类型)。子类型必须满足父类型的特征(值、方法和属性)。该关系意味着在任何需要超类型的上下文中,它都可以被子类型替换,而不会影响执行的行为。让我们看一些代码来举例说明我在说什么。假设我写了一个整数列表(用某种伪语言):

class List {
  data = new Array();

  Integer size() {
    return data.length;
  }

  add(Integer anInteger) {
    data[data.length] = anInteger;
  }
}

然后,我将整数集写为整数列表的子类:

class Set, inheriting from: List {
  add(Integer anInteger) {
     if (data.notContains(anInteger)) {
       super.add(anInteger);
     }
  }
}

我们的 Set of integers 类是 List of Integers 的子类,但不是子类型,因为它不满足 List 类的所有特性。方法的值和签名得到满足,但属性不满足。 add(Integer) 方法的行为已经明显改变,没有保留父类型的属性。从班级客户的角度思考。他们可能会收到一组整数,其中需要一个整数列表。客户端可能希望添加一个值并将该值添加到列表中,即使该值已存在于列表中。但如果价值存在,她就不会得到这种行为。对她来说是一个很大的惊喜!

这是一个不恰当地使用继承的典型例子。在这种情况下使用组合。

(来自:use inheritance properly 的片段)。


R
Ravindra babu

尽管组合是首选,但我想强调继承的优点和组合的缺点。

继承的优点:

它建立了一个逻辑“IS A”关系。如果 Car 和 Truck 是 Vehicle 的两种类型(基类),则子类是基类。即 Car 是 Vehicle Truck 是 Vehicle 具有继承性,您可以定义/修改/扩展能力基类不提供实现,子类必须覆盖完整方法(抽象)=>您可以实现合同基类提供默认实现子类可以改变行为=>您可以重新定义合约子类通过调用 super.methodName() 作为第一个语句来为基类实现添加扩展=>您可以扩展合约基类定义算法的结构和子类将覆盖算法的一部分 => 您可以在不更改基类骨架的情况下实现 Template_method

组成的缺点:

在继承中,子类可以直接调用基类方法,即使它因为 IS A 关系没有实现基类方法。如果使用组合,则必须在容器类中添加方法以公开包含的类 API

例如,如果 Car 包含 Vehicle 并且您必须获取已在 Vehicle 中定义的 Car 价格,您的代码将是这样的

class Vehicle{
     protected double getPrice(){
          // return price
     }
} 

class Car{
     Vehicle vehicle;
     protected double getPrice(){
          return vehicle.getPrice();
     }
} 

我认为它没有回答这个问题
您可以重新查看 OP 问题。我已经解决了:每种方法都有哪些权衡?
正如您所提到的,您只谈论“继承的优点和组合的缺点”,而不是每种方法的权衡或应该使用一种方法的情况
优点和缺点提供了权衡,因为继承的优点是组合的缺点,组合的缺点是继承的优点。
我迟到了,但是;因为组合的缺点是继承的优点,所以你没有谈论继承的缺点。你只谈到了继承的优点,两次。
佚名

我听说的一条经验法则是,当它是“is-a”关系时应该使用继承,而当它是“has-a”时应该使用组合。即便如此,我觉得你应该总是倾向于组合,因为它消除了很多复杂性。


S
Shami Qureshi

组合v / s继承是一个广泛的主题。没有更好的答案,因为我认为这完全取决于系统的设计。

通常,对象之间的关系类型提供了更好的信息来选择其中之一。

如果关系类型是“IS-A”关系,那么继承是更好的方法。否则关系类型是“HAS-A”关系,那么组合会更好。

它完全取决于实体关系。


V
Veverke

我看到没有人提到 diamond problem,这可能与继承有关。

乍一看,如果类 B 和 C 继承 A 并且都覆盖方法 X,第四个类 D 继承自 B 和 C,并且不覆盖 X,那么 XD 应该使用哪个实现?

Wikipedia 很好地概述了此问题中讨论的主题。


D继承B和C而不是A。如果是这样,那么它将使用A类中X的实现。
@fabricio:谢谢,我编辑了文本。顺便说一句,这种情况不会发生在不允许多类继承的语言中,对吗?
是的,你是对的..而且我从来没有使用过允许多重继承(根据钻石问题中的示例)..
这根本不是问题,因为这是扩展系统或为负载平衡保留设施和功能的基本方式。两种实现都存在,由用户或负载平衡算法/规则决定应用哪一个以及何时应用。在语言级别上这不是问题,因为即使在您的问题中,方法的所有实现X都使用不同的限定名称进行标识。