更喜欢组合而不是继承?

为什么喜欢组合而不是继承?每种方法都有什么权衡?什么时候应该选择继承而不是组合?

436420 次浏览

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

把继承想象成一种是一个关系。汽车是“交通工具”,人是“哺乳动物”,等等。

我对这种方法不感兴趣。我直接从代码完成第二版史蒂夫麦康奈尔第6.3节

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

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

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

如果对象需要具有相同的类型,则使用继承或实现接口。

继承非常强大,但你不能强迫它(参见:圆椭圆问题)。如果你真的不能完全确定一个真正的“is-a”子类型关系,那么最好使用组合。

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

除了is a/have之外,还必须考虑你的对象必须经历的继承的“深度”。任何超过五六个继承深度的级别都可能导致意想不到的转换和装箱/拆箱问题,在这种情况下,组成你的对象可能是明智的。

与继承相比,更喜欢组合,因为它更具延展性/更容易稍后修改,但不要使用组合总是方法。使用组合,使用依赖注入/设置器很容易动态更改行为。继承更加严格,因为大多数语言不允许你从多个类型派生。所以一旦你从TypeA派生,鹅或多或少就熟了。

我对上述的酸性测试是:

  • TypeB是否想要公开TypeA的完整接口(至少所有公共方法),以便TypeB可以在期望TypeA的地方使用?指示继承

    • 例如:塞斯纳双翼飞机将暴露飞机的完整界面,如果不是更多的话。所以这使得它适合从飞机派生。
  • TypeB是否只想要TypeA暴露的部分行为?表示需要组成。

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

更新时间:刚刚回到我的答案,现在看来,如果没有具体提到Barbara Liskov的李斯科夫替换原理作为“我应该继承这种类型吗?”的测试,它是不完整的。

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

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

简而言之,我会同意“喜欢组合而不是继承”的观点,但对我来说,这经常听起来像是“喜欢土豆而不是可口可乐”。有继承的地方,也有组合的地方。你需要理解差异,那么这个问题就会消失。对我来说,它真正意味着“如果你要使用继承——再想一想,你很可能需要组合”。

当你想吃东西时,你应该更喜欢土豆而不是可口可乐,当你想喝酒时,你应该更喜欢可口可乐而不是土豆。

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

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

如果你理解其中的区别,就更容易解释了。

程序代码

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

继承

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

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

这是工作中的继承。EmployeePerson0”Person或从Person继承。所有继承关系都是“is-a”关系。Employee也遮蔽了PersonTitle属性,这意味着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 {...}

这个例子可以很好地工作,但是,如果PersonEmployee都声明了Title呢?Manager.Title应该返回“运营经理”还是“先生”?在组合下,这种歧义更好地处理:

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

Manager对象由EmployeePerson组成。Title的行为取自Employee。这种显式组合消除了歧义,您会遇到更少的错误。

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

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

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

我建议使用组合作为默认值。它更模块化,并且提供了后期绑定的好处(你可以动态更改组件)。并且更容易单独测试事物。如果你需要使用类中的方法,你不会被迫成为某种形式(Liskov替换原则)。

继承带来的所有不可否认的好处,这里有一些它的缺点。

继承的缺点:

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

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

你想强迫自己(或其他程序员)遵守什么,什么时候想让自己(或其他程序员)有更多的自由。有人认为,当你想强迫某人以某种方式处理/解决特定问题,这样他们就不会朝着错误的方向前进时,继承是有帮助的。

Is-aHas-a是一个有用的经验法则。

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

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

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

文章继承是邪恶的:数据的史诗般的失败介绍了C#中的一个示例。它展示了在应该使用组合时使用继承以及如何重构它。

你需要看看Bob叔叔的类设计原则固体中的Liskov替换原理。:)

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

Class Aircraft extends Engine{var wings;}

现在你的飞机可以从固定机翼开始
并在飞行中将它们改为旋翼。这本质上是
有翅膀的引擎。但是如果我想改变呢?
#36825;动的发动机?

要么基类Engine公开一个突变体来改变它的
属性,或者我将Aircraft重新设计为:

Class Aircraft {var wings;var engine;}

现在,我也可以在飞行中更换我的发动机。

