CS-Notes全部笔记在阅读器中打开

9. 策略(Strategy)

意图

定义一系列算法,封装每个算法,并使它们可以互换。

策略模式可以让算法独立于使用它的客户端。

类图

下图按后文的运费示例画出:Order 是 Context,FreightStrategy 是 Strategy,fee()、freight() 和 setFreightStrategy() 分别对应 behavior()、doSomething() 和 setStrategy()。

类图的配图

实现

1. 用策略替代 if-else

业务代码中,同一件事常按类型分别处理,例如按配送方式计算运费。这里的规则是:标准快递首千克 10 元,之后每千克加 3 元;加急快递首千克 25 元,之后每千克加 6 元;到店自提免运费。调用方先把重量向上取整为千克数 kg,例如 2.5 kg 取 3。

public class IfElseFreightService {

    public int freight(String type, int kg) {
        if ("STANDARD".equals(type)) {
            return 10 + (kg - 1) * 3;
        } else if ("EXPRESS".equals(type)) {
            return 25 + (kg - 1) * 6;
        } else if ("PICKUP".equals(type)) {
            return 0;
        }
        throw new IllegalArgumentException("未知配送方式: " + type);
    }
}

这种写法的问题是:每新增一种配送方式都要修改 freight(),各分支的计费规则耦合在一个方法中,测试时也只能整体验证。把每个分支拆成一个策略类后,接口为:

public interface FreightStrategy {

    int fee(int kg);
}

每个实现类只包含一种计费规则。FreightStrategy 是 Strategy,三个实现类是 ConcreteStrategy:

public class StandardFreight implements FreightStrategy {

    @Override
    public int fee(int kg) {
        return 10 + (kg - 1) * 3;
    }
}

public class ExpressFreight implements FreightStrategy {

    @Override
    public int fee(int kg) {
        return 25 + (kg - 1) * 6;
    }
}

public class PickupFreight implements FreightStrategy {

    @Override
    public int fee(int kg) {
        return 0;
    }
}

Order 是 Context,持有一个运费策略,计算运费时交给它;用户改选配送方式时,换一个策略对象即可:

public class Order {

    private FreightStrategy freightStrategy;

    public void setFreightStrategy(FreightStrategy freightStrategy) {
        this.freightStrategy = freightStrategy;
    }

    public int freight(int kg) {
        return freightStrategy.fee(kg);
    }
}
public class Client {

    public static void main(String[] args) {
        Order order = new Order();
        order.setFreightStrategy(new StandardFreight());
        System.out.println(order.freight(3));
        order.setFreightStrategy(new ExpressFreight());    // 用户改选加急
        System.out.println(order.freight(3));
    }
}
16
37

同样的结构也适用于支付渠道、消息推送渠道等场景,只是各实现类的内容换成对应的处理逻辑。

2. 与简单工厂和 Spring 容器结合

策略模式还要解决按类型找到策略对象的问题,这一步与简单工厂相同:工厂根据类型返回对象,再由对象执行计算。两者的分工是:工厂决定取得哪一个对象,策略决定执行哪一种算法。用 Map 保存类型与策略的对应关系,即可实现这个工厂:

import java.util.Map;

public class FreightStrategyFactory {

    private static final Map<String, FreightStrategy> STRATEGIES = Map.of(
            "STANDARD", new StandardFreight(),
            "EXPRESS", new ExpressFreight(),
            "PICKUP", new PickupFreight());

    public static FreightStrategy of(String type) {
        FreightStrategy strategy = STRATEGIES.get(type);
        if (strategy == null) {
            throw new IllegalArgumentException("未知配送方式: " + type);
        }
        return strategy;
    }
}

调用方式为 FreightStrategyFactory.of("EXPRESS").fee(3),结果是 37,即 25 + (3 - 1) * 6。

在 Spring 中可以不用手写 Map。容器会把实现同一接口的 Bean 收集为 Map<String, 接口>,key 是 Bean 名称(见附录 A2)。实现类用 @Component 指定名称,名称即为配送方式的编码:

