喜欢构成而不是继承?

Rea*_*nly 1538 language-agnostic oop inheritance composition aggregation

为什么喜欢构图而不是继承呢?每种方法都有哪些权衡取舍?什么时候应该选择继承而不是作文?

Gis*_*shu 1132

首选组合而不是继承,因为它更具延展性/易于修改,但不使用compose-always方法.通过组合,可以使用依赖注入/设置器轻松更改行为.继承更加严格,因为大多数语言不允许您从多个类型派生.因此,一旦你从TypeA派生,鹅就会或多或少地煮熟.

我对上述的酸测试是:

  • TypeB是否希望公开TypeA的完整接口(所有公共方法不少),以便TypeB可以在预期的TypeA中使用?表示继承.

例如,塞斯纳双翼飞机将暴露飞机的完整界面,如果不是更多.因此,它适合从飞机派生.

  • TypeB是否只想要TypeA公开的部分/部分行为?表示需要合成.

例如,鸟可能只需要飞机的飞行行为.在这种情况下,将其作为接口/类/两者提取出来并使其成为两个类的成员是有意义的.

更新:刚回到我的答案,现在看来,如果不特别提及芭芭拉利斯科夫的利斯科夫替代原则作为"我是否应该继承这种类型?"的测试,它似乎是不完整的.

  • 第二个例子直接来自Head First Design Patterns(http://www.amazon.com/First-Design-Patterns-Elisabeth-Freeman/dp/0596007124)书:)我强烈推荐那本书给任何人谷歌搜索这个问题. (75认同)
  • @Alexey--重点是'我可以将Cessna双翼飞机传递给所有期望飞机的客户而不会让他们感到惊讶吗?' 如果是,那么你很可能想要继承. (20认同)
  • 我实际上很难想到继承本来是我的答案的任何例子,我经常发现聚合,组合和接口会产生更优雅的解决方案.使用这些方法可以更好地解释上述许多例子...... (8认同)
  • 你暗示我认为应该是最基本的测试:"这个对象是否可以被期望(基本类型)对象的代码使用".如果答案是肯定的,那么对象*必须*继承.如果不是,那么它可能不应该.如果我有我的druthers,语言将提供一个关键字来引用"this class",并提供一种定义一个类的方法,该类应该像另一个类一样,但不能替代它(这样一个类将具有所有"这个" class"引用替换为自身". (4认同)
  • 这很清楚,但是可能会遗漏一些东西:“ TypeB是否要公开TypeA的完整接口(不少于所有公共方法),以便可以在需要TypeA的地方使用TypeB?” 但是,如果这是真的,并且TypeB也公开TypeC的完整接口怎么办?如果尚未建模TypeC呢? (3认同)
  • 我看不出在什么意义上“是一个”比“正方形是否是矩形”更具争议性。正方形是矩形,但可能可变正方形不是可变矩形。某些东西是否“是”其他东西取决于它的用途,否则我不明白怎么可能说一个数据结构是另一个(不同的)数据结构。 (3认同)

Nic*_*kiy 393

将遏制视为一种关系.汽车"有一个"引擎,一个人"有一个"的名字等.

想继承的作为是一个关系.汽车"是"汽车,人是"哺乳动物"等.

我不相信这种方法.我把它直接从代码的第二版完全史蒂夫·麦康奈尔,第6.3节.

  • 这并不总是一个完美的方法,它只是一个很好的指导方针.Liskov替换原则更准确(失败更少). (104认同)
  • "我的车有车." 如果你认为这是一个单独的句子,而不是在编程环境中,那就完全没有意义了.这就是这项技术的重点.如果听起来很尴尬,那可能就错了. (39认同)
  • @Nick当然,但"我的车有一个VehicleBehavior"更有意义(我猜你的"车辆"类可以命名为"VehicleBehavior").所以你不能把你的决定建立在"有一个"vs"是一个"的比较上,你必须使用LSP,否则你会犯错误 (34认同)
  • 而不是"是一个"想到"表现得像".继承是关于继承行为,而不仅仅是语义. (32认同)
  • 这不能回答问题。问题是“为什么”而不是“什么”。 (7认同)
  • @ybakos"Behaves like"可以通过接口实现,无需继承.[来自维基百科](https://en.wikipedia.org/wiki/Composition_over_inheritance#Basics):*"继承的组合实现通常从创建表示系统必须展示的行为的各种接口开始...... ,系统行为是在没有继承的情况下实现的."* (3认同)
  • 你不能将most **is a** 转换为**has a** 关系吗?因此,汽车有一个 basicVehicle 对象,而人有一个 basicMammal 对象,等等。 (2认同)

ale*_*emb 204

如果您了解其中的差异,则更容易解释.

程序代码

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

遗产

这鼓励使用课程.继承是OO设计的三个原则之一(继承,多态,封装).

class Person {
   String Title;
   String Name;
   Int Age
}

class Employee : Person {
   Int Salary;
   String Title;
}
Run Code Online (Sandbox Code Playgroud)

这是工作中的继承.员工"是"人或继承人.所有继承关系都是"is-a"关系.Employee还会从Person隐藏Title属性,这意味着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);
Run Code Online (Sandbox Code Playgroud)

组合通常"具有"或"使用"关系.这里Employee类有一个Person.它不会从Person继承,而是将Person对象传递给它,这就是它"拥有"Person的原因.

继承的组合

现在假设您要创建一个Manager类型,最终得到:

class Manager : Person, Employee {
   ...
}
Run Code Online (Sandbox Code Playgroud)

这个例子可以正常工作,但是,如果Person和Employee都声明了Title呢?Manager.Title应该返回"运营经理"还是"先生"?在构图下,这种歧义可以更好地处理:

Class Manager {
   public string Title;
   public Manager(Person p, Employee e)
   {
      this.Title = e.Title;
   }
}
Run Code Online (Sandbox Code Playgroud)

Manager对象由Employee和Person组成.标题行为取自员工.这种明确的组合消除了其他事物的歧义,你会遇到更少的错误.

  • 重要的是要记住,有人可能会说"构成超过继承",但这并不意味着"构成永远超过继承"."是一个"意味着继承并导致代码重用.员工是一个人(员工没有人). (64认同)
  • 这个例子令人困惑.员工是一个人,所以它应该使用继承.您不应该在此示例中使用合成,因为它在域模型中是错误的关系,即使在技术上您可以在代码中声明它. (35认同)
  • 我不同意这个例子.一个Employee _is-a_ Person,它是正确使用继承的教科书案例.我也认为"问题"重新定义Title字段没有意义.Employee.Title阴影Person.Title这一事实表明编程不佳.毕竟,是"先生" 和"操作经理"真的指的是一个人的同一方面(小写)?我会重命名Employee.Title,因此能够引用Employee的Title和JobTitle属性,这两个属性在现实生活中都有意义.此外,经理没有理由(续......) (15认同)
  • (...继续)从Person和Employee继承 - 毕竟,Employee已经从Person继承.在更复杂的模型中,一个人可能是一个管理器和一个代理,确实可以使用多重继承(小心!),但在许多环境中最好有一个抽象的Role类,Manager(包含Employees)他/她管理)和代理(包含合同和其他信息)继承.然后,员工是具有多个角色的人.因此,正确使用组合和继承. (8认同)
  • 对于继承:没有歧义.您正在根据要求实现Manager类.因此,如果这是您的要求,您将返回"操作管理器",否则您将只使用基类的实现.您还可以使Person成为一个抽象类,从而确保下游类实现Title属性. (5认同)
  • 您必须知道在创建初始人员类时您希望能够更改标题的含义,抽象属性是设计不良的标志,行为应该是抽象的,而不是状态. (2认同)
  • 我实际上非常喜欢这个例子,因为,如果一个Employee既是一个Manager又是一个Agent,该怎么办呢.在无法从多个类继承的环境中,您遇到了问题,并且最有可能最终出现重复数据.通过合成,您可以以优雅的方式处理此问题.即使在可以进行多重继承的环境中,开销也很小,我更喜欢这种解决方案. (2认同)
  • @netiul,它仍然不是一个好例子:一个更好的模型是Employee是一个Person,但是有一个角色. (2认同)
  • @Celos 这是同样的想法。作为一名员工是某种角色。我是一名员工,但我也是一名父亲。因此,将 Employee 和 Father 设为一个 Role,并将它们与同一 Person 组合起来。 (2认同)
  • @raxod502 问题是,虽然我多年来一直使用(并观察)继承,但我从未见过它的优雅。结合状态和行为?当然。虚拟调度?当然。但是继承呢?只有当你有一个树状的层次结构时它才真正有效,然后经过半年的开发你突然发现你的层次结构不再是树状的,你可以重新开始。也就是说,许多现代语言对组合的支持确实很差——这在某些情况下可能使组合比继承更笨拙(例如,必须显式地将接口路由到子级)。 (2认同)

Gal*_*you 130

由于继承提供了所有不可否认的好处,这里有一些缺点.

继承的缺点:

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

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

  • 在我看来,这是更好的答案之一 - 我将补充一点,根据我的经验,尝试重新思考你的构成方面的问题往往会导致更小,更简单,更自包含,更可重用的类,具有更清晰,更小,更集中的责任范围.通常这意味着对依赖注入或模拟(在测试中)的需求较少,因为较小的组件通常能够独立存在.只是我的经验.YMMV :-) (4认同)
  • 这篇文章的最后一段真的点击了我.谢谢. (2认同)