子类型是适当的,更强大的不变量可以枚举,否则使用函数组合的可扩展性。

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

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

  • 你的类是受益于多态性的结构的一部分吗?例如,如果你有一个Shape类,它声明了一个名为Draw()的方法,那么我们显然需要Circle和Square类成为Shape的子类,这样它们的客户端类就会依赖于Shape而不是特定的子类。
  • 你的类是否需要重用另一个类中定义的任何高级交互?没有继承,模板法设计模式是不可能实现的。我相信所有可扩展的框架都使用这种模式。

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

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

原因:子类化通常意味着更多的复杂性和连通性,即更难以在不犯错误的情况下进行更改、维护和扩展。

一个更加完整和具体的Sun来自Tim Boudreau的回复

在我看来,使用继承的常见问题有:

  • 无辜的行为会有意想不到的结果-这方面的经典示例是从超类调用可重写的方法构造函数,在子类实例字段被在一个完美的世界里,没有人会这样做。这是不是一个完美的世界。
  • 它为子类提供了不正当的诱惑来假设方法调用的顺序等等-这样的假设往往不会如果超类可能会随着时间的推移而演变,请保持稳定。另请参阅my toaster和咖啡壶类比
  • 课程越来越重-你不一定知道你的超类在它的构造函数中做了什么工作,或者它消耗了多少内存因此,构建一些无辜的潜在轻量级物体可以比你想象的要贵得多,这可能会随着时间的推移而改变超类的演化
  • 它鼓励了子类的爆炸。类加载需要时间,更多的类需要内存。这可能不是问题,直到您处理NetBeans规模的应用程序,但在那里,我们有真正的例如,菜单很慢,因为第一次显示触发了大量的类加载。我们通过移动到更多的声明式语法和其他技术,但这需要时间来也修好。
  • 这样以后就更难改变了-如果你已经公开了一个类,交换超类会破坏子类-这是一个选择,一旦你公开了代码,你就结婚了所以如果你不改变你的真实功能超级阶级,你以后会有更多的自由来改变事情,如果你使用,而不是扩展你需要的东西。例如,子类化JPanel-这通常是错误的;如果子类是在某个地方公开,你永远不会有机会重新审视这个决定。如果它作为JComponent getThePanel()访问,您仍然可以这样做(提示:将组件的模型公开为您的API)。
  • 对象层次结构不缩放(或者让它们稍后缩放比提前计划要困难得多)-这是经典的“太多层”问题。我将在下面讨论这个问题,以及AskTheOracle模式如何解决它(尽管它可能会冒犯OOP纯粹主义者)。

如果你允许继承,我可以采取什么措施下一篇:一粒盐:

  • 永远不要暴露任何字段,除了常量
  • 方法应是抽象的或最终的
  • 不从超类构造函数调用任何方法

所有这些都适用于小项目,而不是大项目,并且更少比公共类更私密的类

正如许多人所说,我将首先检查是否存在“is-a”关系。如果存在,我通常检查以下内容:

基类是否可以实例化。也就是说,基类是否可以是非抽象的。如果可以是非抽象的,我通常更喜欢组合

例如1。会计是一个员工。但我将没有使用继承,因为可以实例化员工对象。

例如2. Book是一个 SellingItem。SellingItem不能实例化-它是抽象的概念。因此,我将使用继承。SellingItem是C#中的抽象基类(或接口)

你对这种做法有什么看法?

另外,我支持@anon在为什么要使用继承?中回答

使用继承的主要原因不是作为一种组合形式-它是为了让您可以获得多态行为。如果您不需要多态性,您可能不应该使用继承。

@MatthieuM.在https://softwareengineering.stackexchange.com/questions/12439/code-smell-inheritance-abuse/12448#comment303759_12448中说

继承的问题在于它可以用于两个正交的目的:

接口(用于多态)

实现(用于代码重用)

参考

  1. 哪个类设计更好?
  2. 继承与聚合

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

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

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

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

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

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

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

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

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

为啥?

因为继承不是传递信息的好方法

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

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

  1. 你有很多相同的接口的类,你想节省写它们的时间
  2. 您必须为每个对象使用相同的基类
  3. 你需要修改私有变量,在任何情况下都不能是公共的

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

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

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

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

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

子类和子类型不一样。你可以创建不是子类型的子类(这是你应该使用组合的时候)。为了理解什么是子类型,让我们开始解释什么是类型。

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

