为什么喜欢构图而不是继承呢?每种方法都有哪些权衡取舍?什么时候应该选择继承而不是作文?
首选组合而不是继承,因为它更具延展性/易于修改,但不使用compose-always方法.通过组合,可以使用依赖注入/设置器轻松更改行为.继承更加严格,因为大多数语言不允许您从多个类型派生.因此,一旦你从TypeA派生,鹅就会或多或少地煮熟.
我对上述的酸测试是:
TypeB是否希望公开TypeA的完整接口(所有公共方法不少),以便TypeB可以在预期的TypeA中使用?表示继承.
例如,塞斯纳双翼飞机将暴露飞机的完整界面,如果不是更多.因此,它适合从飞机派生.
TypeB是否只想要TypeA公开的部分/部分行为?表示需要合成.
例如,鸟可能只需要飞机的飞行行为.在这种情况下,将其作为接口/类/两者提取出来并使其成为两个类的成员是有意义的.
更新:刚回到我的答案,现在看来,如果不特别提及芭芭拉利斯科夫的利斯科夫替代原则作为"我是否应该继承这种类型?"的测试,它似乎是不完整的.
将遏制视为一种关系.汽车"有一个"引擎,一个人"有一个"的名字等.
想继承的作为是一个关系.汽车"是"汽车,人是"哺乳动物"等.
我不相信这种方法.我把它直接从代码的第二版完全由史蒂夫·麦康奈尔,第6.3节.
如果您了解其中的差异,则更容易解释.
这方面的一个例子是没有使用类的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将返回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);
组合通常"具有"或"使用"关系.这里Employee类有一个Person.它不会从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; } }
Manager对象由Employee和Person组成.标题行为取自员工.这种明确的组合消除了其他事物的歧义,你会遇到更少的错误.
由于继承提供了所有不可否认的好处,这里有一些缺点.
继承的缺点:
您无法在运行时更改从超类继承的实现(显然,因为继承是在编译时定义的).
继承将子类暴露给其父类的实现的细节,这就是为什么通常会说继承会破坏封装(在某种意义上,你真的需要只关注接口而不是实现,所以通过子类重用并不总是首选).
由继承提供的紧密耦合使得子类的实现与超类的实现非常相关,父实现中的任何更改都将强制子类更改.
通过子类过度重用可以使继承栈非常深入并且非常混乱.
另一方面,对象组合在运行时通过获取对其他对象的引用的对象来定义.在这种情况下,这些对象将永远无法访问彼此的受保护数据(没有封装中断),并且将被迫尊重彼此的接口.在这种情况下,实现依赖性将比继承的情况少得多.
另一个非常务实的原因是,优先考虑组合而不是继承,这与域模型有关,并将其映射到关系数据库.将继承映射到SQL模型真的很难(最终会遇到各种各样的hacky变通方法,比如创建不常用的列,使用视图等).一些ORML试图解决这个问题,但它总是很快变得复杂.可以通过两个表之间的外键关系轻松地对组合进行建模,但继承更加困难.
虽然简而言之,我同意"首选组合而不是继承",但对我而言,这听起来像"喜欢土豆而不是可口可乐".有继承的地方和组成的地方.你需要了解差异,然后这个问题就会消失.它对我来说真正意味着"如果你要继承 - 再想想,你可能需要作文".
当你想吃的时候,你应该更喜欢土豆而不是可口可乐,而当你想喝的时候,你可以选择土豆可乐.
创建子类不仅仅意味着调用超类方法的方便方法.当子类"is-a"超类在结构和功能上都应该使用继承,当它可以用作超类时,你将使用它.如果不是这种情况 - 它不是继承,而是其他东西.组合是指您的对象由另一个组成,或与它们有某种关系.
所以对我来说,看起来如果有人不知道他是否需要继承或组成,真正的问题是他不知道他是想喝酒还是吃饭.更多地考虑您的问题域,更好地理解它.
继承是非常诱人的,特别是来自程序性的土地,它往往看起来很优雅.我的意思是我需要做的就是将这一点功能添加到其他类中,对吧?嗯,其中一个问题是
您的基类通过以受保护成员的形式将实现细节暴露给子类来打破封装.这使您的系统变得僵硬和脆弱.然而,更具悲剧性的缺陷是新的子类带来了继承链的所有包袱和意见.
文章,继承是邪恶的:DataAnnotationsModelBinder的Epic Fail,在C#中介绍了这个例子.它显示了在应该使用组合时如何使用继承以及如何重构它.
在Java或C#中,对象在实例化后无法更改其类型.
因此,如果您的对象需要显示为不同的对象或根据对象状态或条件而表现不同,则使用Composition:请参阅状态和策略设计模式.
如果对象需要具有相同的类型,则使用继承或实现接口.
就个人而言,我学会了总是喜欢构图而不是继承.你可以用继承解决没有程序问题,你无法用组合来解决; 虽然在某些情况下您可能必须使用接口(Java)或协议(Obj-C).由于C++不知道任何这样的东西,你将不得不使用抽象基类,这意味着你不能完全摆脱C++中的继承.
组合通常更符合逻辑,它提供更好的抽象,更好的封装,更好的代码重用(特别是在非常大的项目中),并且不太可能在远处破坏任何东西,因为您在代码中的任何位置进行了单独的更改.它还使得更容易坚持" 单一责任原则 ",这通常被概括为" 一个类不应该有一个以上的理由来改变. ",这意味着每个类都存在于特定目的,它应该只有与其目的直接相关的方法.即使项目开始变得非常庞大,使用非常浅的继承树也可以更容易地保持概览.许多人认为继承代表了我们的现实世界,但事实并非如此.现实世界使用的组合比继承更多.你可以握在手中的每一个真实物体都是由其他较小的现实世界物体组成的.
但是,构图有缺点.如果你完全跳过继承而只关注组合,你会注意到你经常需要编写一些额外的代码行,如果你使用了继承则不需要这些代码行.你有时也被迫重复自己,这违反了DRY原则(DRY =不要重复自己).组合通常也需要委托,而方法只是调用另一个对象的另一个方法而没有围绕此调用的其他代码.这种"双方法调用"(可能很容易扩展到三次或四次方法调用,甚至比这更远)的性能比继承要差得多,在继承中只需继承父级的方法.调用一个继承的方法可能与调用一个非继承的方法同样快,或者它可能稍慢,但通常仍然比两个连续的方法调用快.
您可能已经注意到大多数OO语言不允许多重继承.虽然有几种情况下多重继承可以真正为你买点东西,但这些只是规则的例外.每当你遇到一种你认为"多重继承将是一个非常酷的功能来解决这个问题"的情况时,你通常都应该重新考虑继承,因为即使它可能需要一些额外的代码行基于构图的解决方案通常会变得更加优雅,灵活和面向未来.
继承真的是一个很酷的功能,但我担心它在过去几年里被滥用了.人们将遗传视为可以钉住所有东西的一把锤子,无论它实际上是钉子,螺钉还是完全不同的东西.
这里没有找到满意的答案,所以我写了一个新的.
要理解为什么" 更喜欢组合而不是继承",我们首先要回到这个缩短的习语中省略的假设.
继承有两个好处:子类型和子类
子类型意味着符合类型(接口)签名,即一组API,并且可以覆盖部分签名以实现子类型多态性.
子类化意味着隐式重用方法实现.
这两个好处有两个不同的目的:继承:面向子类型和面向代码重用.
如果代码重用是唯一的目的,那么子类化可能比他需要的更多,即父类的一些公共方法对子类没有多大意义.在这种情况下,不需要使组合优于继承,而是要求组合.这也是"is-a"与"has-a"概念的来源.
因此,只有在用于子类型的情况下,即稍后以多态方式使用新类时,我们才会面临选择继承或组合的问题.这是在讨论中缩短的习语中省略的假设.
要使子类型符合类型签名,这意味着组合总是暴露出同样数量的API类型.现在,权衡取舍:
如果不重写,继承提供直接的代码重用,而组合必须重新编码每个API,即使它只是一个简单的委托工作.
继承通过内部多态站点提供直接的开放递归this
,即在另一个成员函数(公共或私有)中调用重写方法(甚至类型)(尽管不鼓励).开放递归可以通过合成来模拟,但它需要额外的努力,并且可能并不总是可行(?).这个重复问题的答案可以说明类似的问题.
继承暴露受保护的成员.这会破坏父类的封装,如果子类使用,则会引入子类与其父类之间的另一个依赖关系.
组合具有控制反转的适应性,并且其依赖性可以动态注入,如装饰器模式和代理模式中所示.
组合具有面向组合器的编程的优点,即以类似于复合模式的方式工作.
构图紧接着编程到界面.
组合物具有易于多重继承的优点.
考虑到上述权衡,我们因此更喜欢构成而不是继承.然而,对于紧密相关的类,即当隐式代码重用确实带来好处,或者需要开放递归的神奇力量时,继承应该是选择.
我的一般经验法则:在使用继承之前,请考虑组合是否更有意义.
原因:子类化通常意味着更多的复杂性和连通性,即更难以改变,维护和扩展而不会出错.
来自 Sun的Tim Boudreau的更完整和具体的答案:
我认为使用继承的常见问题是:
无辜的行为可能会产生意想不到的结果 - 这个经典的例子是在初始化子类实例字段之前调用超类构造函数中的可覆盖方法.在一个完美的世界里,没有人会这样做.这不是一个完美的世界.
它为子类提供了有关方法调用顺序的假设的反常诱惑 - 如果超类可能随着时间的推移而演变,这种假设往往不稳定.另见我的烤面包机和咖啡壶类比.
类越来越重 - 你不一定知道你的超类在它的构造函数中做了什么工作,或者它将要使用多少内存.所以构建一些无辜的可能轻量级对象可能比你想象的要贵得多,如果超类演变,这可能会随着时间的推移而改变
它鼓励子类的爆炸.类加载成本时间,更多类成本内存.在您处理NetBeans规模的应用程序之前,这可能不是问题,但在那里,我们遇到了一些实际问题,例如菜单很慢,因为菜单的第一次显示触发了大量的类加载.我们通过转向更具声明性的语法和其他技术来解决这个问题,但这也需要花费时间来修复.
这使得以后更改内容变得更加困难 - 如果你已经公开了一个类,那么交换超类就会打破子类 - 这是一个选择,一旦你公开了代码,你就会嫁给它.因此,如果您没有改变超类的真实功能,那么如果您使用,您可以更自由地更改内容,而不是扩展您需要的内容.举例来说,继承JPanel - 这通常是错误的; 如果子类在某个地方公开,你永远不会有机会重新审视这个决定.如果它作为JComponent getThePanel()访问,您仍然可以这样做(提示:在API中公开组件的模型).
对象层次结构不能扩展(或者使它们后期扩展比计划更难) - 这是典型的"太多层"问题.我将在下面讨论,以及AskTheOracle模式如何解决它(虽然它可能冒犯OOP纯粹主义者).
...
如果你允许遗传,我可以采取的做法是:
除了常数之外,永远不会暴露任何字段
方法应该是抽象的或最终的
从超类构造函数中调用no方法
...
所有这些对小型项目的应用要少于大型项目,对私人类别的应用要少于公共项目
看到其他答案.
什么时候应该选择继承而不是作文?每当句子"一个酒吧是一个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不能以多态方式使用.换句话说,继承不是关于共享属性,而是关于共享功能.派生类应该扩展基类的功能,而不是限制它.
这相当于利斯科夫替代原则:
使用指针或对基类的引用的函数必须能够在不知道它的情况下使用派生类的对象
继承是非常强大的,但你不能强迫它(参见:圆椭圆问题).如果你真的不能完全确定一个真正的"is-a"子类型关系,那么最好选择合成.
继承在子类和超类之间创建了一种强大的关系; 子类必须知道超类的实现细节.当你必须考虑如何扩展它时,创建超级类会更加困难.您必须仔细记录类不变量,并说明其他方法可覆盖的方法在内部使用的内容.
如果层次结构实际上表示is-a-relationship,则继承有时很有用.它涉及开放 - 封闭原则,它规定类应该被关闭以进行修改但是可以扩展.这样你就可以拥有多态性; 有一个处理超类型及其方法的泛型方法,但是通过动态调度,可以调用子类的方法.这是灵活的,并有助于创建间接,这在软件中是必不可少的(更少了解实现细节).
但是,继承很容易被过度使用,并且会产生额外的复杂性,并且类之间存在硬依赖关系.由于层和动态选择方法调用,理解在执行程序期间发生的事情也变得相当困难.
我建议使用composing作为默认值.它更模块化,并提供后期绑定的好处(您可以动态更改组件).此外,单独测试这些东西也更容易.如果您需要使用类中的方法,则不必强制使用某种形式(Liskov Substitution Principle).
你需要看看鲍勃叔叔的SOLID课堂设计原则中的Liskov替代原则.:)
假设飞机只有两个部分:发动机和机翼.
然后有两种方法来设计飞机类.
Class Aircraft extends Engine{ var wings; }
现在你的飞机可以从固定的机翼开始,
并在飞行中将它们改为旋转机翼.它本质上是
一个带翅膀的发动机.但是,如果我想
在飞行中改变引擎呢?
基类Engine
暴露了一个mutator来改变它的
属性,或者我重新设计Aircraft
为:
Class Aircraft { var wings; var engine; }
现在,我也可以动态更换我的发动机.
当您想要"复制"/公开基类的API时,您可以使用继承.如果您只想"复制"功能,请使用委托.
例如:您想要从列表中创建堆栈.堆栈只有pop,push和peek.鉴于您不希望在堆栈中使用push_back,push_front,removeAt等等功能,因此不应使用继承.
这两种方式可以很好地共存,实际上相互支持.
组合就是模块化:你创建类似于父类的接口,创建新对象并委托对它的调用.如果这些对象不需要彼此了解,那么使用组合物是非常安全和容易的.这里有很多可能性.
但是,如果父类由于某种原因需要访问"子类"为没有经验的程序员提供的功能,那么它可能看起来像是一个使用继承的好地方.父类可以只调用它自己的抽象"foo()",它被子类覆盖,然后它可以将值赋给抽象基.
它看起来是一个不错的主意,但在许多情况下,最好只给类一个实现foo()的对象(或者甚至设置手动提供foo()的值,而不是从一些基类继承新类,这需要要指定的函数foo().
为什么?
因为继承是一种移动信息的不良方式.
该组合在这里有一个真正的优势:关系可以颠倒:"父类"或"抽象工作者"可以聚合任何特定的"子"对象实现某个接口+ 任何子类都可以设置在任何其他类型的父类中,它接受它的类型.并且可以有任意数量的对象,例如MergeSort或QuickSort可以对实现抽象比较接口的任何对象列表进行排序.或者换句话说:实现"foo()"的任何一组对象和可以使用具有"foo()"的对象的其他对象组可以一起玩.
我可以想到使用继承的三个真正原因:
您有许多具有相同界面的类,并且您希望节省编写它们的时间
您必须为每个对象使用相同的基类
您需要修改私有变量,在任何情况下都不能公开
如果这些都是真的,则可能需要使用继承.
使用原因1没有什么不好,在对象上有一个可靠的界面是非常好的.这可以使用组合或继承来完成,没问题 - 如果这个界面很简单而且不会改变.通常,继承在这里非常有效.
如果原因是2号,那就有点棘手了.你真的只需要使用相同的基类吗?通常,仅使用相同的基类是不够好的,但它可能是您的框架的要求,这是一个无法避免的设计考虑因素.
但是,如果你想使用私有变量,案例3,那么你可能会遇到麻烦.如果您认为全局变量不安全,那么您应该考虑使用继承来访问私有变量也是不安全的.请注意,全局变量并非都不好 - 数据库本质上是一组全局变量.但如果你能处理它,那就很好了.
除了考虑因素之外,还必须考虑对象必须经历的继承"深度".任何超过五或六级继承的深度都可能导致意外的转换和装箱/拆箱问题,在这些情况下,构建对象可能是明智的.
如果你有一个两个类之间的is-a关系(例如dog是一个犬),你就去继承.
另一方面,当你在两个班级(学生有课程)或(教师学习课程)之间有一个或一些形容词关系时,你选择了作文.
理解这一点的一个简单方法是,当您需要类的对象具有与其父类相同的接口时,应该使用继承,以便可以将其视为父类的对象(向上转换) .此外,对派生类对象的函数调用在代码中的任何地方都将保持不变,但调用的具体方法将在运行时确定(即,低级实现不同,高级接口保持不变).
当您不需要新类具有相同的接口时,应该使用组合,即您希望隐藏该类的实现的某些方面,该类的用户不需要知道.因此,组合更多地支持封装(即隐藏实现),而继承意味着支持抽象(即提供某种东西的简化表示,在这种情况下,对于具有不同内部的一系列类型的相同接口).