策略模式

从 SimUDuck 谈起

SimUDuck 是一个鸭子模拟器应用。初始设计使用继承:所有鸭子共享一个 Duck 基类,子类覆写 display() 以展示不同外观。

classDiagram
    direction LR
    class Duck {
        <<abstract>>
        +quack()
        +swim()
        +display()*
    }
    class MallardDuck {
        +display()
    }
    class RedheadDuck {
        +display()
    }
    Duck <|-- MallardDuck
    Duck <|-- RedheadDuck

quack()swim() 由基类统一实现,display() 由子类各自覆写(绿头鸭和红头鸭有不同的外观)。这个设计清晰直观——直到需求变更。

继承的第一道裂缝

产品经理要求所有鸭子都能飞。最直觉的做法是在 Duck 中添加 fly() 方法,所有子类自动继承。

但问题随即暴露:橡皮鸭也飞上了天。橡皮鸭不会叫(quack() 已被覆写为吱吱声),现在又多了不该有的飞行能力。可以在 RubberDuck 中将 fly() 覆写为什么都不做来临时补救,但紧接着又要加入木头诱饵鸭(DecoyDuck)——它既不会飞也不会叫,又得覆写两个方法。

产品经理还说每六个月要推出一批新鸭子。每次新增子类,都要逐一检查并可能覆写 fly()quack(),这是一场维护噩梦。

接口方案的陷阱

一个自然的想法是把 fly()quack() 从基类中移除,改为定义 FlyableQuackable 接口。能飞的鸭子实现 Flyable,能叫的鸭子实现 Quackable。这解决了「不该有的能力」问题,但带来了新问题——代码重复。如果有 48 种鸭子需要飞行能力,这 48 个类都要各自实现一遍相同的 fly() 方法。一旦飞行逻辑需要修改,就要改 48 处。