Tim*_*and 83

另一个非常务实的原因是,优先考虑组合而不是继承,这与域模型有关,并将其映射到关系数据库.将继承映射到SQL模型真的很难(最终会遇到各种各样的hacky变通方法,比如创建不常用的列,使用视图等).一些ORML试图解决这个问题,但它总是很快变得复杂.可以通过两个表之间的外键关系轻松地对组合进行建模,但继承更加困难.


Pav*_*man 74

虽然简而言之,我同意"首选组合而不是继承",但对我而言,这听起来像"喜欢土豆而不是可口可乐".有继承的地方和组成的地方.你需要了解差异,然后这个问题就会消失.它对我来说真正意味着"如果你要继承 - 再想想,你可能需要作文".

当你想吃的时候,你应该更喜欢土豆而不是可口可乐,而当你想喝的时候,你可以选择土豆可乐.

创建子类不仅仅意味着调用超类方法的方便方法.当子类"is-a"超类在结构和功能上都应该使用继承,当它可以用作超类时,你将使用它.如果不是这种情况 - 它不是继承,而是其他东西.组合是指您的对象由另一个组成,或与它们有某种关系.

所以对我来说,看起来如果有人不知道他是否需要继承或组成,真正的问题是他不知道他是想喝酒还是吃饭.更多地考虑您的问题域,更好地理解它.

  • 适合工作的正确工具.敲击锤子可能比扳手更好,但这并不意味着人们应该将扳手视为"劣质锤子".当添加到子类的内容是对象作为超类对象运行所必需的东西时,继承会很有用.例如,考虑一个带有派生类`GasolineEngine`的基类`InternalCombustionEngine`.后者增加了基类缺乏的火花塞之类的东西,但是将东西用作`InternalCombustionEngine`会导致火花塞被使用. (3认同)

