为什么有“组合优于继承”的说法
首先需要注意, 广为流传的“组合优于继承” 的说法是一种不严谨的翻译, 其来源如下
众多设计模式包含的2个最核心原则(引自参考书籍 《Design Patterns: Elements of Reusable Object-Oriented Software》)
Program to an interface, not an implementation. (面向接口编程,而不是具体的实现)
Favor object composition over class inheritance.(如果某个场景的代码复用既可以通过类继承实现, 也可以通过对象组合实现, 尽量选择对象组合的设计方式)
第一个原则的好处非常明显: 可以极大程度地减少子系统具体实现之间的相互依赖。
第二个原则则不那么容易理解, 下面展开叙述 。
对象组合与类继承的对比
面向对象设计的过程中, 两个最常用的技巧就是类继承和对象组合,同一个场景下的代码复用,这两个技巧基本上都可以完成。 但是他们有如下的区别:
通过继承实现的代码复用常常是一种“白盒复用”, 这里的白盒指的是可见性: 对于继承来说,父类的内部实现对于子类来说是不透明的(实现一个子类时, 你需要了解父类的实现细节, 以此决定是否需要重写某个方法)
对象组合实现的代码复用则是一种“黑盒复用”“: 对象的内部细节不可见,对象仅仅是以“黑盒”的方式出现(可以通过改变对象引用来改变其行为方式)
这里通过汽车的刹车逻辑进行说明。 对于汽车来说, 存在多种不同的型号, 我们会很自然的希望定义一个类 Car 来描述所有汽车通用的刹车行为 brake(), 然后通过某种方式(继承/组合)来为不同的型号的汽车提供不同的刹车行为。
如果通过继承来实现, 思路就是定义一个Car, 实现不同子类 CarModelA, CarModelB 来重写父类的 brake() 方法以体现不同型号车的刹车行为区别。
public abstract class Car { // 也可以将该方法设置成抽象方法, 强迫子类来实现该方法 public void brake() { // 提供一个默认的刹车实现 ... }}public class CarModelA extends Car { public void brake() { aStyleBrake();// A 风格的刹车行为 }}public class CarModelB extends Car { public void brake() { bStyleBrake(); // B 风格的刹车行为 }}
上述的例子展现了如何通过继承来完成不同型号车辆刹车行为的变化。但是可以注意到, 每一个型号的车的刹车行为是在编译时就确定好的 , 没有办法在运行时刻将 CarModelB 的刹车行为赋予 CarModelA 。
如果通过对象组合的实现方式, 则需要为 Car 定义一个引用, 该引用的类型是一个为刹车行为定义的接口。
public interface IBrakeBehavior { public void brake();}public class AStyleBrake implements IBrakeBehavior { public void brake() { aStyleBrake(); // A 风格的刹车行为 }}public class BStyleBrake implements IBrakeBehavior { public void brake() { bStyleBrake(); // B 风格的刹车行为 }}//通过给下面的类赋予 AStyleBrake 或 BStyleBrake 可以完成不同 Model 的刹车行为的切换 // 同理, 汽车其他的行为(如启动 launch) 也可以用类似的方法实现// 不同型号的汽车实现, 可以通过赋予不同风格的行为实例来 “组装” 出来的, 也就不需要为 Car 定义不同的子类了 public class Car{ protected IBrakeBehavior brakeBehavior; public void brake() { brakeBehavior.brake(); } public void setBrakeBehavior(final IBrakeBehavior brakeType) { this.brakeBehavior = brakeType; }}
值得注意的是, 上面的刹车行为不一定需要通过接口来实现, 定义一个 BrakeBehaviour 的父类, 然后再定义AStyleBrake , BStyleBrake 来继承该类, 实现不同的行为, 同样是组合方式的应用。
所以不难发现, 当我们拿类继承和组合在一起进行对比时, 并不是以实现方式中是否有用到类继承而区分的。
我们真正关注的是行为的继承与行为的组合:需要变化的行为是通过继承后重写的方式实现, 还是通过赋予不同的行为实例实现。
继承与组合的优缺点对比
类继承优点:
类之间的继承关系时在编译时刻静态地定义好的, 因此使用起来也非常直观, 毕竟继承是被编程语言本身所支持的功能。
类继承也使得修改要重用的代码变得相对容易, 因为可以仅仅重写要更改的父类方法。
类继承缺点:
第一个缺点是伴随第一个优点而生的: 没有办法在运行时刻改变继承了父类的子类行为。
这一点在之前汽车的例子中已经进行了说明
第二个缺点与第一个缺点相比往往更严重: 通过继承实现的代码复用,本质上把父类的内部实现细节暴露给了子类, 子类的实现会和父类的实现紧密的绑定在一起, 结果是父类实现的改动,会导致子类也必须得改变。
以之前的例子进行说明, 如果是通过继承的方式来实现不同型号汽车的刹车行为变化, 假设现在我们基于 Car 这个父类实现了 10 种不同型号的汽车 CarModel( A, B, C, D, E, F, G,H ,I , J ), 其中前 5 个型号( A、B、C、D、E) 都没有重写父类的刹车方法, 直接使用了父类 Car 提供的默认方法, 后 5 个型号均提供了自己独特的 brake 实现 。 现假设, 我们希望对 Car 中的 brake 方法进行升级改造, 然而,升级改造后的 brake 行为只适用于C,D , 最早的两种型号A, B 并不兼容升级后的刹车行为。 这样, 我们为了保证 A, B 依旧能正常工作, 就不得不把旧的 brake 实现挪到 A、B 中。 或者, 分别去升级 C、 D、E 中的 brake 方法。
对象组合优点:
对象的组合是在运行时刻通过对象之间获取引用关系定义的,所以对象组合要求不同的对象遵从对方所实现的接口来实现引用传递, 这样反过来会要求更加用心设计的接口,以此支持你在使用一个对象时, 可以把它和很多其他的对象组合在一起使用而不会出现问题。
对象的组合由于是通过接口实现的, 这样在复用的过程中是不会打破其封装的。 任意一个对象都可以在运行时刻被替换成另外一个实现了相同接口且类型相同对象, 更重要的是,由于一个对象的实现是针对接口而编写的, 具体实现之间的依赖会更少。
对象组合的方式可以帮助你保持每个类的内聚性,让每个类专注实现一个任务。 类的层次会保持的很小,不会增长到一种无法管理的恐怖数量。 (这也是为什么Java语言支持单继承的原因)
对象组合缺点:
不具备之前所罗列的类继承的优点