写业务代码写到一定年头,会碰到一类特别难缠的东西:一坨接一坨的 if-else 或者 switch-case,每个分支里塞着一段独立的算法逻辑,改一个分支怕碰坏另一个,加一个新分支又得回到那个已经几百行的文件里埋头找位置。设计模式里的策略模式就是冲这类问题来的——它把每一种算法单独抽成一个类,让它们可以互相替换,而调用方只认一个统一接口。道理听着不复杂,但真要把它用顺,接口怎么定、策略怎么选、注册表放哪、要不要跟枚举或工厂搭着用、项目里怎么自动装配,每一步都有讲究,也都有坑。这篇打算把策略模式从概念、结构、适用边界一路讲到代码落地,Java 和 C++ 两种实现的差异也会掰开说清楚,顺手把重构 if-else 的完整过程走一遍。不管你是正在赶设计模式大作业的学生,还是工作几年想把手头那坨条件分支收拾干净的同学,应该都能从里面抄到能直接用的东西。
1. 先弄明白策略模式到底在解决哪类麻烦
很多教程一上来就抛定义,看完还是不知道什么时候该用它。我更愿意从一个具体需求切入,先把痛点摆出来,再回头看模式的设计动机,这样印象才深。
1.1 一个几乎人人都写过的需求:订单优惠计算
假设你在做一个电商结算模块,需要根据用户下单时选用的优惠方式,算出最终应付金额。一开始需求很简单,只有两种:满减和无优惠。你大笔一挥写了个方法:
public BigDecimal calculate(String type, BigDecimal price) { if ("FULL_REDUCTION".equals(type)) { if (price.compareTo(new BigDecimal("200")) >= 0) { return price.subtract(new BigDecimal("30")); } return price; } else if ("NONE".equals(type)) { return price; } throw new IllegalArgumentException("unknown type: " + type); }这段代码在当时是没问题的,逻辑清楚,测试也好写。问题出在需求会一直变。过两周产品说加打折券,按 8 折;再过两周说加会员专享价,跟其他优惠互斥;再过一个月说每种优惠的叠加规则不一样,甚至同一个优惠在不同品类下的算法都不同。于是那个方法开始膨胀,if 里套 if,switch 里套 switch,分支从 2 个变成 8 个再变成 15 个,方法体从 10 行变成 300 行。
更糟的是,这段代码同时踩了三个雷:第一,每次加一种优惠都要改动这个已经稳定的方法,违反开闭原则,回归测试范围失控;第二,所有算法的代码物理上挤在一起,职责边界模糊,一个人改满减可能顺手把打折的分支格式调了;第三,单元测试没法只测一种算法,你得构造一堆参数把整个方法跑一遍,测试用例相互干扰。
1.2 策略模式的核心定义与解题思路
策略模式(Strategy Pattern)的官方定义是:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换,让算法的变化独立于使用它的客户端。这句话拆开来看,其实就三件事——把算法抽成独立的类、给这些类一个统一的接口、让调用方面向接口而不是面向具体实现。
回到优惠计算的例子,所谓"一系列算法"就是满减、打折、无优惠各自的计算逻辑;"封装起来"就是每种优惠单独一个类;"相互替换"就是运行期根据用户的选择切换具体实现;"独立于客户端"就是结算流程不需要知道到底是哪种优惠,它只调用统一的方法拿到结果。
这个思路带来的直接收益是:新增一种优惠,只需要新写一个类,结算流程一行都不用改;改满减逻辑,只需要动满减那一个类,其他优惠不受影响;每种优惠的算法可以单独写单元测试,互不干扰。你会发现,前面那三个雷,全被拆掉了。
提示:策略模式的本质不是"消除 if-else",而是"把变化的维度独立出去"。如果你的条件分支根本不是算法变化,比如只是简单的参数映射,那硬套策略模式反而会把代码搞复杂,这一点后面会专门讲。
2. 三个角色与调用链路,一次讲透
理解了动机,再看结构就顺了。策略模式的类结构非常轻,核心角色只有三个,但每个角色的职责边界必须拎清,否则很容易写成"披着策略模式外衣的 if-else"。
2.1 抽象策略、具体策略、上下文
第一个角色是抽象策略(Strategy),它通常是一个接口或者抽象类,声明所有具体策略必须实现的方法。以优惠计算为例,接口里就一个方法,输入原价,输出折后价。这个接口是整个模式的契约,是所有替换动作的基准。
public interface DiscountStrategy { BigDecimal calculate(BigDecimal originalPrice); }第二个角色是具体策略(ConcreteStrategy),每一个类实现一种算法。它们之间互不依赖,互不知道对方存在,各自负责一段完整的计算逻辑。这是策略模式最舒服的地方——你在写满减类的时候,完全不用操心打折类长什么样。
public class FullReductionStrategy implements DiscountStrategy { private final BigDecimal threshold; private final BigDecimal reduction; public FullReductionStrategy(BigDecimal threshold, BigDecimal reduction) { this.threshold = threshold; this.reduction = reduction; } @Override public BigDecimal calculate(BigDecimal originalPrice) { if (originalPrice.compareTo(threshold) >= 0) { return originalPrice.subtract(reduction); } return originalPrice; } }第三个角色是上下文(Context),它持有一个抽象策略的引用,对外暴露一个稳定的调用入口,内部把请求委托给当前策略。上下文不关心具体是哪一种策略,它只保证"我拿到了一个策略,我调它的方法"。上下文通常还会提供一个设置策略的方法,让调用方能在运行期切换。
public class PriceContext { private DiscountStrategy strategy; public PriceContext(DiscountStrategy strategy) { this.strategy = strategy; } public void setStrategy(DiscountStrategy strategy) { this.strategy = strategy; } public BigDecimal execute(BigDecimal price) { if (strategy == null) { throw new IllegalStateException("strategy not set"); } return strategy.calculate(price); } }2.2 调用链路:请求是怎么一步步走到具体算法的
把上面三个角色串起来,一次完整的调用是这样走的:客户端决定用哪种优惠,构造出对应的具体策略对象;把这个对象交给上下文;客户端调用上下文的 execute 方法;上下文内部调用 strategy.calculate;具体策略完成计算并返回结果,结果原路回到客户端。
public static void main(String[] args) { BigDecimal price = new BigDecimal("260"); PriceContext context = new PriceContext(new FullReductionStrategy( new BigDecimal("200"), new BigDecimal("30"))); System.out.println(context.execute(price)); // 230 context.setStrategy(p -> p.multiply(new BigDecimal("0.8"))); System.out.println(context.execute(price)); // 208.00 }注意最后那段,因为 DiscountStrategy 是函数式接口(只有一个抽象方法),Java 8 之后可以直接用 lambda 写临时策略,省掉一个类。这在写测试或者做一次性计算时非常顺手,但生产代码里如果一段逻辑会复用,还是老老实实建类,可读性和可维护性更好。
2.3 和简单工厂的关系,别搅在一起
很多人学到这里会迷糊:策略模式是不是就等于简单工厂?不是。简单工厂解决的是"对象怎么创建"的问题,它把 new 的动作收拢到一处;策略模式解决的是"算法怎么切换"的问题,它把变化的行为抽象出来。两者经常配合使用——工厂负责根据类型创建策略,策略负责执行算法。但它们的职责是正交的,把工厂说成策略模式的一部分是不准确的。后面第 4 节我会给一个工厂加策略组合的完整写法,你会看到两者怎么无缝搭起来。
3. 什么时候该上策略模式,什么时候别硬套
学设计模式最容易犯的错是"手里拿着锤子,看什么都像钉子"。策略模式虽好,但它引入的类数量和间接层是有成本的,判断该不该用,比会写更考验功底。
3.1 出现这些信号,说明可以上了
我总结了几条实操中的判断标准,只要中了两条以上,基本就可以考虑重构。
| 判断信号 | 说明 |
|---|---|
| 同一个方法里有大量同构的 if-else 或 switch 分支 | 每个分支逻辑结构相似,只是算法细节不同 |
| 分支里的算法经常独立变化 | 某个分支需求变动频繁,且不希望影响其他分支 |
| 算法需要在运行期动态切换 | 比如根据用户选择、配置项、灰度开关切换行为 |
| 算法逻辑较长且可独立测试 | 想给单独算法写单元测试,但被塞在同一个大方法里 |
| 多个调用方复用同一套算法族 | 算法被好几个入口使用,不想复制粘贴 |
举个真实的场景,我之前做过一个报表导出模块,支持导出 PDF、Excel、CSV 三种格式,后来还要支持在线预览。导出的组织流程是一样的——取数据、组装、输出,变的只是序列化那一步。这就是典型的策略模式适用场景,把序列化抽成一个策略接口,三种格式各写一个类,导出流程完全不用动。后来加预览功能,只是又加了一个策略而已。
3.2 这几种情况,别硬套
反过来,以下几种场景就不适合,硬上只会增加复杂度。
第一,算法数量极少且几乎不会变。比如整个系统只有"是/否"两种处理方式,而且三年没变过,那一个 if-else 就够了,抽成策略类纯属过度设计。第二,策略需要访问上下文的内部私有数据。如果算法依赖大量上下文状态,你会被迫暴露 getter 或者把数据当参数传,不但难看,还破坏了封装。第三,调用方必须了解每一个策略才能做出选择。策略模式经常被诟病的一点就是"选择权暴露给了客户端",如果这个暴露会导致客户端代码里又出现一堆类型判断,那就形成了"if-else 换了个地方继续存在"的尴尬局面,这时候往往需要配合工厂或者注册表来收拢选择逻辑。
注意:策略模式的收益来自"变化的隔离",不是"分支的消除"。如果你只是把 if-else 从 A 类搬到了 B 类,复杂度总量没有下降,那就是白忙活。判断标准是——新增一种算法时,需要修改的已有文件数量是否减少了。
4. Java 代码实战:从 if-else 地狱到策略调度中心
理论讲够了,接下来我把前面的优惠计算需求完整重构一遍,从最原始的写法一步步演进到可以在生产环境落地的版本。这个过程比直接甩最终代码更有价值,因为它展示了每一步为什么要这么改。
4.1 第一版:先看清"坏味道"长什么样
原始版本就是 1.1 节那个 calculate 方法,参数是一个字符串类型的 type。除了前面说的三个问题,它还有一个隐患:type 用的是魔法字符串,写错一个字母编译期根本发现不了,只能等运行期抛异常。而且所有算法共享一个方法栈,局部变量混在一起,想单独调试某一种优惠,得靠打断点加条件,体验很差。
4.2 第二版:抽出接口,让每种优惠独立成类
按第 2 节的三个角色改造后,代码结构变成这样:一个 DiscountStrategy 接口,加上 FullReductionStrategy、PercentDiscountStrategy、NoDiscountStrategy 三个实现类,再加一个 PriceContext 上下文。新增优惠只需要加类,原有代码不改。
但这时还有个问题没解决:谁来根据 type 决定 new 哪个策略?如果调用方还写着if ("FULL_REDUCTION".equals(type)) { ... },那 if-else 只是搬家了。这就引出了下一版。
4.3 第三版:用枚举加注册表收拢策略选择
正确的做法是把"类型到策略实例"的映射收进一个注册中心。我一般用枚举定义类型,用 EnumMap 做注册表,静态块里完成初始化,再暴露一个按类型取策略的方法。
public enum DiscountType { FULL_REDUCTION, PERCENT, NONE }public final class DiscountStrategyRegistry { private static final Map<DiscountType, DiscountStrategy> REGISTRY = new EnumMap<>(DiscountType.class); static { REGISTRY.put(DiscountType.FULL_REDUCTION, new FullReductionStrategy(new BigDecimal("200"), new BigDecimal("30"))); REGISTRY.put(DiscountType.PERCENT, new PercentDiscountStrategy(new BigDecimal("0.8"))); REGISTRY.put(DiscountType.NONE, price -> price); } private DiscountStrategyRegistry() { } public static DiscountStrategy get(DiscountType type) { DiscountStrategy strategy = REGISTRY.get(type); if (strategy == null) { throw new IllegalArgumentException("unsupported discount type: " + type); } return strategy; } }调用方现在变成这样,干干净净:
DiscountType type = DiscountType.valueOf(request.getDiscountType()); DiscountStrategy strategy = DiscountStrategyRegistry.get(type); BigDecimal payable = new PriceContext(strategy).execute(order.getOriginalPrice());用 EnumMap 而不是 HashMap 的原因有两个:一是 key 是枚举,EnumMap 内部用数组实现,查找性能更好,内存占用也更小;二是类型安全,编译期就能避免传入非法 key。
4.4 第四版:在依赖注入环境里自动收集策略
上面的注册表有个短板:每新增一种策略,就得回到注册表里加一行 put。策略一多,这个文件又成了新的改动热点。在主流依赖注入框架下,有个更优雅的办法——让框架把所有实现类自动注入进来,组装成注册表。
@Component public class DiscountStrategyRegistry { private final Map<String, DiscountStrategy> strategies = new ConcurrentHashMap<>(); public DiscountStrategyRegistry(List<DiscountStrategy> allStrategies) { for (DiscountStrategy strategy : allStrategies) { strategies.put(strategy.type(), strategy); } } public DiscountStrategy get(String type) { DiscountStrategy strategy = strategies.get(type); if (strategy == null) { throw new IllegalArgumentException("unsupported type: " + type); } return strategy; } }这样一来,只要实现类是受容器管理的 Bean,就会被自动收集。新增策略时,只需要新建一个类并加注解,注册表一行都不用动,真正实现了对扩展开放、对修改关闭。这里的 type() 方法由策略接口提供,每个实现返回自己的唯一标识。
实操心得:自动收集策略时要注意 Bean 的初始化顺序和命名冲突。如果两个实现返回了相同的 type,我在构造器里会加一个重复校验并直接抛异常,让问题在启动阶段就暴露,而不是等到线上某个请求进来才报错。这个细节能省掉很多排查时间。
5. C++ 版本实现与两种语言的差异
策略模式是语言无关的,但 C++ 因为没有垃圾回收和统一的接口语法,写起来细节差别不小。如果你的设计模式大作业要求用 C++,这一节可以直接参考。
5.1 用抽象基类加虚函数实现
C++ 里"抽象策略"一般写成含纯虚函数的抽象基类,具体策略继承它并实现虚函数。
#include <memory> #include <stdexcept> class DiscountStrategy { public: virtual ~DiscountStrategy() = default; virtual double calculate(double originalPrice) const = 0; }; class FullReductionStrategy : public DiscountStrategy { public: FullReductionStrategy(double threshold, double reduction) : threshold_(threshold), reduction_(reduction) {} double calculate(double originalPrice) const override { return originalPrice >= threshold_ ? originalPrice - reduction_ : originalPrice; } private: double threshold_; double reduction_; }; class PercentDiscountStrategy : public DiscountStrategy { public: explicit PercentDiscountStrategy(double rate) : rate_(rate) {} double calculate(double originalPrice) const override { return originalPrice * rate_; } private: double rate_; };上下文的写法要注意所有权问题。C++ 里裸指针管理生命周期很容易出错,我一般用智能指针。如果上下文不打算长期持有策略,用引用或裸指针配清楚的生命周期约定也行;如果要在运行期替换并独占所有权,用std::unique_ptr最省心。
class PriceContext { public: explicit PriceContext(std::unique_ptr<DiscountStrategy> strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptr<DiscountStrategy> strategy) { strategy_ = std::move(strategy); } double execute(double price) const { if (!strategy_) { throw std::runtime_error("strategy not set"); } return strategy_->calculate(price); } private: std::unique_ptr<DiscountStrategy> strategy_; };5.2 两种语言下策略模式的关键差异
| 对比维度 | Java 实现 | C++ 实现 |
|---|---|---|
| 抽象方式 | 接口(interface) | 含纯虚函数的抽象基类 |
| 对象管理 | 由垃圾回收负责 | 需手动或用智能指针管理生命周期 |
| 轻量策略 | 可用 lambda 直接实现函数式接口 | 可用 std::function 或 lambda |
| 运行期切换 | setter 注入即可 | 注意所有权转移,避免悬空指针 |
| 编译期检查 | 接口实现校验较宽松 | override 关键字可强制校验重写 |
C++ 里还有个小技巧:如果策略逻辑很短,可以用std::function<double(double)>代替整个继承体系,省掉一堆子类。但一旦策略需要维护状态或者逻辑复杂,还是老老实实用抽象基类,可读性和调试体验更好。
using DiscountFn = std::function<double(double)>; PriceContext context(std::make_unique<LambdaStrategy>( [](double price) { return price * 0.8; }));注意:C++ 里如果上下文用裸指针持有策略,一定要保证策略对象的生命周期长于上下文,否则会出现访问已释放内存的问题。这类 bug 往往在测试环境不显现,上线压测时才爆,排查成本极高。用
std::unique_ptr或std::shared_ptr能规避绝大部分问题。
6. 优缺点盘点与几个高频踩坑点
任何模式都是权衡的结果,把优缺点和坑点摊开讲,用的时候心里才有底。
6.1 策略模式带来的实际收益
先说好处。符合开闭原则,新增算法只需加类,不动老代码,回归范围可控。消除大量条件判断,调用方面向接口,逻辑清爽。算法可复用,同一套策略能被多个入口共享。可运行期切换,配合配置或开关能实现灰度、A/B 类实验。便于独立测试,每种算法一个测试类,边界条件写得清楚。职责单一,每个策略类只需要专注自己的计算逻辑,代码短小易读。
6.2 需要接受的代价
再说代价。类数量膨胀,算法一多,项目里会多出一堆小类,文件目录需要合理组织。选择逻辑仍需存在,如果不用工厂或注册表收拢,客户端还是得判断用哪个策略。通信开销,所有策略必须通过接口暴露数据,不能直接读取上下文的私有字段,有时候需要多传几个参数。对象创建成本,如果策略是无状态的,反复 new 是浪费,可以做成单例或复用;如果策略有状态,又要小心并发。
6.3 我实际踩过的几个坑
第一个坑是把有状态的策略做成单例。早些年我把一个带内部计数器的策略类做成了单例,结果多个请求并发调用,计数器互相污染,计算结果全乱。教训是:无状态策略才可以共享,有状态策略必须每次新建或者用线程局部变量。
第二个坑是策略接口设计过宽。一开始接口里放了五六个方法,结果每个实现类都被迫实现一堆用不到的方法,最后只能用空实现凑数。后来我把接口收敛到一到两个核心方法,需要传递的辅助数据改成参数或引入参数对象,实现类立刻清爽了。
第三个坑是在策略里做业务校验。有段时间我把参数合法性校验也塞进了策略,导致每个策略都要重复写一遍,而且校验规则不统一。正确做法是把通用校验前置到上下文或者调用方,策略只负责纯粹的算法计算,单一职责要守住。
第四个坑是异常处理不一致。不同策略对非法输入的处理方式不同,有的抛异常,有的返回默认值,调用方根本猜不到。后来我统一约定:策略内部只处理正常路径,非法输入由上下文统一拦截。约定建立后,问题少了一大半。
实操心得:策略类最好保持无状态、可共享。把配置参数通过构造器注入,而不是存在可变字段里。这样既方便做单例复用,也天然线程安全,省掉一堆并发的心智负担。
7. 常见问题速查表
把平时被问得最多、自己也最容易搞混的几个问题整理成表,方便随时对照。
| 问题 | 排查思路与建议 |
|---|---|
| 用了策略模式,if-else 反而更多了 | 说明策略选择逻辑没被收拢,补一个工厂或注册表,把判断集中在创建阶段 |
| 新增策略要改的地方太多 | 检查是否用了手动注册表,考虑改成自动收集或配置化注册 |
| 策略之间需要共享数据 | 优先通过方法参数传递,必要时引入参数对象,避免让策略反向依赖上下文 |
| 并发下计算结果错乱 | 检查策略是否持有可变状态,改成无状态或每请求新建实例 |
| 运行时找不到对应策略 | 增加启动期校验,注册时检查重复与缺失,让问题提前暴露 |
| 单元测试不好写 | 把策略接口依赖注入进上下文,测试时用桩实现替换真实策略 |
| 策略类命名混乱 | 统一后缀(如 Strategy)并放到独立包或目录,用类型标识命名,方便检索 |
| 什么时候该升级成更复杂的模式 | 当策略之间出现组合、嵌套或状态流转时,才考虑结合组合模式或状态模式,别提前上复杂度 |
这张表我在团队里当 checklist 用,代码评审时对着过一遍,能挡掉不少低级问题。
最后聊点个人体会。策略模式我用了很多年,最大的感受是:它的价值不在于"用上了某个模式",而在于它逼你去思考"哪些东西会变、哪些不会变"。真正难的不是写出那几个策略类,而是判断边界——把变化的部分精确地圈出来,剩下的保持稳定。我见过太多项目,策略接口定得随意,结果每次加需求都要动接口,模式用了个寂寞。所以我现在习惯在动手前先问自己三个问题:这套算法族未来半年会不会新增品种?调用方能不能只依赖接口?策略之间有没有共享状态?三个问题的答案清晰了,要不要用、接口怎么定,基本也就定了。代码结构这东西,方向对了,后面都是顺水推舟。