import org.springframework.stereotype.Component;

@Component("STANDARD")
public class StandardFreight implements FreightStrategy {

    @Override
    public int fee(int kg) {
        return 10 + (kg - 1) * 3;
    }
}

ExpressFreight 和 PickupFreight 分别标注为 @Component("EXPRESS") 和 @Component("PICKUP"),FreightService 的写法如下:

import java.util.Map;
import org.springframework.stereotype.Service;

@Service
public class FreightService {

    private final Map<String, FreightStrategy> strategies;

    public FreightService(Map<String, FreightStrategy> strategies) {
        this.strategies = strategies;
    }

    public int freight(String type, int kg) {
        FreightStrategy strategy = strategies.get(type);
        if (strategy == null) {
            throw new IllegalArgumentException("未知配送方式: " + type);
        }
        return strategy.fee(kg);
    }
}

新增配送方式时只需新增一个带 @Component 的实现类,FreightService 不需要改动。实测中新增 @Component("SAME_CITY") 的类后,注入的 Map 多出了 SAME_CITY 这个 key(见附录 A2)。

3. 优缺点与注意事项

优点:

缺点:

注意事项:

与状态模式的比较

状态模式和策略模式的类图相似,都是通过组合一个接口对象来动态改变行为,区别在于这个对象是如何被切换的。

状态模式的 State 对象随运行过程中的状态转移而切换,转移由状态对象或 Context 的内部逻辑触发。例如订单处于“待支付”时调用 pay() 会完成支付,进入“已支付”后再调用 pay() 会被拒绝。

策略模式的 Strategy 对象由客户端创建并交给 Context,之后客户端通过 Context 使用它,需要时再替换为另一个策略。例如先为订单设置“标准快递”的运费计算策略,用户改选“加急”后替换为加急策略。

JDK 中的应用

附录:实测

A1 运费策略与 if-else 对拍

环境:OpenJDK 21.0.13,macOS 15.8.1(arm64)。

用正文中的 IfElseFreightService、FreightStrategy、三个策略实现类和 FreightStrategyFactory 编译,另写一个对拍程序:对 STANDARD、EXPRESS、PICKUP、SAME_CITY 四种类型,各取 kg 为 1~20,共 80 组,比较 if-else 版本、工厂版本和 Lambda 版本的返回值与异常信息。

javac -encoding UTF-8 -d out *.java
java -cp out Main

原始输出:

checked=80 mismatch=0
2.5 kg 按 3 kg 计: STANDARD=16 EXPRESS=37 PICKUP=0
lambda EXPRESS(3)=37
unknown: 未知配送方式: SAME_CITY

为确认对拍能够发现错误,分别注入两处错误后重跑:工厂中 EXPRESS 对应 StandardFreight,mismatch=20;标准快递的计算改为 10 + kg * 3(续重少减 1 千克),mismatch=20。

A2 Spring 中的 Map 注入

环境:Spring Boot 3.5.16(Spring Framework 6.2.19),JDK 21.0.13,Maven 3.9.16,macOS 15.8.1(arm64)。

最小项目包含 FreightStrategy 接口、三个带 @Component 的实现类、FreightService(构造器注入 Map<String, FreightStrategy>),以及用 ApplicationRunner 打印结果的启动类。运行输出:

Map 注入的 key(Bean 名): [EXPRESS, PICKUP, STANDARD]
2.5 kg 按 3 kg 计: STANDARD=16 EXPRESS=37 PICKUP=0
unknown: 未知配送方式: SAME_CITY

扩展实验:新增 @Component("SAME_CITY") 的实现类,FreightService 不做修改,输出的 key 变为 [EXPRESS, PICKUP, SAME_CITY, STANDARD],SAME_CITY 可以正常计算。Spring 官方文档说明,Map 注入时值为该类型的所有 Bean,key 为对应的 Bean 名称。

参考资料

在阅读器中可以用目录和搜索、切换深色模式、练习这篇笔记中的面试题:打开《设计模式 - 策略》