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


当前回答

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

其他回答

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

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

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

原因:子类化通常意味着更复杂和更紧密,即更难在不出错的情况下进行更改、维护和扩展。

Sun的Tim Boudreau给出了一个更完整、更具体的答案:

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

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

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

当我们说数字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);
     }
  }
}

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

这是一个不正确使用继承的典型例子。在这种情况下使用合成。

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

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

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

is-a/has-a比较存在问题

例如:

正方形是矩形,但如果矩形类具有setWidth()/setHeight()方法,那么没有合理的方法可以在不违反Liskov替换原则的情况下使正方形继承自矩形。is-a关系通常可以被改写为听起来像has-a关系。例如,员工是一个人,但一个人也具有“已就业”的就业状态。如果不小心,is-a关系可能会导致讨厌的多重继承层次结构。毕竟,英语中没有一条规则规定一个对象是一件事情。人们很快就把这条“规则”传递出去了,但有没有人试图支持它,或者解释为什么这是一个很好的启发式方法?当然,这很符合OOP应该模拟真实世界的想法,但这本身并不是采用原则的理由。

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

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

实现继承的问题

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

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

构图的问题

有时可能有点冗长。

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

什么时候应该使用继承?

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

至少目前还没有。

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

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

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

最后,如果您决定没有其他好的选择,并且必须使用继承来实现所需的代码重用,那么您可以使用它,但是,请遵循P.A.I.L.限制继承的四条规则,以保持其正常。

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

特别是“隔离”规则可能听起来有点难理解,但如果你自律,你会得到一些很好的好处。特别是,它让您可以自由地避免上面提到的与继承相关的所有主要令人讨厌的陷阱。

遵循代码要容易得多,因为它不会在基类/子类之间来回穿梭。如果您从未使任何方法可重写,则当您的方法在内部调用其他可重写方法时,不会意外泄漏。换句话说,您不会意外地破坏封装。脆弱的基类问题源于依赖意外泄漏的实现细节的能力。由于基类现在是孤立的,所以它不会比依赖于另一个via组合的类更脆弱。致命的死亡钻石不再是一个问题,因为根本不需要有多层继承。如果你有抽象基类B和C,它们都共享很多功能,只需将该功能从B和C中移出,并移入一个新的抽象基类类D。从B继承的任何人都应该更新为从B和D继承,从C继承的任何人均应该从C和D继承。由于你的基类都是私有实现细节,要弄清楚谁继承了什么,做出这些改变应该不会太难。

结论

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

进一步阅读:

我写了一篇关于这一主题的文章,深入探讨并提供了示例。一个网页,讲述了继承所做的三种不同的工作,以及如何通过Go语言中的其他方式完成这些工作。将类声明为不可继承(例如,Java中的“final”)是很好的原因列表。乔舒亚·布洛赫(Joshua Bloch)的《有效的Java》(Effective Java)一书,第18项,讨论了组合而非继承,以及继承的一些危险。

你什么时候可以用作文?

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

什么时候可以使用继承?

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

bar是foo,AND酒吧可以做foos所能做的一切。

第一个测试确保Foo的所有getter在Bar中都有意义(=共享的财产),而第二个测试确保所有Foo的setter都在Bar(=共享功能)中有意义。

示例:狗/动物

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

反例:圆/椭圆

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

这被称为圆椭圆问题,这并不是一个真正的问题,但更多的是表明“一个条就是一个foo”本身不是一个可靠的测试。特别是,这个例子强调了派生类应该扩展基类的功能,而不是限制它。否则,基类不能以多态的方式使用。添加测试“bar可以做foos所能做的一切”确保了多态性的使用是可能的,这相当于Liskov替换原则:

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

什么时候应该使用继承?

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

作为经验法则,当多态性使用被认为非常普遍时,我倾向于选择继承而不是组合,在这种情况下,动态分派的功能可以导致更可读和更优雅的API。例如,在GUI框架中有一个多态类Widget,或者在XML库中有一种多态类Node,这样就可以使用一个比纯基于组合的解决方案更易读、更直观的API。