Mik*_*nty 57

继承是非常诱人的,特别是来自程序性的土地,它往往看起来很优雅.我的意思是我需要做的就是将这一点功能添加到其他类中,对吧?嗯,其中一个问题是

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

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

文章,继承是邪恶的:DataAnnotationsModelBinder的Epic Fail,在C#中介绍了这个例子.它显示了在应该使用组合时如何使用继承以及如何重构它.

  • 继承没有好坏之分,它只是 Composition 的一个特例。实际上,子类正在实现与超类类似的功能。如果您提议的子类不是重新实现,而只是*使用*超类的功能,那么您就错误地使用了继承。那是程序员的错误,而不是对继承的反思。 (4认同)

dan*_*die 40

在Java或C#中,对象在实例化后无法更改其类型.

因此,如果您的对象需要显示为不同的对象或根据对象状态或条件而表现不同,则使用Composition:请参阅状态策略设计模式.

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

  • +1我发现在大多数情况下继承的工作越来越少.我更喜欢共享/继承的接口和对象的组合....或者它被称为聚合?不要问我,我有EE学位! (10认同)

Mec*_*cki 32

就个人而言,我学会了总是喜欢构图而不是继承.你可以用继承解决没有程序问题,你无法用组合来解决; 虽然在某些情况下您可能必须使用接口(Java)或协议(Obj-C).由于C++不知道任何这样的东西,你将不得不使用抽象基类,这意味着你不能完全摆脱C++中的继承.

组合通常更符合逻辑,它提供更好的抽象,更好的封装,更好的代码重用(特别是在非常大的项目中),并且不太可能在远处破坏任何东西,因为您在代码中的任何位置进行了单独的更改.它还使得更容易坚持" 单一责任原则 ",这通常被概括为" 一个类不应该有一个以上的理由来改变. ",这意味着每个类都存在于特定目的,它应该只有与其目的直接相关的方法.即使项目开始变得非常庞大,使用非常浅的继承树也可以更容易地保持概览.许多人认为继承代表了我们的现实世界,但事实并非如此.现实世界使用的组合比继承更多.你可以握在手中的每一个真实物体都是由其他较小的现实世界物体组成的.

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

