理解“组合优于继承原则”

如何理解“组合优于继承”的说法

整理自知乎

原作者:萧萧
原链接:https://www.zhihu.com/question/21862257/answer/181179184

前言

首先,广为流传的“组合优于继承” 的说法是一种不严谨的翻译,其来源如下:(众多设计模式强调的两个个最核心原则《Design Patterns: Elements of Reusable Object-Oriented Software》)

1.Program to an interface, not an implementation. (面向接口编程,而不是具体的实现)
2.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来继承该类,实现不同的行为, 同样是组合方式的应用。所以不难发现, 当我们拿类继承组合在一起进行对比时, 并不是以实现方式中是否有用到类继承来区分的。

我们真正关注的是行为的继承行为的组合 :需要变化的行为是通过继承后重写的方式实现,还是通过赋予不同的行为实例对象实现。

三、继承与组合对比

3.1类继承优点

  • 类之间的继承关系时在编译时刻静态地定义好的,因此使用起来也非常直观,毕竟继承是被编程语言本身所支持的功能。
  • 类继承也使得修改要复用的代码变得相对容易,因为可以仅仅重写要更改的父类方法。(如在Java集合标准库中,大量方法就是通过继承复用的,针对这些方法的维护、升级都相对简单)

3.2类继承缺点:

  • 第一个缺点是伴随第一个优点而生的:无法在运行时改变继承了父类的子类行为(这一点在之前汽车的例子中已经进行了说明)。

  • 第二个缺点更严重:通过继承实现的代码复用,本质上把父类的内部实现细节暴露给了子类,子类的实现会和父类的实现紧密的绑定在一起,结果是父类实现的改动,会导致子类也必须得改变

以之前的例子进行说明,如果是通过继承的方式来实现不同型号汽车的刹车行为变化,假设现在我们基于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集合标准库中,后期对Stream的支持,直接改变了interface类最早的定义,在interface中加入了默认实现机制)

3.3对象组合优点:

  • 对象的组合是在运行时,通过对象之间所获取的引用关系决定的,所以对象组合要求不同的对象遵从对方所实现的接口来做引用传递,这样反过来会要求开发者更用心地设计接口,以此保证客户端在使用一个对象时,可以把它和很多其他的对象组合在一起使用而不会出现问题。
  • 对象的组合由于是通过接口实现的,这样在复用的过程中不会打破其封装性。任意一个对象都可以在运行时被替换成另外一个实现了相同接口且类型相同的对象,更重要的是,由于一个对象的实现是针对接口而编写的,具体实现之间的依赖会更少。
  • 对象组合的方式可以帮助你保持每个类的内聚性,让每个类专注实现一个任务。类的层次会保持的很小,不会增长到一种无法管理的恐怖数量。(这也是Java只支持单继承的原因

3.4对象组合缺点

  • 不具备之前所罗列的类继承的优点。
posted @ 2021-07-07 17:57  JaxYoun  阅读(3337)  评论(0编辑  收藏  举报