为什么更喜欢组合而不是继承?每种方法都有哪些权衡?什么时候应该选择继承而不是组合?
当前回答
就我个人而言,我学会了总是喜欢组合而不是继承。没有可以用继承解决的编程问题,而不能用组合解决;尽管在某些情况下,您可能必须使用接口(Java)或协议(Obj-C)。因为C++不知道任何这类东西,所以您必须使用抽象基类,这意味着您无法完全摆脱C++中的继承。
组合通常更符合逻辑,它提供了更好的抽象、更好的封装、更好的代码重用(尤其是在非常大的项目中),并且不太可能因为您在代码中的任何地方进行了孤立的更改而在远处破坏任何东西。这也使得维护“单一责任原则”变得更容易,该原则通常被概括为“一个类的改变不应该有一个以上的原因”,这意味着每个类都是为了一个特定的目的而存在的,它只应该有与其目的直接相关的方法。此外,拥有一个非常浅的继承树,即使您的项目开始变得非常大,也可以更容易地保持概述。许多人认为遗产很好地代表了我们的现实世界,但事实并非如此。现实世界使用的合成比继承多得多。几乎每一个你能拿在手中的现实世界物体都是由其他更小的现实世界对象组成的。
不过,组合也有缺点。如果您完全跳过继承,只关注组合,您会注意到,如果您使用了继承,通常需要编写一些额外的代码行。你有时也会被迫重复自己,这违反了干原则(干=不要重复自己)。此外,组合通常需要委托,一个方法只是调用另一个对象的另一个方法,而没有其他代码围绕此调用。这种“双重方法调用”(可能很容易扩展到三重或四重方法调用,甚至更远)的性能要比继承差得多,因为继承只是继承父级的方法。调用继承的方法可能与调用非继承的方法一样快,也可能稍慢,但通常仍比两个连续的方法调用快。
您可能已经注意到,大多数OO语言不允许多重继承。虽然在一些情况下,多重继承确实可以为您带来一些东西,但这些都是例外,而不是规则。每当你遇到一个你认为“多重继承将是解决这个问题的一个非常酷的特性”的情况时,你通常都应该重新考虑继承,因为即使它可能需要一些额外的代码行,基于组合的解决方案通常会变得更加优雅、灵活和经得起未来考验。
继承确实是一个很酷的功能,但我担心它在过去几年中被过度使用了。人们将遗产视为一把能钉住一切的锤子,不管它实际上是一颗钉子、一颗螺丝钉,还是一个完全不同的东西。
其他回答
我听过的一条经验法则是,当它是一种“is-A”关系时,应该使用继承;当它是“has-A”关系的时候,应该使用组合。尽管如此,我觉得你应该始终倾向于构图,因为它消除了很多复杂性。
更喜欢组合而不是继承,因为它更具延展性/更易于稍后修改,但不要使用组合始终方法。通过组合,使用依赖注入/设置器可以很容易地动态改变行为。继承更为严格,因为大多数语言不允许从多个类型派生。因此,一旦你从A型衍生出,鹅或多或少都是熟的。
我的上述酸性测试是:
TypeB是否希望公开TypeA的完整接口(所有公共方法都不少于),以便在需要TypeA的地方使用TypeB?表示继承。例如,赛斯纳双翼飞机将暴露出飞机的完整界面,如果不是更多的话。因此,它适合于从飞机衍生。TypeB是否只想要TypeA暴露的部分行为?表示需要合成。例如,鸟可能只需要飞机的飞行行为。在这种情况下,将其作为接口/类/两者提取出来并使其成为两个类的成员是有意义的。
更新:刚刚回到我的答案,现在看来,如果没有具体提到芭芭拉·里斯科夫的里斯科夫替代原则作为“我应该继承这种类型吗?”的测试,它似乎是不完整的
如果你想得到自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项,讨论了组合而非继承,以及继承的一些危险。
我看没有人提到钻石问题,这可能是继承带来的。
总之,如果类B和C继承了a,并且都重写了方法X,而第四个类D继承了B和C,并且没有重写X,那么应该使用X D的哪个实现?
维基百科对这个问题中讨论的主题提供了一个很好的概述。
假设飞机只有两个部分:发动机和机翼。然后有两种设计飞机等级的方法。
Class Aircraft extends Engine{
var wings;
}
现在你的飞机可以从固定机翼开始并在飞行中将其改为旋转翼。它本质上是有翅膀的发动机。但如果我想改变呢发动机也在飞行?
基类Engine公开一个赋值函数以更改其财产,或我将飞机重新设计为:
Class Aircraft {
var wings;
var engine;
}
现在,我也可以随时更换发动机。