您可能已经注意到大多数OO语言不允许多重继承.虽然有几种情况下多重继承可以真正为你买点东西,但这些只是规则的例外.每当你遇到一种你认为"多重继承将是一个非常酷的功能来解决这个问题"的情况时,你通常都应该重新考虑继承,因为即使它可能需要一些额外的代码行基于构图的解决方案通常会变得更加优雅,灵活和面向未来.

继承真的是一个很酷的功能,但我担心它在过去几年里被滥用了.人们将遗传视为可以钉住所有东西的一把锤子,无论它实际上是钉子,螺钉还是完全不同的东西.


lcn*_*lcn 31

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

要理解为什么" 更喜欢组合而不是继承",我们首先要回到这个缩短的习语中省略的假设.

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

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

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

这两个好处有两个不同的目的:继承:面向子类型和面向代码重用.

如果代码重用是唯一的目的,那么子类化可能比他需要的更多,即父类的一些公共方法对子类没有多大意义.在这种情况下,不需要使组合优于继承,而是要求组合.这也是"is-a"与"has-a"概念的来源.

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

要使子类型符合类型签名,这意味着组合总是暴露出同样数量的API类型.现在,权衡取舍:

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

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

  3. 继承暴露受保护的成员.这会破坏父类的封装,如果子类使用,则会引入子类与其父类之间的另一个依赖关系.

  4. 组合具有控制反转的适应性,并且其依赖性可以动态注入,如装饰器模式代理模式中所示.

  5. 组合具有面向组合器的编程的优点,即以类似于复合模式的方式工作.

  6. 构图紧接着编程到界面.

  7. 组合物具有易于多重继承的优点.

考虑到上述权衡,我们因此更喜欢构成而不是继承.然而,对于紧密相关的类,即当隐式代码重用确实带来好处,或者需要开放递归的神奇力量时,继承应该是选择.


Pet*_*eng 22

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

原因:子类化通常意味着更多的复杂性和连通性,即更难以改变,维护和扩展而不会出错.

来自 Sun的Tim Boudreau的更完整和具体的答案:

我认为使用继承的常见问题是:

  • 无辜的行为可能会产生意想不到的结果 - 这个经典的例子是在初始化子类实例字段之前调用超类构造函数中的可覆盖方法.在一个完美的世界里,没有人会这样做.这不是一个完美的世界.
  • 它为子类提供了有关方法调用顺序的假设的反常诱惑 - 如果超类可能随着时间的推移而演变,这种假设往往不稳定.另见我的烤面包机和咖啡壶类比.
  • 类越来越重 - 你不一定知道你的超类在它的构造函数中做了什么工作,或者它将要使用多少内存.所以构建一些无辜的可能轻量级对象可能比你想象的要贵得多,如果超类演变,这可能会随着时间的推移而改变
  • 它鼓励子类的爆炸.类加载成本时间,更多类成本内存.在您处理NetBeans规模的应用程序之前,这可能不是问题,但在那里,我们遇到了一些实际问题,例如菜单很慢,因为菜单的第一次显示触发了大量的类加载.我们通过转向更具声明性的语法和其他技术来解决这个问题,但这也需要花费时间来修复.
  • 这使得以后更改内容变得更加困难 - 如果你已经公开了一个类,那么交换超类就会打破子类 - 这是一个选择,一旦你公开了代码,你就会嫁给它.因此,如果您没有改变超类的真实功能,那么如果您使用,您可以更自由地更改内容,而不是扩展您需要的内容.举例来说,继承JPanel - 这通常是错误的; 如果子类在某个地方公开,你永远不会有机会重新审视这个决定.如果它作为JComponent getThePanel()访问,您仍然可以这样做(提示:在API中公开组件的模型).
  • 对象层次结构不能扩展(或者使它们后期扩展比计划更难) - 这是典型的"太多层"问题.我将在下面讨论,以及AskTheOracle模式如何解决它(虽然它可能冒犯OOP纯粹主义者).

...

如果你允许遗传,我可以采取的做法是:

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

...

所有这些对小型项目的应用要少于大型项目,对私人类别的应用要少于公共项目


Bor*_*ein 21

为什么喜欢构图而不是继承呢?

看到其他答案.

什么时候应该选择继承而不是作文?