方案 优点 致命缺陷
继承(基类实现 fly() 代码复用好 不需要该行为的子类也被迫继承,覆写负担重
接口(Flyable/Quackable 精确控制每个子类的能力 相同行为在多个子类中重复实现,修改代价大

继承不够灵活,接口导致重复——两条路都走不通。问题的根源在于:软件开发中唯一不变的就是变化本身。客户需求变了、底层数据库换了、技术栈升级了、对现有系统的理解加深了……系统必须能优雅地应对变化。

设计原则驱动的重构

SimUDuck 的困境引出了三个关键的设计原则。它们并非全新的概念——在上一节的七大设计原则中已有系统论述——但这里我们通过实际案例看到它们如何协同工作,从问题驱动出最终的设计。

封装变化

Encapsulate what varies. Identify the aspects of your application that vary and separate them from what stays the same.

在 SimUDuck 中,fly()quack() 是随鸭子类型变化的部分,而 swim()display() 的基本结构是稳定的。将变化的部分抽取出来,封装到独立的类族中,后续就可以在不影响稳定部分的前提下自由修改或扩展变化的行为。

这正是开闭原则(OCP)和可变性封装原则(EVP)的实践体现:找到系统中的可变因素,将其封装起来。

具体做法是:将 fly()quack()Duck 类中抽离,为每种行为创建一组独立的类。

面向接口编程

Program to an interface, not an implementation. The declared type of the variables should be a supertype, usually an abstract class or interface, so that the objects assigned to those variables can be of any concrete implementation of the supertype.

这里的「接口」不仅指 Java 等语言的 interface 关键字,更广泛地指超类型(supertype)。核心思想是:变量的声明类型应当是抽象的接口或父类,而非具体的实现类。这样就能在运行时灵活替换不同的具体实现,而使用者完全不需要知道实际的对象类型。

这是依赖倒转原则(DIP)的另一种表述——针对抽象层编程,而非针对具体类编程。

据此,我们为飞行行为和叫声行为各定义一个接口,每种具体行为封装为一个实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 飞行行为接口
public interface FlyBehavior {
    void fly();
}

// 用翅膀飞
public class FlyWithWings implements FlyBehavior {
    public void fly() {
        System.out.println("I'm flying!!");
    }
}

// 不会飞(橡皮鸭、诱饵鸭等)
public class FlyNoWay implements FlyBehavior {
    public void fly() {
        System.out.println("I can't fly");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 叫声行为接口
public interface QuackBehavior {
    void quack();
}

// 正常嘎嘎叫
public class Quack implements QuackBehavior {
    public void quack() {
        System.out.println("Quack");
    }
}

// 吱吱叫(橡皮鸭)
public class Squeak implements QuackBehavior {
    public void quack() {
        System.out.println("Squeak");
    }
}

// 不出声(诱饵鸭)
public class MuteQuack implements QuackBehavior {
    public void quack() {
        System.out.println("<< Silence >>");
    }
}

注意:实现飞行和叫声接口的并不是 Duck 的子类,而是一组专门代表行为的独立类。它们的存在理由就是封装特定行为——这不是什么奇怪的设计。

一个只有行为的类,合理吗?

在 OO 系统中,类通常既有状态(实例变量)又有行为(方法)。但行为类完全可以拥有状态——例如飞行行为可以有每分钟翅膀拍打次数(wing beats per minute)、最大高度(max altitude)、最大速度(speed)等属性。不必拘泥于「类必须代表现实实体」的思维定式。

组合与委托

Duck 类不再自己实现飞行和叫声逻辑,而是持有行为接口类型的引用,将行为委托(delegate)给行为对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public abstract class Duck {
    FlyBehavior flyBehavior;       // 声明为接口类型
    QuackBehavior quackBehavior;

    public Duck() {}

    public abstract void display();

    public void performFly() {
        flyBehavior.fly();         // 委托给行为对象
    }

    public void performQuack() {
        quackBehavior.quack();     // 委托给行为对象
    }

    public void swim() {
        System.out.println("All ducks float, even decoys!");
    }

    // setter 方法——支持运行时动态切换行为
    public void setFlyBehavior(FlyBehavior fb) {
        flyBehavior = fb;
    }

    public void setQuackBehavior(QuackBehavior qb) {
        quackBehavior = qb;
    }
}

两个关键的设计决策:

  1. 成员变量类型是接口FlyBehaviorQuackBehavior),而非具体类——面向接口编程
  2. 提供 setter 方法允许运行时修改行为——这是组合相比继承的核心优势

鸭子不再通过 IS-A(继承)获得飞行和叫声能力,而是通过 HAS-A(组合)拥有飞行行为和叫声行为。

Favor composition over inheritance. HAS-A can be better than IS-A.

组合的优势在于:

  1. 灵活性更高——可以在运行时动态切换行为
  2. 将算法族封装到独立的类层次中,便于复用和扩展
  3. 其他类型的对象也可以复用这些行为,不再被锁定在 Duck 继承体系中

这正是合成复用原则(CRP)的核心主张。

完整实现

具体鸭子

子类在构造器中设置具体的行为对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MallardDuck extends Duck {
    public MallardDuck() {
        quackBehavior = new Quack();        // 绿头鸭正常嘎嘎叫
        flyBehavior = new FlyWithWings();   // 用翅膀飞
    }

    public void display() {
        System.out.println("I'm a real Mallard duck");
    }
}

public class ModelDuck extends Duck {
    public ModelDuck() {
        quackBehavior = new Quack();
        flyBehavior = new FlyNoWay();   // 模型鸭一开始不会飞
    }

    public void display() {
        System.out.println("I'm a model duck");
    }
}

MallardDuck 的构造器设置了 QuackFlyWithWings——当 performQuack() 被调用时,行为被委托给 Quack 对象,于是我们听到真正的嘎嘎叫声。

运行时动态切换

添加一种新的飞行行为——火箭动力飞行:

1
2
3
4
5
public class FlyRocketPowered implements FlyBehavior {
    public void fly() {
        System.out.println("I'm flying with a rocket!");
    }
}

然后在运行时给模型鸭装上火箭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MiniDuckSimulator {
    public static void main(String[] args) {
        // 绿头鸭
        Duck mallard = new MallardDuck();
        mallard.performQuack();  // 输出: Quack
        mallard.performFly();    // 输出: I'm flying!!

        // 模型鸭——动态切换飞行行为
        Duck model = new ModelDuck();
        model.performFly();      // 输出: I can't fly
        model.setFlyBehavior(new FlyRocketPowered());  // 运行时切换!
        model.performFly();      // 输出: I'm flying with a rocket!
    }
}

模型鸭在运行时从「不会飞」变成了「火箭动力飞行」。如果行为逻辑写死在类内部——无论是通过继承还是直接实现——这种动态切换都无法实现。

完整类图

classDiagram
    class Duck {
        <<abstract>>
        FlyBehavior flyBehavior
        QuackBehavior quackBehavior
        +swim()
        +display()*
        +performQuack()
        +performFly()
        +setFlyBehavior(FlyBehavior)
        +setQuackBehavior(QuackBehavior)
    }

    class FlyBehavior {
        <<interface>>
        +fly()*
    }
    class FlyWithWings {
        +fly()
    }
    class FlyNoWay {
        +fly()
    }
    class FlyRocketPowered {
        +fly()
    }

    class QuackBehavior {
        <<interface>>
        +quack()*
    }
    class Quack {
        +quack()
    }
    class Squeak {
        +quack()
    }
    class MuteQuack {
        +quack()
    }

    class MallardDuck {
        +display()
    }
    class RedheadDuck {
        +display()
    }
    class RubberDuck {
        +display()
    }
    class DecoyDuck {
        +display()
    }

    Duck --> FlyBehavior
    Duck --> QuackBehavior
    FlyBehavior <|.. FlyWithWings
    FlyBehavior <|.. FlyNoWay
    FlyBehavior <|.. FlyRocketPowered
    QuackBehavior <|.. Quack
    QuackBehavior <|.. Squeak
    QuackBehavior <|.. MuteQuack
    Duck <|-- MallardDuck
    Duck <|-- RedheadDuck
    Duck <|-- RubberDuck
    Duck <|-- DecoyDuck

客户端(MiniDuckSimulator)使用的是 Duck 抽象类型。飞行行为和叫声行为各自被封装为一族可互换的算法——每组行为都可以独立于 Duck 及其子类而变化。

这就是策略模式

策略模式

策略模式

策略模式(Strategy Pattern)定义了一族算法,分别封装起来,使它们之间可以互相替换。策略模式使得算法的变化独立于使用算法的客户。

The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

别名:Policy

结构

classDiagram
    direction LR
    class Context {
        -strategy: Strategy
        +ContextInterface()
    }
    class Strategy {
        <<interface>>
        +AlgorithmInterface()*
    }
    class ConcreteStrategyA {
        +AlgorithmInterface()
    }
    class ConcreteStrategyB {
        +AlgorithmInterface()
    }
    class ConcreteStrategyC {
        +AlgorithmInterface()
    }

    Context o-- Strategy : strategy
    Strategy <|.. ConcreteStrategyA
    Strategy <|.. ConcreteStrategyB
    Strategy <|.. ConcreteStrategyC

三个角色的职责:

  • Context(上下文):持有一个 Strategy 的引用,通过该引用调用算法。Context 不关心具体使用了哪种策略
  • Strategy(策略接口):声明所有支持的算法的公共接口
  • ConcreteStrategy(具体策略):实现 Strategy 接口,封装具体的算法逻辑

在 SimUDuck 中的对应关系:

策略模式角色 SimUDuck 对应
Context Duck
Strategy FlyBehavior / QuackBehavior
ConcreteStrategy FlyWithWings / FlyNoWay / Quack / Squeak

动机

考虑一个文本排版系统,需要将文本流分割成行。存在多种换行算法(简单贪心、Knuth-Plass 最优、基于连字符等),如果将所有算法硬编码在一个类中:

1
2
3
4
5
6
7
8
9
10
11
public class Context {
    // ...
    public void algorithm(String type) {
        // ...
        if (type == "strategyA") { ... }
        else if (type == "strategyB") { ... }
        else if (type == "strategyC") { ... }
        // 每增加一种算法,就要修改这个方法
    }
    // ...
}

这段代码违反了开闭原则——每次新增算法都必须修改 Context 类。而且所有算法的代码纠缠在一起,不便于理解、测试和复用。策略模式将每种算法封装为独立的 Strategy 类,通过组合和多态实现算法的灵活切换。

适用场景

在以下情况下适合使用策略模式:

  1. 多个相关类仅行为不同——策略模式提供了一种用多种行为中的一种来配置类的方法
  2. 算法存在多种变体,且不同变体体现了不同的时间/空间权衡——可以将这些变体组织为算法的类层次结构
  3. 算法使用了客户不应知道的数据——策略模式可以避免暴露复杂的、算法特定的数据结构
  4. 一个类定义了多种行为,以多重条件语句的形式出现——将各个条件分支移入独立的 Strategy 类,以替代大量的 if-else 判断

效果

策略模式的优势:

  • 算法族的复用:Strategy 类层次定义了一族可复用的算法或行为。继承可以进一步提取各算法的公共部分,避免重复代码
  • 替代子类化:直接在 Context 子类中覆写方法也能实现不同的算法,但会将算法逻辑纠缠到 Context 中,使其难以理解和维护。策略模式将算法独立出来,便于切换、理解和扩展
  • 消除条件语句:将不同行为封装到独立的 Strategy 类中,消除了 Context 中冗长的条件判断
  • 实现的多样选择:策略可以为同一行为提供不同的实现,客户端可以根据不同的时间/空间权衡来选择合适的策略

策略模式的代价:

  • 客户必须了解不同策略:客户需要知道各策略的差异才能做出合适的选择,这可能暴露部分实现细节
  • Strategy 与 Context 之间的通信开销:Context 会向 Strategy 传递通用参数,某些简单策略可能并不需要全部参数
  • 对象数量增多:每种策略一个类,增加了系统中的对象数量

策略模式的代价在简单场景中几乎可以忽略——SimUDuck 中鸭子的行为种类有限,通信开销极低,而获得的灵活性收益是巨大的。这些代价在大规模系统中才需要认真权衡。

设计模式的价值

共享词汇

设计模式不只是技术工具,更是开发者之间的共享词汇(shared vocabulary)。课件用餐厅点单来类比:

  • Alice(顾客):「我要白面包上涂奶油芝士加果冻,巧克力汽水加香草冰淇淋,培根烤芝士三明治,吐司金枪鱼沙拉,香蕉船加冰淇淋和切片香蕉,加一杯加奶加两块糖的咖啡,哦,再烤个汉堡!」
  • Flo(老手服务员):「给我一个 C.J. White, a black & white, a Jack Benny, a radio, a house boat, a coffee regular and burn one!」

两人点的是完全一样的单。Alice 逐一描述每道菜的细节,Flo 用行业术语一句话搞定——更快、更精确、不易出错。

设计模式在团队沟通中扮演类似的角色:

  • 以少传多:说「这里用策略模式」,团队成员立刻理解整套结构——接口抽象、组合委托、运行时替换——无需逐一解释每个类的关系
  • 提升思维层次:在设计讨论中保持在模式层面(pattern level)思考,避免过早陷入对象层面(object level)的细节
  • 加速团队协作:共享词汇帮助初级开发者更快融入团队,理解系统架构决策

设计模式与库/框架

Q: 既然设计模式这么好,为什么不能做成库直接调用?

设计模式比库更高层次。模式告诉你如何组织类和对象来解决特定问题,而具体实现需要根据应用场景来适配。它们不能被打包为可复用的代码库,因为模式描述的是结构和关系,而非具体的实现。

Q: 库和框架不就是设计模式吗?

不是。库和框架提供具体的实现代码,但其内部可能使用了设计模式。理解设计模式有助于更快掌握基于模式构建的 API——当你认出一个框架使用了策略模式,你就能立刻理解它的扩展方式。

设计模式的本质

仅仅了解抽象、继承、多态这些 OO 基础概念,并不能让你成为优秀的面向对象设计者。优秀的设计者思考的是如何创建灵活的、可维护的、能应对变化的设计。好的面向对象设计应当是可复用的(reusable)、可扩展的(extensible)和可维护的(maintainable)——而设计模式正是帮助你构建具备这些品质的系统的经过验证的 OO 设计经验。模式还提供了一种共享语言,能最大化开发者之间沟通的价值。

关于设计模式,有几个要点值得记住:

  • 模式不给你代码,而是给你通用的解决方案,需要你根据具体应用来适配
  • 模式不是发明出来的,而是从反复出现的实践中发现
  • 大多数模式和设计原则都在应对软件中的变化
  • 大多数模式让系统的某部分可以独立于其他部分变化
  • 我们经常做的事情就是把系统中变化的部分提取出来加以封装

小结

本节从 SimUDuck 实例出发,展示了纯继承和纯接口方案在应对行为变化时的局限性,引出了三个核心设计原则:

设计原则 含义 对应的 OOD 原则
封装变化 将变化的部分抽取并独立封装 开闭原则(OCP)/ 可变性封装原则(EVP)
面向接口编程 变量声明为超类型,不依赖具体实现 依赖倒转原则(DIP)
组合优于继承 用 HAS-A(组合)代替 IS-A(继承)获得灵活性 合成复用原则(CRP)

这三个原则的协同应用催生了策略模式——通过将算法族封装为独立的接口与实现类、面向接口编程、使用组合委托,实现了算法与客户的解耦以及运行时的灵活切换。

设计工具箱至此:

层次 内容
OO 基础 抽象、封装、多态、继承
OO 原则 封装变化、面向接口编程、组合优于继承
OO 模式 策略模式