提问人:readonly 提问时间:9/8/2008 最后编辑:Mateen Ulhaqreadonly 更新时间:3/28/2023 访问量:473508
更喜欢组合而不是继承?
Prefer composition over inheritance?
答:
把收容看作是一种关系。一辆车“有”发动机,一个人“有”名字,等等。
把继承看作是一种关系。汽车“是”车辆,人“是”哺乳动物,等等。
我不认为这种方法是可信的。我直接从 Steve McConnell 的 Code Complete 第二版第 6.3 节中获取了它。
评论
我听说过的一个经验法则是,当它是“is-a”关系时,应该使用继承,当它是“has-a”时,应该使用组合。即便如此,我觉得你应该始终倾向于构图,因为它消除了很多复杂性。
在 Java 或 C# 中,对象一旦实例化就无法更改其类型。
因此,如果您的对象需要显示为不同的对象,或者根据对象状态或条件而采取不同的行为,请使用组合:请参阅状态和策略设计模式。
如果对象需要属于同一类型,则使用 Inheritance 或实现接口。
评论
Client
PreferredClient
PreferredClient
Client
client.makePreferred()
Account
Client
Account
StandardAccount
PreferredAccount
继承非常强大,但你不能强迫它(参见:圆椭圆问题)。如果你真的不能完全确定真正的“is-a”亚型关系,那么最好选择组合。
另一个非常务实的原因,更喜欢组合而不是继承,这与你的领域模型有关,并将其映射到关系数据库。将继承映射到 SQL 模型真的很难(你最终会得到各种棘手的解决方法,比如创建并不总是使用的列、使用视图等)。一些ORML试图解决这个问题,但它总是很快变得复杂。组合可以很容易地通过两个表之间的外键关系进行建模,但继承要困难得多。
除了 is a/has a considerations,还必须考虑对象必须经历的继承的“深度”。任何超过五到六级继承深度的东西都可能导致意外的投射和装箱/拆箱问题,在这些情况下,组合对象可能是明智的。
比起继承,更喜欢组合,因为它更具延展性/易于以后修改,但不要使用始终组合的方法。通过组合,可以很容易地使用依赖注入/Setter 即时更改行为。继承更加严格,因为大多数语言不允许从多个类型派生。因此,一旦您从 TypeA 衍生出来,鹅或多或少就会煮熟。
我对上述的酸性测试是:
TypeB 是否要公开 TypeA 的完整接口(所有公共方法不少于),以便 TypeB 可以在需要 TypeA 的地方使用?指示继承。
- 例如,一架塞斯纳双翼飞机将暴露飞机的完整界面,如果不是更多的话。因此,这使得它适合从 Airplane 派生。
TypeB 是否只想要 TypeA 暴露的部分/部分行为?表示需要合成。
- 例如,一只鸟可能只需要飞机的飞行行为。在这种情况下,将其提取为接口/类/两者并使其成为两个类的成员是有意义的。
更新:刚刚回到我的答案,现在看来,如果没有具体提到芭芭拉·利斯科夫(Barbara Liskov)的李斯科夫替代原则作为“我应该继承这种类型吗?
评论
当你想要“复制”/公开基类的 API 时,你使用继承。如果只想“复制”功能,请使用委派。
举个例子:你想从列表中创建一个堆栈。堆栈只有 pop、push 和 peek。您不应该使用继承,因为您不希望堆栈中出现 push_back、push_front、removeAt 等类型的功能。
评论
虽然简而言之,我同意“更喜欢组合而不是继承”,但对我来说,这听起来像是“更喜欢土豆而不是可口可乐”。有继承的地方,也有作曲的地方。你需要了解差异,那么这个问题就会消失。对我来说,这真正意味着“如果你要使用继承——再想一想,你很可能需要组合”。
当你想吃的时候,你应该更喜欢土豆而不是可口可乐,当你想喝的时候,你应该更喜欢可口可乐而不是土豆。
创建子类应该不仅仅意味着调用超类方法的便捷方式。当子类“is-a”超类在结构和功能上都可以用作超类并且您将使用它时,您应该使用继承。如果不是这样 - 这不是继承,而是其他东西。构图是指您的对象由另一个对象组成,或与它们有某种关系。
所以对我来说,如果一个人不知道他是否需要继承或组成,真正的问题是他不知道他是否想喝酒或吃饭。多想想你的问题领域,更好地理解它。
评论
InternalCombustionEngine
GasolineEngine
InternalCombustionEngine
如果你了解其中的区别,就更容易解释。
程序守则
这方面的一个例子是没有使用类的 PHP(尤其是在 PHP5 之前)。所有逻辑都编码在一组函数中。您可以包含包含帮助程序函数等的其他文件,并通过在函数中传递数据来执行业务逻辑。随着应用程序的增长,这可能很难管理。PHP5 试图通过提供更加面向对象的设计来解决这个问题。
遗产
这鼓励使用类。继承是面向对象设计的三大原则之一(继承、多态、封装)。
class Person {
String Title;
String Name;
Int Age
}
class Employee : Person {
Int Salary;
String Title;
}
这是工作中的继承。“is a”或继承自 。所有继承关系都是“is-a”关系。 还隐藏了 的属性,这意味着将返回 for 而不是 。Employee
Person
Person
Employee
Title
Person
Employee.Title
Title
Employee
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);
组合物通常是“具有”或“使用”的关系。这里的类有一个 .它不继承对象,而是获取传递给它的对象,这就是它“有一个”Person的原因。Employee
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;
}
}
该对象由 an 和 .该行为取自 。这种明确的组合消除了歧义等,您将遇到更少的错误。Manager
Employee
Person
Title
Employee
评论
继承在子类和超类之间建立了牢固的关系;Subclass 必须了解超类的实现细节。创建超类要困难得多,因为您必须考虑如何扩展它。您必须仔细记录类不变量,并说明可重写方法在内部使用的其他方法。
如果层次结构确实表示一种关系,则继承有时很有用。它与开放-封闭原则有关,该原则指出,类应该关闭以进行修改,但可以扩展。这样你就可以拥有多态性;有一个处理超类型及其方法的泛型方法,但通过动态调度调用子类的方法。这很灵活,有助于创建间接,这在软件中是必不可少的(对实现细节的了解较少)。
但是,继承很容易被过度使用,并产生额外的复杂性,类之间存在硬依赖关系。此外,由于方法调用的层和动态选择,理解程序执行期间发生的情况变得非常困难。
我建议使用作曲作为默认设置。它更加模块化,并具有后期绑定的优点(您可以动态更改组件)。此外,单独测试这些东西也更容易。如果你需要使用类中的方法,你不会被迫具有某种形式(Liskov 替换原理)。
评论
Inheritance is sometimes useful... That way you can have polymorphism
尽管继承提供了所有不可否认的好处,但这里有一些缺点。
继承的缺点:
- 您不能在运行时更改从超类继承的实现(显然,因为继承是在编译时定义的)。
- 继承将子类暴露给其父类实现的细节,这就是为什么人们常说继承会破坏封装(从某种意义上说,你真的需要只关注接口而不是实现,所以子类重用并不总是首选)。
- 继承提供的紧密耦合使得子类的实现与超类的实现非常紧密地联系在一起,父实现中的任何更改都将迫使子类更改。
- 子类的过度重用会使继承堆栈非常深,也非常混乱。
另一方面,对象组合是在运行时通过对象获取对其他对象的引用来定义的。在这种情况下,这些对象将永远无法访问彼此的受保护数据(不会发生封装中断),并且将被迫尊重彼此的接口。在这种情况下,实现依赖性将比继承的情况少得多。
评论
你想强迫自己(或其他程序员)遵守什么,什么时候你想给自己(或其他程序员)更多的自由。有人认为,当你想强迫某人采取某种处理/解决特定问题的方式时,继承是有帮助的,这样他们就不会朝着错误的方向前进。
Is-a
并且是一个有用的经验法则。Has-a
继承是相当诱人的,特别是来自程序土地,它通常看起来很优雅。我的意思是我需要做的就是将这一点功能添加到其他类中,对吧?嗯,其中一个问题是
遗传可能是最糟糕的耦合形式
基类通过以受保护成员的形式向子类公开实现详细信息来中断封装。这会使您的系统变得僵硬和脆弱。然而,更悲惨的缺陷是,新的子阶级带来了继承链的所有包袱和观点。
Inheritance is Evil: The Epic Fail of the DataAnnotationsModelBinder 一文介绍了 C# 中的一个示例。它显示了在应该使用组合时使用继承,以及如何重构它。
评论
你需要看看鲍勃叔叔的类设计的 SOLID 原则中的 Liskov 替换原则。:)
假设一架飞机只有两个部分:发动机和机翼。
然后有两种方法可以设计飞机类别。
Class Aircraft extends Engine{
var wings;
}
现在,您的飞机可以从固定机翼开始
,然后将它们更改为旋转翼。它本质上是
一个带翅膀的引擎。但是,如果我也想即时更换
引擎怎么办?
要么基类公开一个赋值器来更改其
属性,要么我重新设计为:Engine
Aircraft
Class Aircraft {
var wings;
var engine;
}
现在,我也可以即时更换发动机。
评论
在可以枚举不变量的情况下,子类型是合适的,并且更强大,否则使用函数组合来实现可扩展性。
我同意@Pavel,当他说时,有组成的地方,也有继承的地方。
我认为,如果你的回答对这些问题中的任何一个是肯定的,就应该使用继承。
- 你的类是受益于多态的结构的一部分吗?例如,如果你有一个 Shape 类,它声明了一个名为 draw() 的方法,那么我们显然需要 Circle 和 Square 类是 Shape 的子类,以便它们的客户端类将依赖于 Shape,而不是特定的子类。
- 您的类是否需要重用在另一个类中定义的任何高级交互?如果没有继承,就不可能实现模板方法设计模式。我相信所有可扩展的框架都使用这种模式。
但是,如果你的意图纯粹是代码重用,那么组合很可能是更好的设计选择。
我的一般经验法则:在使用继承之前,请考虑组合是否更有意义。
原因:子类化通常意味着更多的复杂性和连接性,即更难在不犯错误的情况下进行更改、维护和扩展。
Sun 的 Tim Boudreau 给出了一个更完整、更具体的答案:
在我看来,使用继承的常见问题是:
- 无辜的行为可能会产生意想不到的结果 - 这方面的典型例子是调用超类中的可重写方法 构造函数,在子类实例字段之前 初始 化。在一个完美的世界里,没有人会这样做。这是 不是一个完美的世界。
- 它为子类化者提供了不正当的诱惑,让他们对方法调用的顺序等做出假设——这样的假设往往不会 如果超类可能随时间推移而演变,则保持稳定。另见我的烤面包机 和咖啡壶的类比。
- 类变得更重 - 你不一定知道你的超类在其构造函数中做了什么工作,或者它要占用多少内存 使用。因此,构建一些无辜的轻量级物体可以 比你想象的要贵得多,如果 超类进化
- 它鼓励子类的爆炸式增长。类加载会消耗时间,更多的类会消耗内存。在您之前,这可能不是问题 处理 NetBeans 规模的应用程序,但在那里,我们有真正的 例如,由于第一次显示,菜单速度很慢的问题 的菜单触发了大量类加载。我们通过移动到 更多的声明性语法和其他技术,但这需要花费时间 也修复。
- 这使得以后更难更改内容 - 如果您已将一个类公开,则交换超类将破坏子类 - 这是一个选择,一旦你公开了代码,你就结婚了 自。因此,如果您不将实际功能更改为您的 Superclass,如果你 使用,而不是扩展你需要的东西。举个例子, 子类化 JPanel - 这通常是错误的;如果子类是 在某个地方公开,你永远没有机会重新审视这个决定。如果 它以 JComponent getThePanel() 的形式访问,您仍然可以这样做(提示: 将其中组件的模型公开为 API)。
- 对象层次结构无法扩展(或者以后扩展它们比提前计划要困难得多)——这是经典的“层太多” 问题。我将在下面讨论这个问题,以及 AskTheOracle 模式如何 解决它(尽管它可能会冒犯 OOP 纯粹主义者)。
...
我对该怎么做的看法,如果你允许继承,你可以 用一粒盐服用是:
- 从不公开任何字段,除了常量
- 方法应为抽象方法或最终方法
- 不从超类构造函数调用任何方法
...
所有这些都不适用于小型项目,而不是大型项目,而且更少 私人课程比公共课程更重要
正如许多人所说,我将首先从检查开始——是否存在“is-a”关系。如果存在,我通常会检查以下内容:
是否可以实例化基类。也就是说,基类是否可以是非抽象的。如果可以是非抽象的,我通常更喜欢构图
例如 1.会计师是雇员。但我不会使用继承,因为 Employee 对象可以实例化。
例如 2.Book 是一个 SellingItem。SellingItem 不能实例化 - 它是抽象概念。因此,我将使用继承性痤疮。SellingItem 是一个抽象基类(或 C# 中的接口)
您如何看待这种方法?
另外,我支持@anon为什么要使用继承?
使用继承的主要原因不是作为一种组合形式 - 这是为了让你可以获得多态行为。如果不需要多态性,则可能不应使用继承。
@MatthieuM。https://softwareengineering.stackexchange.com/questions/12439/code-smell-inheritance-abuse/12448#comment303759_12448 说
继承的问题在于它可以用于两个正交目的:
接口(用于多态性)
实现(用于代码重用)
参考
评论
就我个人而言,我学会了总是更喜欢作曲而不是继承。没有编程问题可以通过继承来解决,而组合无法解决;尽管在某些情况下您可能必须使用接口(Java)或协议(Obj-C)。由于 C++ 不知道任何这样的东西,你必须使用抽象基类,这意味着你不能完全摆脱 C++ 中的继承。
组合通常更合乎逻辑,它提供了更好的抽象、更好的封装、更好的代码重用(尤其是在非常大的项目中),并且不太可能仅仅因为您在代码中的任何位置进行了孤立的更改而破坏任何内容。这也使得维护“单一责任原则”变得更加容易,“单一责任原则”通常被概括为“一个类的改变不应该有多个原因”,这意味着每个类都是为了一个特定的目的而存在的,它应该只具有与其目的直接相关的方法。此外,拥有非常浅的继承树可以更容易地保持概览,即使你的项目开始变得非常大。许多人认为继承很好地代表了我们的现实世界,但事实并非如此。现实世界使用的组合比继承要多得多。几乎每个你能拿在手里的现实世界物体都是由其他更小的现实世界物体组成的。
不过,构图也有缺点。如果你完全跳过继承,只关注组合,你会注意到,你经常需要编写一些额外的代码行,如果你使用了继承,这些代码行就没有必要了。您有时也被迫重复自己,这违反了 DRY 原则(DRY = 不要重复自己)。此外,组合通常需要委托,并且方法只是调用另一个对象的另一个方法,而没有围绕此调用的其他代码。这种“双重方法调用”(可能很容易扩展到三重或四重方法调用,甚至更远)的性能比继承要差得多,在继承中,您只是继承父方法的方法。调用继承的方法可能与调用非继承的方法一样快,也可能稍慢,但通常仍比连续调用两个方法快。
您可能已经注意到,大多数面向对象语言都不允许多重继承。虽然在几种情况下,多重继承确实可以给你买到一些东西,但这些都是例外,而不是规则。每当你遇到你认为“多重继承将是一个非常酷的功能来解决这个问题”的情况时,你通常应该完全重新考虑继承,因为即使它可能需要几行额外的代码,基于组合的解决方案通常会变得更加优雅, 灵活且面向未来。
继承确实是一个很酷的功能,但恐怕它在过去几年中被过度使用。人们把遗产看作是一把锤子,可以钉住一切,不管它实际上是钉子、螺丝钉,还是完全不同的东西。
评论
TextFile
File
这两种方式可以很好地生活在一起,实际上可以相互支持。
组合只是模块化的:你创建类似于父类的接口,创建新对象并委托对它的调用。如果这些对象不需要相互了解,那么构图是相当安全且易于使用的。这里有很多可能性。
但是,如果父类出于某种原因需要访问“子类”为没有经验的程序员提供的函数,那么它可能看起来是使用继承的好地方。父类可以调用它自己的抽象“foo()”,它被子类覆盖,然后它可以将值赋予抽象基。
这看起来是个好主意,但在许多情况下,最好给类一个实现 foo() 的对象(甚至手动设置 foo() 提供的值),而不是从某个需要指定函数 foo() 的基类继承新类。
为什么?
因为继承是移动信息的一种糟糕的方式。
这种组合在这里有一个真正的优势:这种关系可以颠倒过来:“父类”或“抽象工作者”可以聚合任何特定的“子”对象来实现特定的接口+任何子对象都可以设置在任何其他类型的父类中,父类接受它的类型。并且可以有任意数量的对象,例如 MergeSort 或 QuickSort 可以对实现抽象 Compare 接口的任何对象列表进行排序。或者换一种说法:任何一组实现“foo()”的对象和其他一组可以使用具有“foo()”的对象都可以一起玩。
我能想到使用继承的三个真正原因:
- 您有许多具有相同接口的类,并且希望节省编写它们的时间
- 必须对每个对象使用相同的基类
- 需要修改私有变量,在任何情况下都不能是公共变量
如果这些是真的,那么可能有必要使用继承。
使用原因 1 没有什么不好,在对象上有一个坚实的界面是非常好的事情。这可以使用组合或继承来完成,没问题 - 如果这个接口很简单并且不会改变。通常遗传在这里非常有效。
如果原因是第 2 点,那就有点棘手了。你真的只需要使用同一个基类吗?通常,仅使用相同的基类是不够的,但它可能是框架的要求,是无法避免的设计考虑。
但是,如果您想使用私有变量(情况 3),那么您可能会遇到麻烦。如果你认为全局变量不安全,那么你应该考虑使用继承来访问同样不安全的私有变量。请注意,全局变量并不全是坏事 - 数据库本质上是一大组全局变量。但如果你能处理它,那就很好了。
继承是代码重用的一种非常强大的机制。但需要正确使用。我想说的是,如果子类也是父类的子类型,则正确使用继承。如上所述,李氏替代原理是这里的关键点。
子类与子类不同。您可以创建不是子类型的子类(此时应使用组合)。要了解什么是子类型,让我们开始解释什么是类型。
当我们说数字 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) 方法的行为已明显更改,不保留父类型的属性。从班级客户的角度思考。他们可能会收到一组整数,其中需要整数列表。客户端可能希望添加一个值,并将该值添加到列表中,即使该值已存在于列表中。但如果价值存在,她就不会有这种行为。对她来说是一个很大的惊喜!
这是不当使用继承的典型例子。在这种情况下使用组合。
(片段来自:正确使用继承)。
当你在两个类之间有一个 is-a 关系时(例如狗是犬科动物),你就会去继承。
另一方面,当你在两个班级(学生有课程)或(教师研究课程)之间有或某种形容词关系时,你选择了作文。
评论
这条规则完全是无稽之谈。为什么?
原因是在每种情况下都可以判断是使用组合还是继承。 这是由一个问题的答案决定的:“是某物是别的东西”或“有” 某事 A 某事
你不能“喜欢”让某样东西成为别的东西或拥有别的东西。严格的逻辑规则适用。
此外,也没有“人为的例子”,因为在每种情况下都可以给出这个问题的答案。
如果你不能回答这个问题,那就有别的问题了。 这包括 类的职责重叠通常是由于错误使用接口造成的,较少是通过在不同的类中重写相同的代码。
为了避免这种情况,我还建议对类使用好的名字,这些名称完全类似于他们的职责。
评论
Foo
Bar
Bar
Foo
作文与继承是一个广泛的主题。对于什么更好,没有真正的答案,因为我认为这完全取决于系统的设计。
通常,对象之间的关系类型提供了更好的信息来选择其中之一。
如果关系类型是“IS-A”关系,则继承是更好的方法。 否则,关系类型为“HAS-A”关系,则组合将更好地接近。
它完全取决于实体关系。
理解这一点的一个简单方法是,当您需要类的对象与其父类具有相同的接口时,应该使用继承,以便可以将其视为父类的对象(向上转换)。此外,对派生类对象的函数调用在代码中的任何位置都保持不变,但要调用的特定方法将在运行时确定(即低级实现不同,高级接口保持不变)。
当你不需要新类具有相同的接口时,应该使用组合,即你希望隐藏类实现的某些方面,而该类的用户不需要知道。因此,组合更多地是支持封装(即隐藏实现),而继承则意味着支持抽象(即提供某些东西的简化表示,在这种情况下,为具有不同内部结构的一系列类型提供相同的接口)。
评论
我看到没有人提到钻石问题,这可能与继承有关。
乍一看,如果类 B 和 C 继承了 A 并且都覆盖了方法 X,而第四类 D 继承了 B 和 C,并且不覆盖 X,那么 X D 应该使用哪个实现?
维基百科对这个问题中讨论的主题提供了一个很好的概述。
评论
X
在这里没有找到满意的答案,所以我写了一个新的。
要理解为什么“更喜欢组合而不是继承”,我们首先需要找回这个缩短的成语中省略的假设。
继承有两个好处:子类型化和子类化
子类型化意味着符合一个类型(接口)签名,即一组 API,可以覆盖部分签名以实现子类型多态性。
子类化意味着对方法实现的隐式重用。
有了这两个好处,继承就有两个不同的目的:面向子类型和面向代码重用。
如果代码重用是唯一的目的,那么子类化可能会给人带来比他需要的更多的东西,即父类的某些公共方法对子类没有多大意义。在这种情况下,与其偏爱组合而不是继承,不如要求组合。这也是“是”与“有”概念的来源。
因此,只有当子类型化是有目的的,即以后以多态方式使用新类时,我们才会面临选择继承或组合的问题。这是在讨论的缩短成语中被省略的假设。
子类型就是符合类型签名,这意味着组合必须始终公开不少于该类型的 API 数量。现在,权衡开始了:
继承提供了直接的代码重用(如果不是被重写的话),而组合必须重新编码每个 API,即使它只是一个简单的委派工作。
继承通过内部多态站点提供直接的开放递归,即在另一个成员函数中调用覆盖方法(甚至类型),无论是公共的还是私有的(尽管不鼓励)。开放递归可以通过组合来模拟,但它需要额外的努力,并且可能并不总是可行的(?)。这个重复问题的答案也说了类似的话。
this
继承公开受保护的成员。这会破坏父类的封装,如果由子类使用,则会在子类与其父类之间引入另一个依赖关系。
合成紧随编程到接口之后。
组合具有易于多重遗传的优点。
考虑到上述权衡,因此我们更喜欢组合而不是继承。然而,对于紧密相关的类,即当隐式代码重用确实有好处时,或者需要开放递归的神奇力量时,继承应该是选择。
评论
对于新手程序员来说,要从不同的角度解决这个问题:
当我们学习面向对象编程时,继承通常是很早就教授的,因此它被视为常见问题的简单解决方案。
我有三个类,它们都需要一些通用功能。所以如果我 编写一个基类并让它们都从中继承,然后它们将 它们都具有该功能,我只需要维护一次 地方。
这听起来不错,但实际上它几乎从未奏效,原因如下:
- 我们发现还有一些其他的函数是我们希望我们的类具有的。如果我们向类添加功能的方式是通过继承,我们必须决定 - 我们是否将其添加到现有的基类中,即使不是每个从它继承的类都需要该功能?我们是否创建另一个基类?但是,已经从另一个基类继承的类呢?
- 我们发现,对于从基类继承的一个类,我们希望基类的行为略有不同。所以现在我们回过头来修补我们的基类,也许添加一些虚拟方法,或者更糟糕的是,一些代码说,“如果我是继承的 A 类型,就这样做,但如果我是继承的 B 类型,那就这样做。这很糟糕,原因有很多。一个是,每次我们更改基类时,我们都会有效地更改每个继承的类。因此,我们确实在更改 A、B、C 和 D 类,因为我们需要在 A 类中略有不同的行为。尽管我们认为我们很小心,但我们可能会出于与这些类无关的原因而破坏其中一个类。
- 我们可能知道为什么我们决定让所有这些类相互继承,但对于必须维护我们代码的其他人来说,这可能没有意义(可能不会)。我们可能会迫使他们做出艰难的选择——我是做一些非常丑陋和混乱的事情来做出我需要的改变(参见前面的要点),还是我只是重写了一堆。
最后,我们把代码打成一些困难的结,除了说,“很酷,我学会了继承,现在我用了它。这并不意味着居高临下,因为我们都做过。但我们都这样做了,因为没有人告诉我们不要这样做。
当有人向我解释“偏爱组合而不是继承”时,我回想起每次尝试使用继承在类之间共享功能时,我意识到大多数时候它并不真正有效。
解药是单一责任原则。将其视为一种约束。我的班级必须做一件事。我必须能够给我的类起一个名字,以某种方式描述它所做的一件事。(凡事都有例外,但当我们学习时,绝对的规则有时会更好。因此,我无法编写名为 的基类。我需要的任何不同功能都必须在它自己的类中,然后需要该功能的其他类可以依赖于该类,而不是从该类继承。ObjectBaseThatContainsVariousFunctionsNeededByDifferentClasses
冒着过度简化的风险,这就是组合 - 组合多个类以协同工作。一旦我们养成了这种习惯,我们就会发现它比使用继承更灵活、更可维护、更可测试。
评论
什么时候可以使用构图?
你总是可以使用构图。在某些情况下,继承也是可能的,并且可能导致更强大和/或更直观的 API,但组合始终是一种选择。
什么时候可以使用继承?
人们常说,如果“一根柱线是foo”,那么该类就可以继承该类。不幸的是,仅此测试并不可靠,请改用以下方法:Bar
Foo
- bar 是 foo,并且
- 酒吧可以做 Foos 能做的一切。
第一个测试确保 的所有 getter 在 (= 共享属性) 中都有意义,而第二个测试确保 的所有 setter 在 (= 共享功能) 中都有意义。Foo
Bar
Foo
Bar
示例:狗/动物
狗是一种动物,狗可以做动物能做的一切(如呼吸、移动等)。因此,该类可以继承该类。Dog
Animal
反例:圆形/椭圆
圆是一个椭圆,但圆不能做椭圆可以做的所有事情。例如,圆不能拉伸,而椭圆可以。因此,该类不能继承该类。Circle
Ellipse
这被称为 Circle-Ellipse 问题,这并不是一个真正的问题,而更像是一个表明“柱线是 foo”本身并不是一个可靠的测试。具体而言,此示例强调派生类应扩展基类的功能,而不是限制它。否则,基类不能多态使用。添加测试“bars can do everything that foos can do”可确保多态使用是可能的,并且等价于 Liskov 替换原理:
使用指针或对基类的引用的函数必须能够在不知情的情况下使用派生类的对象
何时应使用继承?
即使你可以使用继承并不意味着你应该:使用组合始终是一种选择。继承是一个强大的工具,允许隐式代码重用和动态调度,但它确实有一些缺点,这就是为什么组合通常是首选的原因。继承和组合之间的权衡并不明显,在我看来,lcn 的回答最好地解释了这一点。
根据经验,当多态使用预计非常普遍时,我倾向于选择继承而不是组合,在这种情况下,动态调度的强大功能可以带来更具可读性和优雅的 API。例如,在 GUI 框架中拥有多态类,或在 XML 库中拥有多态类,可以拥有一个比纯粹基于组合的解决方案更具可读性和直观性的 API。Widget
Node
评论
computeArea(Circle* c) { return pi * square(c->radius()); }
width()
height()
尽管 Composition 是首选,但我想强调 Inheritance 的优点和 Composition 的缺点。
继承的优点:
它建立了一个逻辑上的“IS A”关系。如果 Car 和 Truck 是两种类型的 Vehicle(基类),则子类是 A 基类。
即
汽车是车辆
卡车是一种交通工具
通过继承,您可以定义/修改/扩展功能
- 基类不提供任何实现,子类必须覆盖完整的方法(抽象)=>您可以实现合约
- 基类提供默认实现,子类可以更改行为 => 您可以重新定义合约
- Sub-class 通过调用 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();
}
}
评论
如果你想要自 OOP 兴起以来人们一直在给出的规范的、教科书式的答案(你看到很多人在这些答案中给出了这个答案),那么应用以下规则:“如果你有 is-a 关系,请使用继承。如果你有 has-a 关系,请使用 composition”。
这是传统的建议,如果这让您满意,您可以停止阅读并继续您的快乐之路。对于其他人...
IS-A/HAS-A 比较有问题
例如:
- 正方形是一个矩形,但是如果你的矩形类有 / 方法,那么就没有合理的方法可以在不破坏 Liskov 的替换原则的情况下进行继承。
setWidth()
setHeight()
Square
Rectangle
- “是-a关系”通常可以改写为听起来像是“has-a”关系。例如,雇员是一个人,但一个人也具有“受雇”的就业状态。
- 如果您不小心,IS-A 关系可能会导致令人讨厌的多重继承层次结构。毕竟,英语中没有一条规则规定一个对象只是一个东西。
- 人们很快就把这个“规则”传递出去,但有没有人试图支持它,或者解释为什么它是一个很好的启发式方法?当然,这很符合 OOP 应该对现实世界进行建模的想法,但这本身并不是采用原则的理由。
有关此主题的更多阅读内容,请参阅此 StackOverflow 问题。
要知道何时使用继承与组合,我们首先需要了解每种方法的优缺点。
实现继承的问题
其他答案在解释继承问题方面做得非常出色,所以我将尽量不在这里深入研究太多细节。但是,这里有一个简短的列表:
- 遵循在基类方法和子类方法之间交织的逻辑可能很困难。
- 通过调用另一个可重写的方法粗心地实现类中的一个方法将导致泄露实现细节并破坏封装,因为最终用户可能会重写您的方法并检测您何时在内部调用它。(参见“Effective Java”第 18 项)。
- 脆弱的基础问题,它只是说,如果你的最终用户的代码在你尝试更改它们时碰巧依赖于实现细节的泄漏,它们就会中断。更糟糕的是,大多数 OOP 语言默认允许继承 - API 设计者在重构基类时需要格外小心,如果 API 设计者不会主动阻止人们从他们的公共类继承。不幸的是,脆弱的基础问题经常被误解,导致许多人不明白维护一个任何人都可以继承的类需要什么。
- 致命的死亡钻石
构图问题
- 它有时可能有点冗长。
就是这样。我是认真的。这仍然是一个真正的问题,有时会与 DRY 原则产生冲突,但通常并没有那么糟糕,至少与与继承相关的无数陷阱相比是这样。
什么时候应该使用继承?
下次当你为一个项目绘制你花哨的UML图时(如果你这样做了),并且你正在考虑添加一些继承,请遵循以下建议:不要。
至少,现在还没有。
继承是作为实现多态性的工具出售的,但与它捆绑在一起的是这个强大的代码重用系统,坦率地说,大多数代码都不需要它。问题是,一旦你公开了你的继承层次结构,你就会被锁定在这种特定的代码重用风格中,即使解决你的特定问题有点矫枉过正。
为了避免这种情况,我的两分钱是永远不要公开你的基类。
- 如果需要多态性,请使用接口。
- 如果你需要允许人们自定义你的类的行为,通过策略模式提供明确的挂钩点,这是一种更易读的方式来实现这一点,此外,保持这种 API 的稳定性更容易,因为你完全控制他们可以改变和不能改变的行为。
- 如果您试图通过使用继承来遵循开闭原则,以避免向类添加急需的更新,请不要这样做。更新类。如果你真的拥有你受雇维护的代码的所有权,而不是试图把东西放在它的一边,你的代码库会更干净。如果您害怕引入错误,请测试现有代码。
- 如果需要重用代码,请首先尝试使用组合函数或帮助程序函数。
最后,如果你认为没有其他好的选择,并且你必须使用继承来实现你需要的代码重用,那么你可以使用它,但是,请遵循这四个 P.A.I.L. 限制继承规则来保持理智。
- 使用继承作为私有实现细节。不要公开你的基类,为此使用接口。这样,您就可以根据需要自由添加或删除继承,而无需进行重大更改。
- 保持基类抽象。这样可以更轻松地将需要共享的逻辑与不需要共享的逻辑区分开来。
- 隔离基类和子类。不要让你的子类覆盖基类方法(为此使用策略模式),并避免让它们期望属性/方法彼此存在,使用其他形式的代码共享来实现这一点。使用适当的语言功能强制基类上的所有方法不可重写(在 Java 中为“final”,在 C# 中为非 virtual)。
- 继承是最后的手段。
特别是隔离规则听起来可能有点粗糙,但如果你自律,你会得到一些相当不错的好处。特别是,它使您可以自由地避免与上述继承相关的所有主要令人讨厌的陷阱。
- 遵循代码要容易得多,因为它不会在基类/子类之间穿梭。
- 如果从不使任何方法可重写,则在方法内部调用其他可重写方法时,不会意外泄漏。换句话说,您不会意外地破坏封装。
- 脆弱的基类问题源于依赖于意外泄露的实现细节的能力。由于基类现在是孤立的,因此它不会比通过组合依赖于另一个类更脆弱。
- 致命的死亡钻石不再是问题,因为根本不需要多层继承。如果您有抽象基类 B 和 C,它们都共享许多功能,只需将该功能从 B 和 C 移出并移动到新的抽象基类(类 D)中。由于基类都是私有实现细节,因此要弄清楚谁继承了什么,进行这些更改应该不会太难。
结论
我的主要建议是在这件事上动动脑筋。比关于何时使用继承的注意事项列表更重要的是对继承及其相关优缺点的直观理解,以及对可以使用的其他工具的良好理解,而不是继承(组合不是唯一的选择。例如,策略模式是一个了不起的工具,但经常被遗忘)。也许当你对所有这些工具都有很好的、扎实的了解时,你会选择比我建议的更频繁地使用继承,这完全没问题。至少,你正在做出一个明智的决定,而不仅仅是使用继承,因为这是你知道如何去做的唯一方法。
延伸阅读:
- 一篇文章我写了关于这个主题的文章,更深入地探讨了这个问题,并提供了例子。
- 一个网页,讨论继承所做的三种不同的工作,以及如何通过Go语言中的其他方式完成这些工作。
- 将类声明为不可继承的原因列表(例如,Java 中的“final”)。
- 约书亚·布洛赫(Joshua Bloch)所著的《有效的爪哇》(Effective Java)一书,第18项,讨论了组合而不是继承,以及继承的一些危险。
简单地说,实现就像你(类)应该遵循的规则
但是 extend 就像对许多类使用通用代码一样,也许只需要覆盖其中一个类。
只是意见,继承只应在以下情况下使用:
- 这两个类位于同一逻辑域中
- 子类是超类的适当子类型
- 超类的实现对于子类是必需的或合适的
- 子类所做的增强主要是累加的。
有时,所有这些东西都会融合在一起:
- 更高级别的域建模
- 框架和框架扩展
- 差分编程
上一个:按引用传递与按值传递有什么区别?
下一个:什么是依赖注入?
评论