每当句子"一个酒吧是一个Foo,一个酒吧可以做一切Foo可以做的事情"是有道理的.

传统观点认为,如果句子"a Bar is a Foo"是有道理的,那么选择继承是恰当的是一个很好的暗示.例如,狗是一种动物,因此将Dog类继承自Animal可能是一个很好的设计.

不幸的是,这个简单的"is-a"测试并不可靠.该圆,椭圆的问题是一个很好的反例:即使圆是一个椭圆,这是一个坏主意,有从椭圆Circle类继承,因为有些事情椭圆可以做,但圈不能.例如,椭圆可以拉伸,但圆圈不能拉伸.所以,虽然你可以Bar,但你不能拥有Foo.

这就是为什么一个更好的测试是"Bar是Foo,而Bar可以完成Foo可以做的所有事情 ".这真的意味着Foo可以多态使用."is-a"测试只是多态使用的必要条件,通常意味着所有Foo的getter在Bar中都有意义.额外的"可以做的一切"测试意味着所有的Foo设置者在Bar中也有意义.当类Bar"is-a"Foo时,这个额外的测试通常会失败,但会为它添加一些约束,在这种情况下你不应该使用继承,因为Foo不能以多态方式使用.换句话说,继承不是关于共享属性,而是关于共享功能.派生类应该扩展基类的功能,而不是限制它.

这相当于利斯科夫替代原则:

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

  • @FuzzyLogic,如果您对我对 Circle-Ellipse 的具体情况所主张的内容感到好奇:我建议不要实现 Circle 类。反转关系的问题在于它也违反了 LSP:想象函数 `computeArea(Circle* c) { return pi * square(c->radius()); }`。如果传递一个 Ellipse ,它显然会被破坏(radius() 是什么意思?)。椭圆不是圆形,因此不应从圆形派生。 (2认同)
  • @FuzzyLogic 我不同意:您是否意识到这意味着 Circle 类预期派生类 Ellipse 的存在,因此提供了 `width()` 和 `height()`?如果现在图书馆用户决定创建另一个名为“EggShape”的类怎么办?它也应该来自“Circle”吗?当然不是。蛋形不是圆,椭圆也不是圆,所以不应该从 Circle 派生出来,因为它破坏了 LSP。对 Circle* 类执行操作的方法对圆是什么做出了强有力的假设,打破这些假设几乎肯定会导致错误。 (2认同)
  • 你太棒了,喜欢这个答案:) (2认同)

yuk*_*ude 19

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


Sco*_*son 18

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

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

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

例如:

  • 正方形是一个矩形,但是如果你的矩形类有setWidth()/setHeight()方法,那么就没有合理的方法可以在不违反里氏替换原则的情况Square下进行继承。Rectangle
  • “is-a”关系通常可以改写为“has-a”关系。例如,雇员是——一个人,但一个人也具有——“受雇”的就业状态。
  • 如果您不小心,is-a 关系可能会导致令人讨厌的多重继承层次结构。毕竟,英语中没有任何规则规定对象完全一件事。
  • 人们很快就传播了这个“规则”,但是有没有人尝试过支持它,或者解释为什么它是一个值得遵循的很好的启发式方法?当然,它非常适合 OOP 应该模拟现实世界的想法,但这本身并不是采用原则的理由。

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

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

实现继承的问题

其他答案在解释继承问题方面做得非常出色,因此我将尽量不在这里深入研究太多细节。但是,这里有一个简短的列表:

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

构图问题

  • 有时可能有点冗长。

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

什么时候应该使用继承?

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

至少现在还没有。

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

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

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

最后,如果您确定没有其他好的选择,并且必须使用继承来实现所需的代码重用,那么您可以使用它,但是请遵循这四个受限继承的PAIL规则以保持理智。

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

特别是“隔离”规则听起来可能有点难以遵循,但如果你约束自己,你会得到一些相当不错的好处。特别是,它使您可以自由地避免与上述继承相关的所有主要令人讨厌的陷阱。

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

结论

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

进一步阅读:

  • 我就这个主题写了一篇文章,深入探讨并提供了示例。
  • 该网页讨论了继承所做的三种不同的工作,以及如何通过 Go 语言中的其他方式完成这些工作。
  • 列出了为什么将类声明为不可继承(例如 Java 中的“final”)的好处的原因。
  • Joshua Bloch 的“Effective Java”一书,第 18 条,讨论了组合优于继承,以及继承的一些危险。


ega*_*aga 14

