接手一个运行了五年多的订单结算模块时,我在一个名叫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_ptr<IDiscountStrategy> strategy) : strategy_(std::move(strategy)) {} double Checkout(const OrderContext& ctx) const { return strategy_->Calculate(ctx); } private: std::unique_ptr<IDiscountStrategy> strategy_; };使用端反而很干净:
OrderContext ctx{500.0, 1, false}; OrderCalculator calc(std::make_unique<FullMinusDiscount>()); 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::function<double(const OrderContext&)> fn) : fn_(std::move(fn)) {} double Checkout(const OrderContext& ctx) const { return fn_(ctx); } private: std::function<double(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; } }; // 使用 OrderCalculator<FullMinusDiscount> calc; double discount = calc.Checkout(ctx);这里要求策略提供静态成员函数,省掉了对象构建,连类实例都不需要。编译器会把Apply内联进Checkout,调用链上没有任何虚函数指针跳转,性能是最好的。代价是策略在运行时不可换,OrderCalculator<FullMinusDiscount>和OrderCalculator<MemberDiscount>就是两个完全不同的类型,没法在一个容器里自由切换。所以模板版适合"策略终身不变"的场景,比如对某个固定格式做序列化、对特定容器做排序。
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_unique<FullMinusDiscount>());- Setter 注入:允许同一个上下文对象在运行时切换策略。比如用户从普通会员升级到白金会员,可以调用
calc.SetStrategy()换掉策略。
calc.SetStrategy(std::make_unique<MemberDiscount>());- 直接传参:策略只在单个方法调用时生效,不保存在上下文状态里。适合一次性管道计算。
double discount = ApplyDiscount(ctx, strategy);生产中我见过不少人过度依赖 Setter,结果每次调用策略前都要确认"策略有没有被替换过",那不是灵活性,是隐性状态。如果策略确实会变,我倾向于用一个独立的策略工厂或者注册表,而不是让上下文承担换策略的职责。
3.4 策略注册表:让扩展不再触碰 if-else
要支持"新增规则不改calculateDiscount",光靠接口还不够,还需要一个注册表,把策略的创建和使用解耦。注册表本质上是把工厂函数挂到一个 map 里:
#include <functional> #include <map> #include <memory> #include <string> using StrategyFactory = std::function<std::unique_ptr<IDiscountStrategy>()>; 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_ptr<IDiscountStrategy> Create(const std::string& key) const { auto it = factories_.find(key); if (it == factories_.end()) { return nullptr; } return it->second(); } private: std::map<std::string, StrategyFactory> factories_; };注册动作可以放在策略类的定义文件里:
// full_minus_discount.cpp static const bool kRegistered = [] { DiscountRegistry::Instance().Register("full_minus_300_80", [] { return std::make_unique<FullMinusDiscount>(); }); return true; }();业务代码里只需要一个 key:
auto 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_ptr<IDiscountStrategy> 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_ptr:
class OrderCalculator { public: explicit OrderCalculator(std::unique_ptr<IDiscountStrategy> strategy) : strategy_(std::move(strategy)) {} private: std::unique_ptr<IDiscountStrategy> 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::variant<NormalDiscount, 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_ptr<IDiscountStrategy> 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::vector<std::unique_ptr<IDiscountStrategy>> 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 换成类",而在于让变化被隔离在独立的类里,让团队扩展新规则时不再心惊胆战地翻老代码。如果你正在维护一个分支越来越长的计算函数,不妨先花半小时把变化点列出来,再对照我这篇的三种实现选一个最不费力的方案,从最小的一两个策略开始落地,比一次性推倒重来稳妥得多。