首页 / 资讯中心 / 文章详情

策略模式实战:从 if-else 重构到 Java/C++ 落地

策略模式实战:从 if-else 重构到 Java/C++ 落地 ★ FEATURED ARTICLE
写业务代码写到一定年头会碰到一类特别难缠的东西一坨接一坨的 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 里套 ifswitch 里套 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 MapDiscountType, 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 MapString, DiscountStrategy strategies new ConcurrentHashMap(); public DiscountStrategyRegistry(ListDiscountStrategy 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_ptrDiscountStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptrDiscountStrategy 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_ptrDiscountStrategy strategy_; };5.2 两种语言下策略模式的关键差异对比维度Java 实现C 实现抽象方式接口interface含纯虚函数的抽象基类对象管理由垃圾回收负责需手动或用智能指针管理生命周期轻量策略可用 lambda 直接实现函数式接口可用 std::function 或 lambda运行期切换setter 注入即可注意所有权转移避免悬空指针编译期检查接口实现校验较宽松override 关键字可强制校验重写C 里还有个小技巧如果策略逻辑很短可以用std::functiondouble(double)代替整个继承体系省掉一堆子类。但一旦策略需要维护状态或者逻辑复杂还是老老实实用抽象基类可读性和调试体验更好。using DiscountFn std::functiondouble(double); PriceContext context(std::make_uniqueLambdaStrategy( [](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 用代码评审时对着过一遍能挡掉不少低级问题。最后聊点个人体会。策略模式我用了很多年最大的感受是它的价值不在于用上了某个模式而在于它逼你去思考哪些东西会变、哪些不会变。真正难的不是写出那几个策略类而是判断边界——把变化的部分精确地圈出来剩下的保持稳定。我见过太多项目策略接口定得随意结果每次加需求都要动接口模式用了个寂寞。所以我现在习惯在动手前先问自己三个问题这套算法族未来半年会不会新增品种调用方能不能只依赖接口策略之间有没有共享状态三个问题的答案清晰了要不要用、接口怎么定基本也就定了。代码结构这东西方向对了后面都是顺水推舟。
阅读完成 · 觉得有帮助?
咨询建站