再举一个例子,想想抽象数据类型,Set of integers和List of integers,它们可以保存的值仅限于整数。它们都支持一组方法,如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类的所有特性。方法的值和签名得到满足,但属性没有。add(Integer)方法的行为已经明显改变,没有保留父类型的属性。从类的客户端的角度思考。他们可能会收到一组整数,而需要一个整数列表。客户端可能希望添加一个值并将该值添加到List中,即使该值已经存在于List中。但是如果这个值存在,她就不会得到这种行为。这对她来说是一个很大的惊喜!

这是不正确使用继承的典型示例。在这种情况下使用组合。

(来自:正确使用继承的片段)。

当你在两个类之间有关系时(例如狗是犬),你会选择继承。

另一方面,当你在两个类(学生有课程)或(教师学习课程)之间有有一个或某种形容词关系时,你选择了作文。

这条规则完全是胡说八道。为什么?

原因是在任何情况下都可以判断是使用组合还是继承。这是由一个问题的答案决定的:“是别的东西”或“有“什么东西,什么东西”

你不能“宁愿”把某样东西变成别的东西或拥有别的东西。严格的逻辑规则适用。

也没有“人为的例子”,因为在任何情况下都可以给出这个问题的答案。

如果你不能回答这个问题,还有别的问题。这包括类的重叠职责通常是错误使用接口的结果,较少通过在不同的类中重写相同的代码来实现。

为了避免这种情况,我还建议为类使用好的名称,这与它们的职责完全相似。

组合v/s继承是一个广泛的主题。对于什么更好没有真正的答案,因为我认为这一切都取决于系统的设计。

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

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

它完全依赖于实体关系。

理解这一点的一个简单方法是,当你需要类的对象与其父类具有相同的接口时,应该使用继承,这样它就可以被视为父类的对象(上溯)。此外,派生类对象的函数调用在代码中的任何地方都保持不变,但要调用的具体方法将在运行时确定(即低级实施不同,高级接口保持不变)。

当你不需要新类具有相同的接口时,应该使用组合,即你希望隐藏类的用户不需要知道的类实现的某些方面。所以组合更多的是支持封装(即隐藏实现),而继承意味着支持抽象(即提供某种东西的简化表示,在这种情况下是相同接口,用于一系列内部不同的类型)。

我看到没有人提到钻石问题,这可能会随着继承而出现。

一目了然,如果类B和C继承了A并且都覆盖了方法X,而第四个类D继承了B和C,并且没有覆盖X,那么应该使用X D的哪个实现?

维基百科很好地概述了这个问题中讨论的主题。

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

要理解为什么“更喜欢组合胜过继承”,我们首先需要回到这个缩短的习惯用法中省略的假设。

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

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

  2. 子类化意味着方法实现的隐式重用。

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

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

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

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

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

  2. 继承通过内部多态站点this提供了直接的开放递归,即在另一个成员函数中调用重写方法(或甚至type),无论是公共的还是私有的(尽管不鼓励)。开放递归可以通过组合来模拟,但它需要额外的努力并且可能并不总是可行的(?)。这个对重复问题的回答类似。

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

  4. 组合具有控制反转的特性,它的依赖关系可以动态注入,如装饰器模式代理模式所示

  5. 组合的好处是面向组合器的编程,即以类似于复合模式的方式工作

  6. 组合紧随编程到接口之后

  7. 组合的好处是易于多重继承

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

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

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

我有三个类,它们都需要一些共同的功能。所以如果我写一个基类,让他们都继承它,然后他们会都有这个功能我只需要维护一次地方。

这听起来很棒,但在实践中,它几乎从未奏效,原因如下:

  • 我们发现我们希望我们的类拥有其他一些功能。如果我们向类添加功能的方式是通过继承,我们必须决定-我们是否将其添加到现有的基类中,即使并非每个从它继承的类都需要该功能?我们是否创建另一个基类?但是已经从其他基类继承的类怎么办?
  • 我们发现,对于从我们的基类继承的一个类,我们希望基类的行为有点不同。所以现在我们回去修补我们的基类,也许添加一些虚拟方法,或者更糟的是,一些代码说,“如果我继承了类型A,做这个,但如果我继承了类型B,做那个。”这很糟糕,原因有很多。一个是每次我们更改基类时,我们实际上都在更改每个继承的类。所以我们真的在改变类A、B、C和D,因为我们需要在类A中有一个稍微不同的行为,尽管我们认为我们很小心,但我们可能会因为与这些类无关的原因而破坏其中一个类。
  • 我们可能知道为什么我们决定让所有这些类相互继承,但对于必须维护我们代码的其他人来说,这可能没有(可能不会)意义。我们可能会迫使他们做出艰难的选择——我是做了一些非常丑陋和混乱的事情来做出我需要的更改(参见前面的项目符号),还是我只是重写了一堆。

