设计模式拾荒之策略模式(Strategy Pattern): 开闭原则的践行者

  1. 参考书籍: 《Design Patterns: Elements of Reusable Object-Oriented Software》

策略模式与对象组合的关系

策略模式初看上去, 像是在教学小白如何使用继承或是接口, 然而实际上, 它是在教学如何使用对象组合。 了解完对象组合和类继承的关系以后, 再看策略模式, 会觉得豁然开朗。

对象组合与类继承的对比

面向对象设计的过程中, 两个最常用的技巧就是类继承对象组合,同一个场景下的代码复用,这两个技巧基本上都可以完成。 但是他们有如下的区别:

  • 通过继承实现的代码复用常常是一种“白盒复用”, 这里的白盒指的是可见性: 对于继承来说,父类的内部实现对于子类来说是不透明的(实现一个子类时, 你需要了解父类的实现细节, 以此决定是否需要重写某个方法)
  • 对象组合实现的代码复用则是一种“黑盒复用”“: 对象的内部细节不可见,对象仅仅是以“黑盒”的方式出现(可以通过改变对象引用来改变其行为方式)

这里通过汽车的刹车逻辑进行说明。 对于汽车来说, 存在多种不同的型号, 我们会很自然的希望定义一个类 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 , BAStyleBrake 来继承该类, 实现不同的行为, 同样是组合方式的应用。

所以不难发现, 当我们拿类继承组合在一起进行对比时, 并不是以实现方式中是否有用到类继承而区分的。

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

继承与组合的优缺点对比

类继承优点:

  • 类之间的继承关系时在编译时刻静态地定义好的, 因此使用起来也非常直观, 毕竟继承是被编程语言本身所支持的功能。
  • 类继承也使得修改要重用的代码变得相对容易, 因为可以仅仅重写要更改的父类方法。

类继承缺点:

  • 第一个缺点是伴随第一个优点而生的: 没有办法在运行时刻改变继承了父类的子类行为。
    • 这一点在之前汽车的例子中已经进行了说明
  • 第二个缺点与第一个缺点相比往往更严重: 通过继承实现的代码复用,本质上把父类的内部实现细节暴露给了子类, 子类的实现会和父类的实现紧密的绑定在一起, 结果是父类实现的改动,会导致子类也必须得改变。
    • 以之前的例子进行说明, 如果是通过继承的方式来实现不同型号汽车的刹车行为变化, 假设现在我们基于 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语言支持单继承的原因

对象组合缺点:

  • 不具备之前所罗列的类继承的优点

策略模式( Strategy Pattern)

  • 设计意图

    • GoF: 定义一个算法系列, 将其中的每一个算法都封装起来, 使得它们可以相互替换。 策略模式使得算法的变化独立于使用它的客户端。
  • GoF 举例

    • GoF 关于策略模式的举例中提到了分行算法, 然而大部分读者可能并不熟悉分行算法, 下面通过图例来说明一下这种算法的应用场景。
      • 尝试用 XShell 命令行工具连接一个服务器, 然后使用 vim 打开一个有大量文字的文本文件如下
      • 将客户端窗口横向缩窄后, 文字会被重排, 原本没有换行的位置会新增换行
      • 首先我们所打开的文本文件的内容中换行符肯定都是固定的, 我们看到的新增的换行符是客户端在显示文字时, 根据界面窗口的大小, 动态地向 TextView 中的显示的文字增加了换行。而这个添加的过程就可以用到 GoF所描述的分行算法。
    • 为了解决上述场景的分行需求, 很多分行算法被设计出来, 用于将文字流切分为不同的行。 将这些算法生硬地封装到需要调用他们的类中并不是一种妥帖的做法。 具体原因有如下几点:
      • 需要调用分行算法的客户端在添加了分行算法的代码后会变得更加复杂。 这会增加客户端的维护难度, 尤其是当客户端需要支持多种分行算法的时候。
      • 当分行算法与客户端调用代码耦合时, 添加新的分行算法或修改已经实现的算法会比较困难。
  • 解决方案

    • 使用策略模式, 定义不同的类, 将不同的分行算法封装到定义的类中,可以解决上述的问题。 使用这种方式封装的算法被称作一个 strategy
    • 图例说明
      • 图片中的空心三角箭头,代表着继承(extends)或实现(Implement)关系, 由继承者/实现者 指向 被继承者/被继承者。
      • 图片中的实心三角箭头且箭头末尾没有圆圈的, 代表着单一的引用关系, 但是被引用的对象也有可能被其他对象引用。
      • 图片中的实心三角箭头且箭头末尾有圆圈的, 代表着一对多的引用关系。
      • 图片中的末端有圆圈的虚线是一个对方法体内容用伪代码说明的关系
    • Composition 类负责维护和更新文本视区(TextViewer) 中文字的换行符。 但是换行策略(换行算法)并不在 Composition 类里实现, 而是分开实现在 Compositor 抽象类的子类中:
      • SimpleCompositor 实现了一个简单的策略, 遍历文本过程中, 逐个决定是否添加换行符。
      • TeXCompositor 实现了一个 TeX 分行算法。 该算法尝试从全局的角度优化分行符的位置。 它会以段落为单位来决定是否添加换行符。
      • ArrayCompositor 实现的分行策略会添加换行, 使得每一行都包含相同数量的元素, 这种算法在尝试把多个图标拆分为多行时会很有用。
    • Composition 会持有一个 Compositor 对象的引用。 每当 Composition 类要重排文本格式时, 它会把重排文本的责任转发给它的 Compositor 对象。 需要调用 Compositor 的代码负责指定哪一个 Compositor 应该被使用。

应用场景

  • 在如下情况使用策略模式(Strategy Pattern)

    • 很多相关联的类仅仅是行为不同。 策略模式提供了一种方式类将一个类的行为配置为多种行为类别的其中之一 。
    • 你需要一个算法的不同变种。 例如, 你可能会为了解决一个问题(例如排序)定义多种不同的时间复杂度和空间复杂度不同的算法。 使用策略模式可以在运行时刻根据数据或内存空间的变化决定使用哪一种算法。

      • 一个类定义了多种行为, 而这些行为位于不同的条件分支中。 例如

            if( condition == A) 
                doA() ; // 大量复杂的操作
            if(condition == B) 
                doB() ; // 大量复杂的操作
            if(condition == C) 
                doC(); // 大量复杂的操作
                ......`
      • 这种情况下, 相比使用多个条件分支, 把每个分支的行为迁移到各自的策略类中更为合适(Tips: 尤其适用于 每个条件分支的行为都比较复杂,操作比较多的时候, 不理解的同学可以瞅一眼如下代码, 应该就能秒懂 。

            Action action  = ActionFactory.createAction(String parameters); 
            action.execute

结构图

  • 图片中的空心三角箭头,代表着继承(extends)或实现(Implement)关系, 由继承者/实现者 指向 被继承者/被继承者。
  • 图片中的实心三角箭头且箭头末尾没有圆圈的, 代表着单一的引用关系, 但是被引用的对象也有可能被其他对象引用。
  • 图片中的实心三角箭头且箭头末尾有圆圈的, 代表着一对多的引用关系。

总结

策略模式的中心思想是对象组合的应用, 从这个角度出发的话, 可能几乎所有的程序员都或多或少地应用过策略模式。

你可能感兴趣的:(设计模式)