资讯动态

C++策略模式实战:从if-else地狱到优雅扩展

发布时间:2026/10/15 14:36:23 来源:尧图企业网站定制
前几天帮团队做代码评审看到支付模块里一个函数有七十多行里面套了三层 if-else根据渠道类型分别拼装参数、调接口、解析回执。功能确实能跑但没人敢改因为每一处分支背后都牵着一个线上渠道动一处就担心影响另一处。我跟同事说这个位置早就该用策略模式了。策略设计模式是 GoF 二十三种经典设计模式里的行为型模式之一核心就一句话定义一族算法把每个算法封装起来并且让它们可以互相替换。用 C 实现时它能把一堆散落在 if-else 里的业务规则收拢成独立的策略类让增加一种新算法从改旧代码变成加新文件。这篇文章适合正在写业务逻辑、被 switch-case 折磨过的 C 开发者也适合想搞懂设计模式到底怎么落地的初学者。我会从最原始的痛点讲起给出经典实现、现代 C 的改造方式再用一个电商计价的案例完整走一遍重构过程最后聊聊那些文档里不会写的坑。1. 从一段能跑但没人敢改的代码说起1.1 业务场景与痛点为什么 if-else 会失控几乎所有长期维护的 C 项目里都能找到这样一段代码一个函数接收一个类型标识然后用 if-else 或者 switch 判断走哪套逻辑。业务刚起步时只有两三种情况写起来很痛快每加一种就多一个分支。等到类型变成七八种甚至十几个问题就来了。首先是可读性崩坏。一个函数里混着不同业务的处理逻辑读代码的人需要在脑子里反复切换上下文。其次是耦合度爆炸。负责判断类型的代码和具体实现的代码焊死在一起新增类型必须修改这个函数违反开闭原则——对扩展开放、对修改关闭。最让人头疼的是测试困难你想单独测某一种算法必须构造一堆前置条件走完整个流程测一个分支等于测全部逻辑。拿我当时评审的支付模块举例每种渠道的签名方式不同、请求字段不同、回调校验不同但主流程都是组装参数—发起请求—解析结果。真正变化的只是中间这一小段算法却被 if-else 和固定流程揉在一起。1.2 策略模式的本质把变化点变成扩展点策略模式对这个问题的解法非常朴素把变化的算法部分抽出来定义成一组策略接口然后在运行时决定用哪一个策略来处理。打个比方这就像遥控器。遥控器的按键布局是固定的但每个按键背后绑定的功能是可以换的——你可以把红色按键从开机改成静音。遥控器上下文不需要关心按键具体干什么它只知道按了之后把事件交给绑定的处理函数。策略模式里的上下文类就是遥控器策略接口就是按键的标准规格具体策略类就是按键背后绑定的实际行动。这套思想的精妙之处在于变化的维度被单独隔离。新增一种算法不需要改动现有的业务主流程只需要新增一个策略类然后在某个配置点告诉上下文以后用这个策略。逻辑上这是一个经典的针对接口编程而非针对实现编程的落地方式。2. 经典策略模式在 C 里的骨架三个角色一次说清2.1 策略接口抽象类的设计要点经典实现里策略模式有三个角色策略接口Strategy定义算法族的公共接口通常是一个抽象基类。具体策略ConcreteStrategy实现接口每个策略封装一种具体算法。上下文Context持有策略接口的引用负责调用策略并在需要时切换策略。策略接口的设计有一个容易被忽略的细节析构函数必须是虚函数。因为上下文通常通过基类指针管理具体策略对象如果不是虚析构delete 基类指针时不会调用派生类的析构函数会造成资源泄漏。现代 C 里我更推荐用 default来声明虚拟析构函数既明确意图又避免写空函数体。同时要考虑接口的语义粒度。策略接口的方法应该描述做什么而不是怎么做。比如压缩场景接口定义应该是compress(src, dst)而不是compressWithZipMethod(src, dst, level)。把实现细节暴露在接口里等于把策略模式又做回了 if-else。2.2 具体策略每一套算法一个类具体策略类负责实现接口。每个类只干一件事内部可以自由组织逻辑类与类之间互不感知。这里我以文件压缩为例展示最传统的实现方式// 策略接口 class ICompression { public: virtual ~ICompression() default; virtual void compress(const std::string src, const std::string dst) 0; }; // 具体策略AZIP 压缩 class ZipCompression : public ICompression { public: void compress(const std::string src, const std::string dst) override { // 这里写 ZIP 算法的具体实现 std::cout [ZIP] compress src - dst std::endl; } }; // 具体策略B7z 压缩 class SevenZipCompression : public ICompression { public: void compress(const std::string src, const std::string dst) override { // 这里写 7z 算法的具体实现 std::cout [7Z] compress src - dst std::endl; } };注意每个策略类的职责边界它只负责怎么压缩不负责什么时候压缩压缩完怎么办。如果策略类内部开始依赖外部状态、或者开始调用业务主流程的方法说明职责又混回去了。2.3 上下文对象持有策略并调用上下文是策略模式里的调度中心。它持有策略接口的引用或指针对外开放一个统一的操作入口。当外部调用上下文的入口时上下文把任务委托给当前持有的策略。class Compressor { public: explicit Compressor(std::unique_ptrICompression strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptrICompression strategy) { strategy_ std::move(strategy); } void doCompress(const std::string src, const std::string dst) { if (!strategy_) { throw std::runtime_error(strategy is null); } strategy_-compress(src, dst); } private: std::unique_ptrICompression strategy_; };这里我直接用了std::unique_ptr而不是裸指针这是现代 C 的基本要求上下文拥有策略对象的所有权析构时自动释放避免手动 delete 的麻烦。使用方式如下auto compressor std::make_uniqueCompressor( std::make_uniqueZipCompression()); compressor-doCompress(a.txt, a.zip); compressor-setStrategy(std::make_uniqueSevenZipCompression()); compressor-doCompress(a.txt, a.7z);运行期切换策略正是策略模式最典型的应用场景同一个上下文对象通过setStrategy更换算法而不需要修改上下文内部任何代码。这种运行时替换算法的能力是策略模式区别于其他模式的核心特征。3. 现代 C 才是策略模式的主战场3.1 std::function 让策略从类变成函数经典策略模式用抽象基类实现这是 1994 年 GoF 成书时的主流做法。但 C 发展到现在策略未必需要类这么重的载体。如果策略只有一个方法、不需要内部状态用std::function更轻巧。#include functional #include stdexcept class Compressor { public: using CompressFunc std::functionvoid(const std::string, const std::string); explicit Compressor(CompressFunc func) : func_(std::move(func)) {} void setFunc(CompressFunc func) { func_ std::move(func); } void doCompress(const std::string src, const std::string dst) { if (!func_) { throw std::runtime_error(strategy is null); } func_(src, dst); } private: CompressFunc func_; }; // 使用直接传 lambda Compressor compressor([](const std::string src, const std::string dst) { std::cout [ZIP] compress src - dst std::endl; }); compressor.doCompress(a.txt, a.zip);std::function的好处是显而易见的策略的定义从写一个类降级成写一个函数代码量骤减。配合 lambda 捕获列表还能很自然地携带少量状态比如压缩级别、加密口令。如果你的策略只是一个算法没有复杂的生命周期和状态管理需求优先考虑std::function。但这个方案也有边界std::function本质上是一个类型擦除的包装器有轻微的性能开销间接调用同时它丢失了类型信息调试时看到的是一堆std::_Function_handler内部细节可读性比具名类差。3.2 智能指针接管策略生命周期无论用虚函数还是std::function策略对象的生命周期管理都是绕不开的话题。C 老代码里常见的写法是Context持有裸指针外部传入策略对象然后祈祷调用方活得比上下文久。这种方式在小型 demo 里没问题一旦策略对象在别的线程里被释放崩溃就是一瞬间的事。现代 C 的推荐做法很明确上下文用std::unique_ptr独占策略所有权或者用std::shared_ptr表达共享所有权。具体选哪个看策略对象是否可能被多个上下文共享每个上下文独享一个策略实例用unique_ptr开销最小语义清晰。多个上下文共享同一个只读策略实例用shared_ptr避免重复创建。策略对象由外部管理、上下文只是借用用裸指针或std::reference_wrapper但前提是你能保证生命周期。我见过不少人在策略模式里用shared_ptr形成循环引用策略回调上下文、上下文持有策略结果内存泄漏。遇到这种需求先想想是不是设计出了环而不是急着换智能指针类型。3.3 编译期策略模板与 CRTP 的取舍除了运行期多态C 还提供了一条独特的编译期策略路线用模板参数指定策略类型。这种技术通常叫基于策略的设计Policy-Based Design在标准库的std::allocator、std::char_traits里都有体现。template typename CompressionPolicy class Compressor { public: void doCompress(const std::string src, const std::string dst) { policy_.compress(src, dst); } private: CompressionPolicy policy_; }; // 具体策略直接作为模板参数 class ZipPolicy { public: void compress(const std::string src, const std::string dst) { std::cout [ZIP] compress src - dst std::endl; } }; // 使用编译期固定策略 CompressorZipPolicy compressor; compressor.doCompress(a.txt, a.zip);编译期策略的优点是零运行时开销没有虚函数表没有间接调用编译器甚至可以内联整个策略逻辑。缺点是失去了运行期切换的能力——模板实例化后类型就固定了想在运行期基于用户输入换策略是不可能的。所以它的适用场景是算法在编译期就能确定、且对性能极度敏感的模块比如图像处理内核、序列化协议选择。三种实现方式的取舍我一般用这张表来对照实现方式策略扩展方式运行期切换性能代码量抽象基类 虚函数新增子类支持有虚函数开销中std::function新增 lambda/函数支持有类型擦除开销少模板策略新增模板参数不支持零开销中4. 一次真实重构电商优惠计价模块4.1 原始需求满减、折扣、优惠券三种策略并存前面讲的都是理论骨架现在用一个我实际处理过的场景把整个流程串起来。某电商系统的订单计价模块最开始只有一种打折逻辑后来产品经理连续加了需求满 300 减 50、全场八折、优惠券抵扣。每个需求都往同一个calculatePrice函数里添一段 if-else。到第三次加需求的时候这个函数已经长这样了double calculatePrice(double orderAmount, const std::string promoType, double threshold, double reduction, double rate, double coupon) { double finalAmount orderAmount; if (promoType full_reduction) { if (orderAmount threshold) { finalAmount orderAmount - reduction; } } else if (promoType percent_off) { finalAmount orderAmount * rate; } else if (promoType coupon) { finalAmount orderAmount - coupon; } // 每加一种营销玩法这里就多一个分支 return finalAmount; }看着参数列表我就头大满减的阈值、折扣的比率、优惠券的金额全被塞进一个函数签名里。调用方得记得哪个参数在哪个位置业务上不相关的概念强行耦合。而且测试用例越写越丑每种促销组合都要构造一整套参数。4.2 第一版重构策略接口加上下文重构的第一步定义策略接口。这里我故意把参数收敛成一个Order对象避免每个策略的方法签名不一致。策略接口只关心输入订单金额输出最终金额class IDiscountStrategy { public: virtual ~IDiscountStrategy() default; virtual double calculate(double orderAmount) const 0; }; class FullReductionStrategy : public IDiscountStrategy { public: FullReductionStrategy(double threshold, double reduction) : threshold_(threshold), reduction_(reduction) {} double calculate(double orderAmount) const override { return orderAmount threshold_ ? orderAmount - reduction_ : orderAmount; } private: double threshold_; double reduction_; }; class PercentOffStrategy : public IDiscountStrategy { public: explicit PercentOffStrategy(double rate) : rate_(rate) {} double calculate(double orderAmount) const override { return orderAmount * rate_; } private: double rate_; }; class CouponStrategy : public IDiscountStrategy { public: explicit CouponStrategy(double couponAmount) : couponAmount_(couponAmount) {} double calculate(double orderAmount) const override { return orderAmount couponAmount_ ? orderAmount - couponAmount_ : orderAmount; } private: double couponAmount_; };每个策略类只保存自己的参数构造函数里传入calculate里使用。这时候再看代码每个类的职责清晰到不需要注释满减策略就是超过阈值减金额折扣策略就是乘以比率。上下文类负责持有当前生效的策略class PriceCalculator { public: void setDiscountStrategy(std::unique_ptrIDiscountStrategy strategy) { strategy_ std::move(strategy); } double calculate(double orderAmount) { if (!strategy_) { return orderAmount; } return strategy_-calculate(orderAmount); } private: std::unique_ptrIDiscountStrategy strategy_; };这一版重构之后calculatePrice函数消失了取而代之的是三个小类加一个上下文类。每次新增促销类型只需要新增一个IDiscountStrategy子类然后交给PriceCalculator一行旧代码都不用改。4.3 策略的注册与装配简单工厂配合策略接口和上下文解决了算法怎么封装的问题但还留着一个待办策略对象到底由谁创建如果每次在业务代码里手动 new 具体策略调用方依然要知道所有策略类的存在耦合只是换了个位置。这时候需要一个简单工厂来承担根据类型创建策略的职责class DiscountStrategyFactory { public: enum class PromoType { FullReduction, PercentOff, Coupon }; static std::unique_ptrIDiscountStrategy create(PromoType type, const PromoConfig cfg) { switch (type) { case PromoType::FullReduction: return std::make_uniqueFullReductionStrategy( cfg.threshold, cfg.reduction); case PromoType::PercentOff: return std::make_uniquePercentOffStrategy(cfg.rate); case PromoType::Coupon: return std::make_uniqueCouponStrategy(cfg.couponAmount); default: throw std::invalid_argument(unknown promo type); } } };这里的平衡点在于工厂负责制造策略上下文负责使用策略业务主流程只面对接口。调用方从知道所有策略类的细节降级为知道促销类型枚举和配置结构体耦合度大幅下降。4.4 配置驱动策略选择与业务解耦如果再往前走一步可以用配置驱动策略选择促销类型和参数从配置文件或数据库读取代码里只有一套通用的装配逻辑。struct PromoConfig { std::string type; // full_reduction / percent_off / coupon double threshold 0.0; double reduction 0.0; double rate 1.0; double couponAmount 0.0; }; class DiscountStrategyFactory { public: static std::unique_ptrIDiscountStrategy create(const PromoConfig cfg) { if (cfg.type full_reduction) { return std::make_uniqueFullReductionStrategy(cfg.threshold, cfg.reduction); } if (cfg.type percent_off) { return std::make_uniquePercentOffStrategy(cfg.rate); } if (cfg.type coupon) { return std::make_uniqueCouponStrategy(cfg.couponAmount); } throw std::invalid_argument(unknown promo type: cfg.type); } };业务代码变成PromoConfig cfg loadPromoConfigFromDB(order.promoId); auto strategy DiscountStrategyFactory::create(cfg); PriceCalculator calculator; calculator.setDiscountStrategy(std::move(strategy)); double finalAmount calculator.calculate(order.amount);这套结构的好处是产品经理再提第五种玩法比如第二件半价开发只需要写一个新的策略类工厂里加一个 type 判断旧代码一律不动。如果工厂的 type 判断也嫌多还可以用std::unordered_mapstd::string, std::functionstd::unique_ptrIDiscountStrategy(const PromoConfig)做注册表让工厂本身也变成数据驱动——不过对于绝大多数业务系统简单的 if 链已经足够不必过度设计。5. 实操中容易踩的坑与应对5.1 策略对象的生命周期谁创建、谁销毁、谁复用策略模式里最常见的崩溃就是策略对象的生命周期没理清。比如上下文持有裸指针外部把策略对象提前释放了调用时直接踩野指针又比如上下文持有一个局部创建的unique_ptr却因为某种原因在策略方法里把所有权转移走了导致悬空。我的经验是在项目里约定好策略对象的三条铁律。策略创建和销毁都归同一个模块管优先用unique_ptr在上下文内部管理。如果策略对象需要复用比如多线程共享同一个只读策略改用shared_ptr并且策略内部不要写可变状态。任何情况下都不要用裸new创建策略然后到处传裸指针除非你做好了完备的 owner 文档。另外要注意策略对象里的成员变量如果是可变的比如缓存、计数器就会引入线程安全问题。多个线程同时调用同一个策略对象的calculate方法时必须保证方法是线程安全的否则就要给策略加锁或者干脆设计成无状态策略。无状态策略是策略模式的最佳形态它只依赖入参不保存调用过程中的任何中间数据天然线程安全也方便复用。5.2 策略内部状态与线程安全有状态策略的最典型场景是策略里需要缓存计算结果、或者记录调用次数做统计。我在一个导出服务里见过这样的代码某个策略类里存了一个累计导出条数的成员变量上线后线上多个线程同时调用计数器错乱不说还引发过几次偶发崩溃。如果必须维护状态有两个思路一是把状态放进线程局部存储thread_local每个线程一份二是把状态从策略对象里剥离出去由上下文或外部容器统一管理。写策略类的时候默认按无状态去设计这是最省心的做法。实在需要缓存的考虑在策略外层包一层带锁的装饰器而不是把锁直接写进策略里——保持策略本身纯逻辑测试和复用都更容易。5.3 策略和状态模式长得像但别混用策略模式和状态模式在 UML 类图上几乎一样都是上下文持有一个接口接口有多个实现很多初学者会混淆。关键区别在于行为的切换是由谁驱动、切换背后是什么含义策略模式切换策略是外部主动选择的结果各个策略之间是平级的、无关联的。上面那个计价例子选满减还是选折扣由用户或业务配置决定策略之间没有先后依赖。状态模式切换状态是对象内部状态迁移的必然结果状态之间有明确的转移关系。比如订单状态从待支付到已支付到已发货状态转移是业务规则规定的不能随便跳。如果你发现多个策略之间存在上一阶段是什么决定下一阶段是什么的约束关系那你需要的是状态模式强行套策略模式会让状态转移逻辑散落在业务代码里。反之如果只是根据某个条件选一个算法策略模式就够了。5.4 不是所有 if-else 都该被策略模式替代最后这个坑最隐蔽也最容易走极端。有些团队引入策略模式后看什么都像 if-else连判断今天是周几都要搞一个策略接口出来。策略模式适用的是**一组可互换的算法**这个场景但很多 if-else 分支的本质不是算法互换而是参数不同或者处理流程的某个步骤不同。判断标准很简单如果每个分支的输入输出形态完全一致、只是计算过程不同并且存在运行时替换的需求才考虑策略模式。如果分支之间连输入输出都不一样比如一个分支返回 int一个分支返回 string策略模式强行统一接口只会把代码改得更别扭。我见过最糟糕的例子是把策略模式用在了一个只有两种分支、而且确定不会扩展的功能上一个计算基础运费的函数用了策略接口加两个策略类加一个工厂结果代码量从原来的二十行变成一百行维护者要多打开六个文件才能看懂一次计算。设计模式是工具不是 KPI不要为了用而用。6. 最后分享一点个人体会做了这些年 C 开发我对设计模式的态度经历了一个循环刚入行时觉得模式是银弹什么代码都想往上套写多了反而觉得模式的本质不是用哪个而是识别出哪里的变化需要被隔离。策略模式真正解决的不是 if-else 本身而是让新增算法这个动作的成本降到最低——它把变化点提前暴露出来让后来的维护者不需要通读整个函数就能安全地扩展。就实际落地而言我个人的建议是先从std::function方案起步如果你的策略类开始出现多个方法、需要保存状态、或者被多处复用再升级成抽象基类方案如果还嫌运行期开销大再考虑模板策略。项目里遇到像计价模块那样不断膨胀的 if-else值得花半天时间重构一次之后的每一轮新需求都会感谢这次重构。顺带说一句给策略类命名时用名词 Strategy比如FullReductionStrategy比StrategyA、StrategyB这类名字有意义得多——代码是给人读的策略名本身就是文档。

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

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

免费获取报价 →
↑