最后,我们把我们的代码绑在一些困难的结上,除了我们可以说,“酷,我学到了继承,现在我使用了它。”这并不意味着居高临下,因为我们都做过。但我们都做了,因为没有人告诉我们不要这样做。

当有人向我解释“有利于组合而不是继承”时,我回想起每次我试图使用继承在类之间共享功能时,并意识到大多数时候它并没有很好地工作。

解药是单一责任原则。把它想象成一个约束。我的类必须做一件事。我可以给我的类一个名称,以某种方式描述它所做的一件事。(凡事都有例外,但当我们学习时,绝对规则有时更好。)因此,我不能编写一个名为ObjectBaseThatContainsVariousFunctionsNeededByDifferentClasses的基类。无论我需要什么不同的功能,都必须在它自己的类中,然后其他需要该功能的类可以依赖于该类,没有从它继承。

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

什么时候可以用作文?

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

什么时候可以使用继承?

人们常说,如果“bar是foo”,那么类Bar可以继承类Foo。不幸的是,仅此测试不可靠,请使用以下代码代替:

  1. 酒吧是一个foo,
  2. 酒吧可以做任何足球可以做的事情。

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

示例:狗/动物

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

反例:圆形/椭圆

圆是一个椭圆,但是圆不能做椭圆能做的所有事情。例如,圆不能伸展,而椭圆可以。因此,类Circle不能继承了类Ellipse

这被称为圆椭圆问题,这不是一个真正的问题,但更多的是表明“bar is a foo”本身不是一个可靠的测试。特别是,这个例子强调了派生类应该延长基类的功能,而不是限制基类的功能。否则,基类不能被多态使用。添加测试“bar可以做所有fos能做的事情”确保了多态使用是可能的,相当于李斯科夫替换原理

使用基类指针或引用的函数必须能够使用派生类的对象而不知道它

什么时候应该使用继承?

即使你可以使用继承并不意味着你应该:使用组合总是一种选择。继承是一个强大的工具,允许隐式代码重用和动态调度,但它确实有一些缺点,这就是为什么通常首选组合。继承和组合之间的权衡并不明显,在我看来最好在lcn的回答中解释。

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

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

继承的优点:

  1. 它建立了一个逻辑"是一个"关系。如果汽车卡车车辆(基类)的两种类型,则子类是一个基类。

    汽车是一种交通工具

    卡车是一种交通工具

  2. 使用继承,您可以定义/修改/扩展功能

    1. 基类不提供实现,子类必须覆盖完整的方法(抽象)=>您可以执行合同
    2. 基类提供默认实现,子类可以更改行为=>你可以重新定义合同
    3. 子类通过调用super.methodName()作为第一个语句=>你可以延长合同来添加基类实现的扩展
    4. 基类定义了算法的结构,子类将覆盖算法的一部分=>你可以实现Template_method

构图的缺点:

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

例如,如果汽车包含车辆,如果您必须获得车辆中定义的汽车的价格,您的代码将如下所示

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

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

这是传统的建议,如果这让你满意,你可以停止阅读这里,继续你的快乐之路。

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

例如:

  • 一个正方形是一个矩形,但是如果你的矩形类有setWidth()/setHeight()方法,那么没有合理的方法可以在不破坏李斯科夫替代原理的情况下从Rectangle继承Square
  • 一个是-一个关系通常可以被改写为听起来像一个有-一个关系。例如,一个雇员是-一个人,但一个人也有-一个“受雇”的就业状态。
  • 如果你不小心,is-a关系可能会导致令人讨厌的多重继承层次结构。毕竟,英语中没有规则规定对象正好是一件事。
  • 人们很快就会传递这个“规则”,但是有没有人试图支持它,或者解释为什么它是一个很好的启发式遵循?当然,它非常适合OOP应该模拟现实世界的想法,但这本身并不是采用原则的理由。

有关此主题的更多阅读,请参阅这个 StackOverflow问题。

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

