为什么更喜欢组合而不是继承?每种方法都有哪些权衡?什么时候应该选择继承而不是组合?
当前回答
你需要看看Bob叔叔的SOLID类设计原则中的Liskov替换原则。:)
其他回答
除了是/有一个考虑因素之外,还必须考虑对象必须经历的继承的“深度”。任何超过五或六级继承深度的内容都可能会导致意外的强制转换和装箱/拆箱问题,在这种情况下,编写对象可能是明智的。
我听过的一条经验法则是,当它是一种“is-A”关系时,应该使用继承;当它是“has-A”关系的时候,应该使用组合。尽管如此,我觉得你应该始终倾向于构图,因为它消除了很多复杂性。
当您想要“复制”/公开基类的API时,可以使用继承。当您只想“复制”功能时,请使用委派。
其中的一个例子是:您想要从列表中创建堆栈。堆栈只有pop、push和peek。考虑到堆栈中不需要push_back、push_front、removeAt等功能,您不应该使用继承。
如果你想得到自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项,讨论了组合而非继承,以及继承的一些危险。
如果你明白其中的区别,那么解释起来就更容易了。
程序代码
这方面的一个例子是不使用类的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将返回Employees的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的原因。
继承上的组合
现在,假设您要创建一个管理器类型,这样您就可以:
class Manager : Person, Employee {
...
}
但是,如果Person和Employee都声明了Title,那么这个示例会很好用?经理头衔应该返回“运营经理”还是“先生”?在合成中,这种模糊性得到了更好的处理:
Class Manager {
public string Title;
public Manager(Person p, Employee e)
{
this.Title = e.Title;
}
}
Manager对象由Employee和Person组成。标题行为取自雇员。这种显式组合消除了其他问题中的歧义,您将遇到更少的错误。