继承在子类和超类之间创建了一种强大的关系; 子类必须知道超类的实现细节.当你必须考虑如何扩展它时,创建超级类会更加困难.您必须仔细记录类不变量,并说明其他方法可覆盖的方法在内部使用的内容.

如果层次结构实际上表示is-a-relationship,则继承有时很有用.它涉及开放 - 封闭原则,它规定类应该被关闭以进行修改但是可以扩展.这样你就可以拥有多态性; 有一个处理超类型及其方法的泛型方法,但是通过动态调度,可以调用子类的方法.这是灵活的,并有助于创建间接,这在软件中是必不可少的(更少了解实现细节).

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

我建议使用composing作为默认值.它更模块化,并提供后期绑定的好处(您可以动态更改组件).此外,单独测试这些东西也更容易.如果您需要使用类中的方法,则不必强制使用某种形式(Liskov Substitution Principle).

  • 值得注意的是,继承并不是实现多态的唯一方法.装饰器模式通过组合提供多态性的外观. (3认同)
  • @engaga:我解释了你的评论`继承有时是有用的......这样你可以将多态性作为硬连接继承和多态的概念(在给定上下文的情况下假设子类型).我的意见旨在指出您在评论中阐明的内容:继承不是实现多态的唯一方法,实际上并不一定是决定组合和继承时的决定因素. (2认同)

nab*_*rid 13

你需要看看鲍勃叔叔的SOLID课堂设计原则的Liskov替代原则.:)


dha*_*0us 13

假设飞机只有两个部分:发动机和机翼.
然后有两种方法来设计飞机类.

Class Aircraft extends Engine{
  var wings;
}
Run Code Online (Sandbox Code Playgroud)

现在你的飞机可以从固定的机翼开始,
并在飞行中将它们改为旋转机翼.它本质上是
一个带翅膀的发动机.但是,如果我想
在飞行中改变引擎呢?

基类Engine暴露了一个mutator来改变它的
属性,或者我重新设计Aircraft为:

Class Aircraft {
  var wings;
  var engine;
}
Run Code Online (Sandbox Code Playgroud)

现在,我也可以动态更换我的发动机.


Sco*_*nen 8

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

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

我有三个类都需要一些通用功能。因此,如果我编写一个基类并让它们都继承自它,那么它们都将具有该功能,而我只需要在一个地方维护它。

这听起来很棒,但在实践中它几乎永远不会奏效,原因如下:

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

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

一旦有人向我解释了“优先组合而不是继承”,每当我尝试使用继承在类之间共享功能时,我都会回想起来,并意识到大多数时候它并不能很好地工作。

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

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


Anz*_*rio 7

当您想要"复制"/公开基类的API时,您可以使用继承.如果您只想"复制"功能,请使用委托.

例如:您想要从列表中创建堆栈.堆栈只有pop,push和peek.鉴于您不希望在堆栈中使用push_back,push_front,removeAt等等功能,因此不应使用继承.


Ter*_*nen 7

这两种方式可以很好地共存,实际上相互支持.

组合就是模块化:你创建类似于父类的接口,创建新对象并委托对它的调用.如果这些对象不需要彼此了解,那么使用组合物是非常安全和容易的.这里有很多可能性.

但是,如果父类由于某种原因需要访问"子类"为没有经验的程序员提供的功能,那么它可能看起来像是一个使用继承的好地方.父类可以只调用它自己的抽象"foo()",它被子类覆盖,然后它可以将值赋给抽象基.

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

为什么?

因为继承是一种移动信息的不良方式.

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

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

  1. 您有许多具有相同界面的类,并且您希望节省编写它们的时间
  2. 您必须为每个对象使用相同的基类
  3. 您需要修改私有变量,在任何情况下都不能公开

如果这些都是真的,则可能需要使用继承.

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

如果原因是2号,那就有点棘手了.你真的只需要使用相同的基类吗?通常,仅使用相同的基类是不够好的,但它可能是您的框架的要求,这是一个无法避免的设计考虑因素.

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


Jon*_*jap 6

除了考虑因素之外,还必须考虑对象必须经历的继承"深度".任何超过五或六级继承的深度都可能导致意外的转换和装箱/拆箱问题,在这些情况下,构建对象可能是明智的.


Ami*_*lam 5

如果你有一个两个类之间的is-a关系(例如dog是一个犬),你就去继承.

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


Y.S*_*Y.S 5

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

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


归档时间:

查看次数:

287387 次

最近记录:

6 年 前