实现继承的问题

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

  • 遵循在基本方法和子类方法之间编织的逻辑可能很困难。
  • 通过调用另一个可重写的方法来粗心地实现类中的一个方法将导致您泄漏实现细节并破坏封装,因为最终用户可能会重写您的方法并在您内部调用它时检测到它。(参见“有效Java”项18)。
  • 脆弱基底问题,它只是指出你的最终用户的代码如果碰巧依赖于当你试图更改它们时实现细节的泄漏,它们就会崩溃。更糟糕的是,大多数OOP语言默认允许继承——没有主动阻止人们继承公共类的API设计者在重构基类时需要格外小心。不幸的是,脆弱的基类问题经常被误解,导致许多人不明白维护一个任何人都可以继承的类需要什么。
  • 致命的死亡钻石

构图的问题

  • 有时可能有点冗长。

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

什么时候应该使用继承?

下次当你为一个项目绘制花哨的UML图时(如果你这样做的话),并且你正在考虑添加一些继承,请坚持以下建议:不要。

至少现在还没有。

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

为了避免这种情况,我的建议是永远不要公开你的基类。

  • 如果您需要多态性,请使用接口。
  • 如果你需要允许人们自定义你的类的行为,通过的战略模式提供显式的钩子点,这是一个更可读的方式来实现这一点,另外,保持这种API的稳定更容易,因为你完全控制了他们可以和不能改变的行为。
  • 如果你试图遵循开放-封闭原则,使用继承来避免向类中添加急需的更新,那就不要这样做。更新类。如果你真正拥有受雇维护的代码的所有权,而不是试图将东西钉在它的一边,你的代码库会更干净。如果你害怕引入错误,那么让现有代码接受测试。
  • 如果您需要重用代码,首先尝试使用组合或辅助函数。

最后,如果你已经决定没有其他好的选择,你必须使用继承来实现你需要的代码重用,那么你可以使用它,但是,遵循这四个受限继承的P. A. I. L.规则来保持它的理智。

  1. 使用继承作为私人实现细节。不要公开你的基类,为此使用接口。这让你可以自由地添加或删除继承,而无需进行重大更改。
  2. 保留你的基类抽象。它可以更容易地将需要共享的逻辑与不需要共享的逻辑分开。
  3. 隔离你的基类和子类。不要让你的子类覆盖基类方法(使用策略模式),并避免让它们期望属性/方法相互存在,使用其他形式的代码共享来实现这一点。使用适当的语言特性强制基类上的所有方法都是不可覆盖的(Java中的“Final”或C#中的“非虚拟”)。
  4. 继承是一个最后的手段。

特别是隔离规则可能听起来有点粗糙,但如果你自律,你会得到一些相当不错的好处。特别是,它让你可以自由地避免与上述继承相关的所有主要讨厌的陷阱。

  • 遵循代码要容易得多,因为它不会进出基类/子类。
  • 如果您从未使任何方法可重写,那么当您的方法在内部调用其他可重写方法时,您就不会意外泄漏。换句话说,您不会意外破坏封装。
  • 脆弱基类问题源于依赖意外泄露的实现细节的能力。由于基类现在是隔离的,它不会比依赖另一个通过组合的类更脆弱。
  • 死亡钻石不再是问题,因为根本不需要有多层继承。如果你有抽象基类B和C,它们都共享很多功能,只需将该功能从B和C移出到一个新的抽象基类D中。任何从B继承的人都应该更新为从B和D继承,任何从C继承的人都应该从C和D继承。由于你的基类都是私有实现细节,弄清楚谁在继承什么,进行这些更改应该不会太难。

结论

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

进一步阅读:

  • 一篇文章我写了关于这个主题的文章,深入研究并提供了例子。
  • 一个网页讨论了继承所做的三种不同的工作,以及如何通过Go语言中的其他方式完成这些工作。
  • 一份名单个将类声明为不可继承的原因(例如Java中的“Final”)。
  • 约书亚·布洛赫的“有效Java”一书,第18项,讨论了继承的组合,以及继承的一些危险。

简单地说,实现就像你(类)应该遵循的规则

但是扩展就像对很多类使用通用代码,也许只是其中一个你需要覆盖。

只是意见,继承只应该在以下情况下使用:

  • 两个类都在同一个逻辑域中
  • 子类是父类的正确子类型
  • 超类的实现对于子类是必要的或适当的
  • 子类所做的增强主要是可加的。

有些时候,所有这些事情都会融合在一起:

  • 高级域建模
  • 框架和框架扩展
  • 微分规划