资讯动态

C++策略模式实战:用重构终结结算模块的三百行if-else

发布时间:2026/10/13 9:21:31 来源:尧图企业网站定制
接手一个运行了五年多的订单结算模块时我在一个名叫calculateDiscount的函数里看到了三百多行连续排队的 else if。普通用户、会员、满减、节日叠加、生日折扣……每来一次促销就要在函数里插一段新分支而且分支顺序本身还暗藏着业务优先级先满减再会员价顺序一调结算金额立刻变样。后来我把这块重构成了 C 策略模式接手半年新增折扣规则没有一次改过calculateDiscount本体。这篇文章就把这次实战拆开来讲策略模式解决什么问题、C 里三种落地方式怎么选、折扣引擎从零怎么搭以及策略对象的生命周期里最容易踩的坑。适合刚接触设计模式但想直接写出可运行代码的 C 开发者也适合正在维护一堆 if-else 业务逻辑、想做渐进式重构的组内同学。1. 策略模式到底在解决什么问题从一段失控的 if-else 说起1.1 一段典型的重构前代码很多团队不是没有设计模式而是设计模式被写死在了 if-else 里。拿我当时的代码做例子简化以后大概长这样double CalculateDiscount(int userLevel, double orderAmount, bool isHoliday) { if (userLevel 0) { return 0.0; } else if (userLevel 1) { if (orderAmount 300) { return 80.0; } return 0.0; } else if (userLevel 2) { double discount orderAmount * 0.05; if (isHoliday) { discount 20.0; } return discount; } else if (userLevel 3) { // 更多分支... } return 0.0; }单独看每个分支逻辑都不复杂可一旦所有规则挤在同一个函数里复杂度是相乘的规则之间开始互相纠缠用户等级、订单金额、节日标记、会员积分全都耦合在一处。最典型的症状是测试同事发现满 500 减 120在会员等级为 2 时居然算出了负数——因为两个分支都对orderAmount做了减免顺序一错金额就被扣穿了。这个场景就是策略模式最天然的适用对象。1.2 三个角色与一张分工图策略模式把算法的使用者和算法本身分开只保留一个稳定的调用契约。参与者只有三个上下文Context、策略接口Strategy、具体策略Concrete Strategy。上下文持有策略对象的引用只负责调用策略接口不关心策略内部是怎么算的策略接口声明一个统一的方法比如double Calculate(const OrderContext ctx)具体策略各自实现接口把满减、会员折扣、节日叠加封装成独立类。用生活里的例子理解去餐厅点餐厨师是上下文他手里的刀具是策略接口切丝、切片、切块是具体策略。厨师知道的是我这把刀能切至于老板今天要求切丝还是切块是在运行时才确定的。换一把刀不会把厨师的手艺推倒重来。1.3 什么时候代码在向你求救不是所有分支都需要策略模式但下面几个信号同时出现时基本可以确认了函数里有大量 if-else 或 switch-case但每个分支做的事类型相同只是算法实现不同新增一种算法必须改动已有函数哪怕只是新增一个 case同一组算法可能在运行时动态切换或者在不同产品线之间复用代码逻辑本身的骨架已经稳定变的是细节填充。我当时还犯过一个认知错误看到状态就以为该用策略模式结果做了个半吊子的状态机。策略模式处理的是算法族互相替换状态模式处理的是对象行为随内部状态改变——两者长得很像但出发点完全不同。识别变化点是第一步这一步错了后面写得越规范离需求越远。2. C 里三种落地实现虚函数、std::function、模板2.1 经典虚函数版稳定、直观、运行时替换策略模式在 C 里的经典写法依赖抽象基类和虚函数。接口定义如下struct OrderContext { double orderAmount 0.0; int userLevel 0; bool isHoliday false; }; class IDiscountStrategy { public: virtual ~IDiscountStrategy() default; virtual double Calculate(const OrderContext ctx) const 0; };具体策略各自实现class FullMinusDiscount final : public IDiscountStrategy { public: double Calculate(const OrderContext ctx) const override { if (ctx.orderAmount 300) { return 80.0; } return 0.0; } }; class MemberDiscount final : public IDiscountStrategy { public: double Calculate(const OrderContext ctx) const override { return ctx.orderAmount * 0.05; } };上下文只需要一个策略接口引用class OrderCalculator { public: explicit OrderCalculator(std::unique_ptrIDiscountStrategy strategy) : strategy_(std::move(strategy)) {} double Checkout(const OrderContext ctx) const { return strategy_-Calculate(ctx); } private: std::unique_ptrIDiscountStrategy strategy_; };使用端反而很干净OrderContext ctx{500.0, 1, false}; OrderCalculator calc(std::make_uniqueFullMinusDiscount()); double discount calc.Checkout(ctx);这套写法的核心价值在运行时替换。用户等级不同传进来的策略对象就不同但Checkout函数一行不用改。unique_ptr管理所有权策略生命周期随OrderCalculator一起结束不会漏内存。接口里virtual ~IDiscountStrategy() default;是硬性要求没有虚析构delete一个派生类对象时就是未定义行为。2.2 std::function 版轻量但注意隐式动态分配如果策略本身没有复杂的类层次也不想为了一个算法建一个类文件现代 C 提供了更轻的方案用std::function保存任意可调用对象。class OrderCalculator { public: explicit OrderCalculator(std::functiondouble(const OrderContext) fn) : fn_(std::move(fn)) {} double Checkout(const OrderContext ctx) const { return fn_(ctx); } private: std::functiondouble(const OrderContext) fn_; };使用时可以直接塞 lambda、函数指针、甚至某个函数对象的operator()OrderCalculator calc([](const OrderContext ctx) { if (ctx.orderAmount 300) { return 80.0; } return 0.0; });这比纯虚接口更灵活策略可以来自任意源不需要事先约定继承关系。但要注意两个坑一是std::function对于大于一个指针大小的可调用对象内部会用小对象优化放不下的会走一次堆分配高频构造时开销比虚函数大二是它的类型信息被擦除了出问题排查时不容易从类型名判断当前绑定的策略是什么。业务规则少、策略就是简单 lambda 时我会用这一版规则多到需要类结构、需要单元测试整理时还是老老实实回到接口版。2.3 模板版编译期策略注入性能最好如果策略在编译期就能确定并且对性能有执念可以用模板把策略做成编译期参数。这就是基于策略的设计Policy-Based Design的雏形template typename DiscountStrategy class OrderCalculator { public: double Checkout(const OrderContext ctx) const { return DiscountStrategy::Apply(ctx); } }; struct FullMinusDiscount { static double Apply(const OrderContext ctx) { if (ctx.orderAmount 300) { return 80.0; } return 0.0; } }; // 使用 OrderCalculatorFullMinusDiscount calc; double discount calc.Checkout(ctx);这里要求策略提供静态成员函数省掉了对象构建连类实例都不需要。编译器会把Apply内联进Checkout调用链上没有任何虚函数指针跳转性能是最好的。代价是策略在运行时不可换OrderCalculatorFullMinusDiscount和OrderCalculatorMemberDiscount就是两个完全不同的类型没法在一个容器里自由切换。所以模板版适合策略终身不变的场景比如对某个固定格式做序列化、对特定容器做排序。2.4 三种实现怎么选一张表说清楚我整理一张对比表方便对照场景做选择维度接口多态版std::function 版模板版运行时切换策略支持支持不支持虚函数开销有无虚表可能堆分配无可内联策略类型约束必须继承接口任意可调用对象必须提供约定接口代码结构清晰适合策略多轻量但类型擦除类型明确但变体多编译期复杂度低低中推荐场景业务规则多变、策略成族简单回调、小策略性能敏感、策略固定我的实际倾向是业务层的策略默认接口多态版引擎内部的轻策略用std::function编译期就定死的算法用模板。三层混用也很常见后面第 5 章会讲怎么把它们组合到一起。3. 实战订单折扣计算引擎从零搭建3.1 需求拆解变化点比你想的多重构前我拿到了一份新的需求清单策略点一下子多出五类普通用户无折扣、满减、会员折扣、节假日叠加、生日双倍积分。把积分和折扣混在一起是我一开始犯的错后来我意识到这是两个不同的变化维度折扣影响金额积分影响权益。策略模式里的一个策略接口应该对应一个变化维度所以我拆成了IDiscountStrategy和IPointsStrategy两套。接口拆得越细复用越容易接口拆得太粗具体策略里就要堆业务判断等于把 if-else 搬了个家。3.2 接口设计参数里放订单上下文别堆裸字段接口方法参数别直接用 userId、amount、holiday 这种裸字段参数一多调用次序一乱迟早出 bug。更合理的做法是定义一个订单上下文结构体把策略计算需要的字段都装进去struct OrderContext { double orderAmount 0.0; int userLevel 0; bool isHoliday false; int orderCount 0; // 历史订单次数 double memberPoints 0.0; };策略接口我尽量设计成纯虚让每个具体策略只考虑一种职责class IDiscountStrategy { public: virtual ~IDiscountStrategy() default; virtual double Calculate(const OrderContext ctx) const 0; };有同事问过我为什么接口里不放默认实现我的经验是默认实现会给具体策略偷懒不实现的机会等某个策略忘了覆盖行为就会在运行时回归到默认值排查代价很高。接口就该最小化明确告诉实现者你必须告诉我怎么算。3.3 Context 装配构造注入、Setter 注入与直接传参策略对象的装配方式有三类各有适用场景构造函数注入策略在对象整个生命周期里不变简单、安全。订单计算通常用这种。OrderCalculator calc(std::make_uniqueFullMinusDiscount());Setter 注入允许同一个上下文对象在运行时切换策略。比如用户从普通会员升级到白金会员可以调用calc.SetStrategy()换掉策略。calc.SetStrategy(std::make_uniqueMemberDiscount());直接传参策略只在单个方法调用时生效不保存在上下文状态里。适合一次性管道计算。double discount ApplyDiscount(ctx, strategy);生产中我见过不少人过度依赖 Setter结果每次调用策略前都要确认策略有没有被替换过那不是灵活性是隐性状态。如果策略确实会变我倾向于用一个独立的策略工厂或者注册表而不是让上下文承担换策略的职责。3.4 策略注册表让扩展不再触碰 if-else要支持新增规则不改calculateDiscount光靠接口还不够还需要一个注册表把策略的创建和使用解耦。注册表本质上是把工厂函数挂到一个 map 里#include functional #include map #include memory #include string using StrategyFactory std::functionstd::unique_ptrIDiscountStrategy(); class DiscountRegistry { public: static DiscountRegistry Instance() { static DiscountRegistry registry; return registry; } void Register(const std::string key, StrategyFactory factory) { factories_[key] std::move(factory); } std::unique_ptrIDiscountStrategy Create(const std::string key) const { auto it factories_.find(key); if (it factories_.end()) { return nullptr; } return it-second(); } private: std::mapstd::string, StrategyFactory factories_; };注册动作可以放在策略类的定义文件里// full_minus_discount.cpp static const bool kRegistered [] { DiscountRegistry::Instance().Register(full_minus_300_80, [] { return std::make_uniqueFullMinusDiscount(); }); return true; }();业务代码里只需要一个 keyauto strategy DiscountRegistry::Instance().Create(full_minus_300_80); if (!strategy) { // 处理未注册情况 } OrderCalculator calc(std::move(strategy)); double discount calc.Checkout(ctx);注册表真正的价值在于新增一个策略只需要新增一个类文件 一段注册代码calculateDiscount和OrderCalculator完全不动。静态注册的[]技巧虽然看起来有点绕但它保证了注册动作发生在main之前的初始化阶段不会被忘记。3.5 新增一条折扣规则需要动哪些代码我把一次实际新增规则的改动清单贴出来。需求是白金会员在节假日享受先 95 折再满 1000 减 200的组合折扣而且普通满减、会员折扣仍然保留。改动如下新建platinum_holiday_discount.h/.cpp实现IDiscountStrategy在platinum_holiday_discount.cpp里注册platinum_holiday在配置层为对应用户等级配置 key platinum_holiday新增两条单测覆盖节假日和非节假日场景。整个过程没有改OrderCalculator、没有改注册表代码、没有碰其他策略。对比以前在calculateDiscount里插一个 else if这次动不动原函数的体验是最直观的收益。新同事往往更怕改老接口而不是新增一个类文件——策略模式的这个特性直接降低了团队协作的心理成本。4. 策略对象生命周期这里翻车最多4.1 该持有指针、引用还是值策略对象生命周期报错是最隐蔽的。我复盘了一下多数翻车都是这三个选择搞错了对象值语义策略无状态或者变量简单时直接持有IDiscountStrategy不是不行但如果涉及多态必须要基类引用/指针否则会发生对象切片指针语义用unique_ptr表达独占所有权用shared_ptr表达共享所有权裸指针只用于观察且明确声明我不拥有它;引用语义策略必然存活于上下文生命周期之外时可以存const reference比如全局单例策略。策略本身通常是无状态的算法集合没必要时没必要每个上下文都 new 一个对象后面第 5 章的享元化会提。这里最重要的一条就是别用裸指针去管理一个你本来应该拥有的对象。4.2 悬垂引用重现一个半小时排查出来的崩溃一次线上故障让我深刻记住了生命周期匹配问题。简化后的报错现场是这样DiscountRegistry reg; auto strategy reg.Create(full_minus_300_80); // unique_ptrIDiscountStrategy OrderCalculator calc(*strategy); // 危险写法calc 只存了个引用 // ... strategy.reset(); // 策略释放 double discount calc.Checkout(ctx); // 悬垂引用未定义行为表面上看OrderCalculator calc(*strategy)编译运行都很正常因为引用解引用拿到的确实是一个策略对象。但OrderCalculator内部只是持有了一个引用并不知道外部还有unique_ptr在管理生命周期。一旦外部reset或作用域结束策略就没了Checkout再调用就读取了已释放内存。排查时崩溃位置随机一会儿在Calculate内部一会儿在寄存器访问处花了一个半小时才找到是生命周期不匹配。我后来写代码时给组里定了一条更严的规则策略对象如果由外部传入上下文要么接收unique_ptr完成所有权转移要么接收引用并保证传入者在上下文销毁前不释放。两者不能混。4.3 正确写法unique_ptr 与注册表返回共享策略对应的正确写法有两种。第一种上下文直接接收unique_ptrclass OrderCalculator { public: explicit OrderCalculator(std::unique_ptrIDiscountStrategy strategy) : strategy_(std::move(strategy)) {} private: std::unique_ptrIDiscountStrategy strategy_; };第二种策略是全局共享的无状态实例用const传入class FullMinusDiscount final : public IDiscountStrategy { public: static const FullMinusDiscount Instance() { static FullMinusDiscount instance; return instance; } };OrderCalculator calc(FullMinusDiscount::Instance());第一种把所有权关系写进了类型签名第二种把共享关系也写清楚了。最怕的是签名用裸指针代码注释写着大家别释放啊结果一重构就有人顺手 delete 了。把生命周期约束表达在类型层面是 C 里减少心智负担的关键。5. 面向 C 特性的策略模式延伸5.1 std::variant 代替虚函数封闭策略集如果策略的数量在编译期就是封闭集合不需要运行时扩展可以用std::variant把策略组合成一个类型#include variant struct NormalDiscount { double Calculate(const OrderContext ctx) const { return 0.0; } }; struct FullMinusDiscount { double Calculate(const OrderContext ctx) const { return ctx.orderAmount 300 ? 80.0 : 0.0; } }; using DiscountStrategy std::variantNormalDiscount, FullMinusDiscount; double ApplyDiscount(const DiscountStrategy strategy, const OrderContext ctx) { return std::visit([](const auto impl) { return impl.Calculate(ctx); }, strategy); }这样做的收益是省掉了虚表std::visit本质上是编译期生成了一张索引跳转表而且策略对象可以直接放在栈上。代价是扩展策略类型必须改动variant的定义破坏开闭原则。所以这个方案适合算法族短期内不会新增的引擎内部逻辑不适合业务层那种每周都可能冒出新规则的地方。5.2 无状态策略的享元化策略类的成员里如果没有任何字段这个策略就是无状态的。无状态策略没必要为每笔订单创建一个新对象十万笔订单就创建十万个对象纯属浪费。享元化的做法是让同一类策略共享同一个实例最简单的方式用函数内静态局部变量class MemberDiscount final : public IDiscountStrategy { public: double Calculate(const OrderContext ctx) const override { return ctx.orderAmount * 0.05; } static const MemberDiscount Instance() { static MemberDiscount instance; return instance; } };只要策略不持有可变状态这是完全安全的。如果策略需要统计调用次数、累计折扣金额这种状态就不能这么搞了要考虑线程局部存储或者把累加器改为原子变量。我的经验是90% 的策略其实可以做成无状态状态应该放在上下文里策略只做输入 → 输出的纯计算这样测试也简单不用 mock 一堆成员变量。5.3 让多个策略叠加复合策略业务里经常出现先满减、再会员折扣这种叠加需求。叠加不是简单调用两个策略而是有顺序的链式计算。复合策略作为一个特殊策略内部持有子策略列表class CompositeDiscount final : public IDiscountStrategy { public: void Add(std::unique_ptrIDiscountStrategy strategy) { strategies_.push_back(std::move(strategy)); } double Calculate(const OrderContext ctx) const override { auto temp ctx; double totalDiscount 0.0; for (const auto strategy : strategies_) { totalDiscount strategy-Calculate(temp); temp.orderAmount - totalDiscount; // 每一步基于折后金额 } return totalDiscount; } private: std::vectorstd::unique_ptrIDiscountStrategy strategies_; };这里有一个业务上经常争议的点到底是每一步基于原始金额还是基于折后金额我在需求评审时拉上产品确认了这个细节最后发现他们实际想要的是折后金额再参与下一级规则。把上下文复制一份放入checkoutCtx并在每次迭代里实时更新orderAmount复合策略自己就是可测试的黑盒。上面的代码顺序也代表了优先级先加入的先生效后面调整顺序就是修改业务优先级。5.4 别忘掉的接口铁律虚析构与 final接口多态版里有一个几乎所有 C 开发者都背过、但偶尔还是会踩的坑基类析构函数不是虚的。一个IDiscountStrategy*指向FullMinusDiscount用delete释放时如果基类析构非虚只析构基类部分派生类资源全部泄漏行为未定义。写接口的时候第一行就写class IDiscountStrategy { public: virtual ~IDiscountStrategy() default; virtual double Calculate(const OrderContext ctx) const 0; };另外具体策略实现时能用final就标final。final不只是性能优化它本身是一个设计信号当前这个策略已经封口了不允许别人随便继承扩展。团队协作时看到final类新人就知道应该在注册表里新增策略而不是回来改这个类。6. 我的一次砍掉策略模式的重构过度设计警钟6.1 为什么一开始觉得需要策略模式策略模式很优雅所以也容易让人为了用而用。我有一次负责一个小工具功能是把内部数据格式导出成文本。当时预想输出格式有两种JSON 和 XML未来可能增加 YAML我顺手就建了一套策略接口加注册表。代码结构无可挑剔但过了一个月需求方明确说我们只固定输出 JSON 一种格式XML 只是当初随口说说的。两个实现类里一个几乎没进过版本库注册表里只有一个 key整个抽象层变成了空转。6.2 回退后发现的问题回退就是把策略接口、注册表、工厂全部删掉只保留一个自由的JsonExport()函数。删除后代码行数减少了两百多行调用链路从函数 → 注册表 → 工厂 → 策略接口 → 具体策略变成函数 → 函数。更明显的变化是排查问题路径变短了以前一个导出问题需要判断注册 key 有没有拼错、策略有没有注册成功、基类接口调用是否正确现在直接进导出函数看逻辑十秒定位。单元测试也从五六个 mock 文件缩成两个普通测试文件。这次回退给我的教训是设计模式解决的是变化不是可能的变化。如果变化连影子都没有抽象层就是在增加维护成本。策略模式的价值必须建立在真实存在的分支扩展和平台差异上而不是你对未来的想象里。6.3 什么时候真的该用策略模式经过那次回退我把该不该用策略模式的判定条件收敛成四条同时满足才动手你手上已经有两套或以上的同类算法在线上跑不是未来可能会有新增算法需要在不修改已有调用方的情况下完成调用方确实需要在不同算法之间切换底层机制无法在编译期固定你能够明确说出策略接口里那个方法业务语义是什么。第四条听着像废话但很多人就是没想清楚接口的抽象含义就新建了接口。接口方法必须抽象到无论哪种策略调用者关心的都是同一件事如果你发现不同策略传入的参数、返回值的语义都不同那说明它们压根不是一个策略族的硬凑在一起只会让代码更别扭。我在订单模块里至今留着策略模式和注册表因为它满足上面全部条件满减、会员、节日组合策略真实存在新规则每周可能出现调用方只需要拿到折扣数值语义一直是这笔订单该优惠多少。在小工具里我砍掉了它因为当时只有一个策略在执行、没有运行时切换需求、抽象接口的语义也只是导出 JSON而已。两种做法都是对的它们对应了不同的客观条件。设计模式本身没有优劣只有是否适配当前问题。策略模式的力量不在于把 if-else 换成类而在于让变化被隔离在独立的类里让团队扩展新规则时不再心惊胆战地翻老代码。如果你正在维护一个分支越来越长的计算函数不妨先花半小时把变化点列出来再对照我这篇的三种实现选一个最不费力的方案从最小的一两个策略开始落地比一次性推倒重来稳妥得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