ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++策略模式实战:从虚函数到std::function与模板的全方位解析

C++策略模式实战:从虚函数到std::function与模板的全方位解析 早年做后端服务的时候最怕听到的一句话就是“需求又变了”。刚把订单的运费计算逻辑从一堆if-else里抠出来产品第二天又递过来一张表新渠道的计价规则又变了。那段日子天天在改case分支代码越改越肿改到最后甚至不敢动。后来我接触了策略模式Strategy Pattern才开始理解“把变化的部分封起来让它能在运行时被替换”这件事有多重要。这篇文章就围绕C里的策略模式展开讲原理、讲实现、讲踩坑尽量把能直接落地的方案都写清楚适合正在做中大型项目、或者想改善现有代码结构的C工程师参考。1. 策略模式到底在解决什么问题1.1 一个让我印象深刻的业务场景先还原一下当时的场景。系统里有个运费计算模块最初只有两种渠道普通快递和顺丰。代码写得很直接一个函数里塞了两个if分支根据传入的渠道类型分别计算。后来加了一个同城配送我又往函数里塞了一个else if。再过两周跨境电商的业务也接进来了这次不光要算运费还牵扯到关税预估我干脆在函数体里写了一个switch每个case里几百行。这个函数最后膨胀到了上千行里面夹杂着各种临时变量、嵌套判断、甚至还有对全局配置的直接读取。每次改一个渠道的计算规则就得把这个大函数从头到尾看一遍生怕动到别的渠道的逻辑。最痛苦的还不是代码长而是新来一个同事想加一个“次日达”渠道他根本不知道该在哪个位置插入自己的代码也不知道已有分支之间会不会相互影响。1.2 策略模式的核心思想把“做法”变成可替换的对象策略模式的做法就是把每个渠道的计算逻辑各自封装成独立的类这些类实现同一个接口然后在运行时选择具体使用哪个“策略”来执行。这样运费计算函数本身就不需要关心分支了它只面向接口编程具体怎么算由外部传入的策略对象决定。从设计模式的角度说这是一种典型的“组合优于继承”的思想。它不是靠继承来扩展功能而是通过持有接口、委托给实现来达到同样的效果。对于C工程师来说这尤其重要——因为C里有虚函数、模板、函数对象等多种手段来实现这种“策略”比很多语言的单一方案要灵活得多但也正因如此容易出现“不知道怎么选”的困惑。2. 策略模式的经典结构与C侧的特殊考量2.1 三大角色策略接口、具体策略、上下文策略模式的经典结构很简单就三个角色策略接口定义一组算法/行为的统一抽象通常是一个含纯虚函数的基类。具体策略实现策略接口的具体类每个类代表一种算法/规则。上下文Context持有策略对象的引用/指针在需要时调用策略的方法并允许在运行时切换。放在运费计算的例子里运费计算器就是上下文运费策略接口定义了calculate(OrderInfo)这个方法普通快递、同城配送、跨境电商各自实现自己的计算类。调用方只需把自己的订单数据交给上下文上下文把计算委托给当前持有的策略对象整个过程对调用方是透明的。2.2 C实现策略模式与Java/C#的差异在Java里策略模式基本就是接口实现类上下文这套写法搬过来几乎不用改。但C有几个不同的地方值得专门说内存生命周期问题C没有自动垃圾回收。策略对象是裸指针、unique_ptr还是shared_ptr直接决定了谁负责释放、什么时候释放。最常见的坑就是新手用裸指针上下文析构时没有delete策略对象造成内存泄漏。虚函数的开销每次通过基类指针调用虚函数都会有一次间接跳转。虽然单次开销不大但如果策略方法被高频调用或者调用方对性能极其敏感这就需要考虑其他实现方式。编译期多态C的模板机制允许在编译期就确定策略类型完全没有虚函数开销但代价是无法在运行时轻松切换策略。这不是“哪个更好”的问题而是要看你到底需要在什么时候做出决策。结合这些差异我在下面的实战部分会分别给出虚函数、std::function以及模板三种方式的完整实现并说明各自的适用场景。很多讲设计模式的文章只说第一种但对C工程师来说后面两种才是真正能体现“现代C”风格的地方。3. 实战一个完整的运费策略示例3.1 需求定义与代码结构设计为了把问题讲透我设计一个尽量贴近真实业务的例子。假设我们维护一个电商系统的运费模块目前的业务要求是支持三种运费计算方式策略名称计算规则普通快递基础运费10元 重量(kg)×1.5元满99元免运费同城速递固定运费15元超过10kg每公斤加收2元跨境电商基础运费20元 重量×3元 预估关税货值×8%需求里还明确说后续会接入新的渠道且每种渠道的规则都可能调整。这就非常适合用策略模式。整个代码结构我规划成如下形式// 策略接口 class FreightStrategy { public: virtual ~FreightStrategy() default; virtual double calculate(const OrderInfo order) const 0; }; // 具体策略类 class NormalFreight : public FreightStrategy { ... }; class SameCityFreight : public FreightStrategy { ... }; class CrossBorderFreight : public FreightStrategy { ... }; // 上下文 class FreightCalculator { public: void setStrategy(std::unique_ptrFreightStrategy strategy); double calculate(const OrderInfo order) const; private: std::unique_ptrFreightStrategy strategy_; };设计这个结构时我刻意做了两个决定OrderInfo作为参数传递引用避免拷贝同时策略接口不感知具体的业务容器后续扩展订单字段不会波及策略基类。上下文持有unique_ptrFreightStrategy明确所有权。这样上下文析构时自动释放策略对象避免内存泄漏。3.2 经典虚函数实现最正统的多态写法先看看纯虚函数的实现细节这部分C11之前的代码也适用兼容性最好。#include memory #include vector struct OrderInfo { double weight; // kg double amount; // 订单金额 double goodsValue; // 货值用于关税预估 }; // 策略接口 class FreightStrategy { public: virtual ~FreightStrategy() default; virtual double calculate(const OrderInfo order) const 0; }; // 具体策略普通快递 class NormalFreight : public FreightStrategy { public: double calculate(const OrderInfo order) const override { if (order.amount 99.0) { return 0.0; // 满99免运费 } double fee 10.0 order.weight * 1.5; return fee; } }; // 具体策略同城速递 class SameCityFreight : public FreightStrategy { public: double calculate(const OrderInfo order) const override { double fee 15.0; if (order.weight 10.0) { fee (order.weight - 10.0) * 2.0; } return fee; } }; // 具体策略跨境电商 class CrossBorderFreight : public FreightStrategy { public: double calculate(const OrderInfo order) const override { double fee 20.0 order.weight * 3.0; fee order.goodsValue * 0.08; // 关税预估 return fee; } }; // 上下文 class FreightCalculator { public: void setStrategy(std::unique_ptrFreightStrategy strategy) { strategy_ std::move(strategy); } double calculate(const OrderInfo order) const { if (!strategy_) { throw std::runtime_error(strategy not set); } return strategy_-calculate(order); } private: std::unique_ptrFreightStrategy strategy_; };调用侧代码其实非常清爽int main() { FreightCalculator calculator; OrderInfo order{5.0, 150.0, 300.0}; // 先用普通快递策略计算 calculator.setStrategy(std::make_uniqueNormalFreight()); double fee1 calculator.calculate(order); // 运行时切换为跨境电商策略 calculator.setStrategy(std::make_uniqueCrossBorderFreight()); double fee2 calculator.calculate(order); return 0; }这种实现的好处是策略类之间完全隔离改同城配送的规则完全不影响另外两个类新增渠道只需要再派生一个类然后make_unique丢给上下文老代码一行都不用动。这个过程中我没有在上下文里写任何渠道判断这是策略模式最大的价值所在——它把“变化”从“稳定”中剥离出来了。3.3 现代C的std::function实现连类都不用定义了如果每个策略只是一个计算公式没必要兴师动众地写三个派生类。C11引入std::function之后策略对象可以直接是一个可调用对象lambda表达式就能充当策略。#include functional class FreightCalculator { public: using FreightStrategy std::functiondouble(const OrderInfo); void setStrategy(FreightStrategy strategy) { strategy_ std::move(strategy); } double calculate(const OrderInfo order) const { if (!strategy_) { throw std::runtime_error(strategy not set); } return strategy_(order); } private: FreightStrategy strategy_; };调用侧更加轻巧int main() { FreightCalculator calculator; OrderInfo order{5.0, 150.0, 300.0}; calculator.setStrategy([](const OrderInfo o) { if (o.amount 99.0) return 0.0; return 10.0 o.weight * 1.5; }); calculator.setStrategy([](const OrderInfo o) { double fee 15.0; if (o.weight 10.0) fee (o.weight - 10.0) * 2.0; return fee; }); return 0; }std::function本质是一个带类型擦除的函数包装器它内部可以存储lambda、函数指针、甚至绑定表达式。它把策略模式从“类的层级关系”直接抽象成了“可调用对象的集合”好处是代码极度紧凑读起来一目了然新增策略不需要新建文件或类就在使用侧写一个lambda就完事了。这个方案也不是没有代价。std::function的调用通常比直接虚函数调用慢一些因为它内部多了一层包装和可能的动态内存分配。不过对绝大多数业务代码来说这点开销基本可以忽略。我自己写过很多回调密集的网络模块std::function完全扛得住。但如果你的策略方法在一个每秒执行几百万次的循环里被调用那就得看看下面的模板方案了。3.4 模板策略把决策交给编译期有时候策略不需要在运行时切换而是编译期就确定了。比如程序按不同平台编译或者根据编译宏选择不同的加密算法。这种场景下最合适的是模板策略也叫策略模式的编译期版本。template typename Strategy class FreightCalculator { public: double calculate(const OrderInfo order) const { return strategy_.calculate(order); } private: Strategy strategy_; }; struct NormalFreight { double calculate(const OrderInfo order) const { if (order.amount 99.0) return 0.0; return 10.0 order.weight * 1.5; } }; struct CrossBorderFreight { double calculate(const OrderInfo order) const { return 20.0 order.weight * 3.0 order.goodsValue * 0.08; } };使用方式变成了这样FreightCalculatorNormalFreight calculator1; double fee1 calculator1.calculate(order); FreightCalculatorCrossBorderFreight calculator2; double fee2 calculator2.calculate(order);模板方案的性能极致在哪因为Strategy类型在编译期就是确定的strategy_.calculate(order)可以直接被内联展开完全不需要虚函数表甚至连内存分配都不需要。我在一个图像处理算法里用过这种思路把滤波算法作为模板参数传入最终生成的机器码比我预期还要干净性能几乎和手动写死逻辑持平。代价也很明显FreightCalculatorNormalFreight和FreightCalculatorCrossBorderFreight虽然长得像但它们是两个完全不同的类型这导致你在运行时无法通过一个条件判断自由切换策略。想切换策略本质上得在编译期选择。所以选择哪种方案核心看一个问题你的策略是运行时变化还是编译期就能确定。3.5 三种实现方式的对比与选型建议我把三种方案放在一起做了一次对比实现方式类型依赖运行时切换虚函数开销代码简洁度适用场景虚函数强继承关系支持有单次较小中业务规则复杂且经常新增std::function弱类型擦除支持无但包装开销高策略为简单表达式追求代码精简模板编译期强绑定不支持无可内联中高频调用、策略固定我用一个自己的经验做选型建议如果是业务系统里的策略比如运费、折扣、优惠计算优先选虚函数方案因为业务策略会越来越多类与类之间能形成清晰的映射关系便于后期维护。如果是工具代码里的策略比如排序比较器、临时规则筛选优先选std::function代码量最少改动成本最低。如果是在热路径上且策略在编译期就确定比如协议编解码、加密算法选择直接上模板。4. 策略模式与相关设计模式的分界线4.1 策略模式与状态模式的区别我见过不少人把策略模式和状态模式混为一谈其实两者在核心意图上完全不同。策略模式解决的是“在同一个上下文中用可替换的算法完成同一个任务”状态模式解决的是“对象在不同状态下行为自动切换且状态可以流转”。举个区别的例子一个订单对象处于待支付状态、已支付状态、已发货状态每个状态下调用confirm()的行为不同这是状态模式。而一个运费计算器无论用哪种渠道计算它算出来的都是运费不会改变自身状态这是策略模式。从代码结构上也有区别策略模式里策略对象通常认为外部传入的数据而状态模式里状态对象常常需要引用上下文对象以便切换状态。如果你在实现策略模式时发现策略类需要频繁修改上下文的状态那大概率是用错模式了应该考虑状态模式。4.2 策略模式与简单工厂/工厂方法的搭配实际项目中策略模式和简单工厂经常搭配使用。策略接口用来定义算法简单工厂用来决定创建哪个具体策略。比如在运费模块里前端传过来一个渠道代码工厂类根据渠道代码创建对应的策略对象再注入上下文。class FreightStrategyFactory { public: static std::unique_ptrFreightStrategy create(const std::string channelCode) { if (channelCode normal) return std::make_uniqueNormalFreight(); if (channelCode sameCity) return std::make_uniqueSameCityFreight(); if (channelCode crossBorder) return std::make_uniqueCrossBorderFreight(); throw std::invalid_argument(unknown channel); } };这里要特别注意一点工厂里仍然有if-else或switch分支只是这个分支被限制在唯一的地方不会扩散到业务代码里。有些人看到这里会质疑“策略模式是不是变相把if-else转移了”答案是肯定的但程度完全不同。原来几百行的分支逻辑散落在核心计算函数里现在只剩一个轻量工厂负责创建对象后续新增渠道只需要改工厂一处计算函数完全不动。这就像把杂乱的线缆集中到一个配线盒里虽然不是彻底消灭接线但至少让线束不再乱成一团。如果连工厂的if-else都想去掉可以引入注册表Registry用unordered_map把渠道码映射到创建函数这是另一个话题了。4.3 策略模式与状态模式之外的“兄弟模式”还有一个容易被忽略的对比是策略模式和桥接模式Bridge。两者的类图非常相似都是一端持有抽象接口另一端由具体子类负责实现差异化逻辑。但意图截然不同桥接模式的核心是为了分离“抽象部分”和“实现部分”让两者都能独立扩展强调的是“维度分离”策略模式核心是为了让算法能够被替换强调的是“行为替换”。如果你发现自己的策略接口不仅有算法还有了平台相关的东西比如在Windows上走这个分支、在Linux上走那个分支那可能就应该考虑用桥接而不是策略。5. 实践中最容易踩的坑和排查方法5.1 策略对象生命周期的管理陷阱先说一个我在代码评审里反复看到的问题策略接口的析构函数没有声明为虚函数。class FreightStrategy { public: // 这里必须有 virtual 析构 virtual ~FreightStrategy() default; virtual double calculate(const OrderInfo order) const 0; };如果基类析构函数不是虚函数通过基类指针delete派生类对象时派生类的析构函数不会被调用资源得不到正确释放。这在C里是未定义行为轻则内存泄漏重则程序崩溃。别觉得这是小事我接手过一个老模块内存只增不减最终定位到根因就是一个策略基类忘了写虚析构。另一个生命周期问题是策略对象被创建出来以后到底归谁管。我的经验是——谁持有谁负责释放。上下文持有unique_ptrFreightStrategy是最清晰的它不关心策略对象从哪来只知道析构时归自己管。如果因为历史原因必须用裸指针也务必在上下文析构函数中显式delete并且考虑拷贝构造被调用时策略指针被复制导致的“双重释放”风险。5.2 策略参数的拷贝开销策略接口的calculate方法接收参数时我看到的很多实现是calculate(const OrderInfo order)也就是整个订单对象按值传递。订单大一点里面有商品列表、用户信息、地址对象每次调用都要发生一次深拷贝性能损耗非常明显。正确做法是传递const OrderInfo策略方法只是读取数据不修改数据用const引用完全够用。有些同学会担心引用悬挂的问题我觉得这种担心在策略场景里基本多余——策略计算发生在一个同步调用链中上下文和策略都不会长期保存传入的引用不存在异步使用的问题。如果真要异步执行策略那才有必要考虑传值或者共享指针。5.3 过度设计策略类满天飞设计模式最大的风险是滥用。我见过一些项目明明折扣逻辑就一种只是数值不同结果有人给每个数值都写了一个策略类光策略类就有十几个文件。这种代码严重增加维护负担想改一个数值需要找到对应的类看完再改再编译效率极低。策略模式应该用在行为有结构性差异的场景。什么是结构性差异普通快递和跨境电商不是差一个数值是算法流程本身就不同一个不用考虑关税一个要考虑货值和关税比例。而折扣逻辑如果只是折扣率不同完全可以用一个策略类把折扣率作为构造函数参数传进去class DiscountFreight : public FreightStrategy { public: DiscountFreight(double discountRate) : discountRate_(discountRate) {} double calculate(const OrderInfo o) const override { return o.amount * discountRate_; } private: double discountRate_; };这样既保留了策略模式的扩展性又不会产生一堆“几乎一样但又不完全一样”的类。判断标准其实很直白如果你要在类名上加上数字或形容词来区分策略那大概率就是可以合并的。5.4 std::function方案里的捕获陷阱用lambda做策略时最常见的问题是生命周期。假如lambda捕获了外部变量的引用而这个外部变量在策略执行前就析构了那么调用策略时会拿到悬空引用表现为“偶尔崩溃、偶尔出错误结果”。// 错误示例 std::unique_ptrFreightStrategy strategy; { double discount 0.8; calculator.setStrategy([discount](const OrderInfo o) { return o.amount * discount; // discount已失效 }); } // 这里discount析构 double fee calculator.calculate(order); // 悬空引用我自己的经验是如果lambda需要捕获外部状态优先使用按值捕获[]或显式捕获变量这样lambda内部持有一份拷贝。如果必须按引用捕获要确保引用指向的对象生命周期覆盖策略对象的生命周期。这一点在回调风格、事件驱动的代码里尤其值得警惕。6. 策略模式在大型项目中的更高级用法6.1 策略注册表干掉工厂里的if-else前面提到工厂里还是有if-else如果想做得更优雅可以用注册表模式。每定义一个策略类就让它在静态初始化时向一个全局映射表注册自己。工厂函数只需查表即可。using StrategyCreator std::functionstd::unique_ptrFreightStrategy(); class FreightStrategyRegistry { public: static FreightStrategyRegistry instance() { static FreightStrategyRegistry registry; return registry; } void registerCreator(const std::string code, StrategyCreator creator) { creators_[code] std::move(creator); } std::unique_ptrFreightStrategy create(const std::string code) const { auto it creators_.find(code); if (it creators_.end()) return nullptr; return it-second(); } private: std::unordered_mapstd::string, StrategyCreator creators_; };策略类可以在静态初始化时注册自己struct NormalFreightRegister { NormalFreightRegister() { FreightStrategyRegistry::instance().registerCreator( normal, [] { return std::make_uniqueNormalFreight(); }); } }; static NormalFreightRegister g_normalFreightRegister;这种方式让新增策略类完全不需要动旧代码连工厂的if-else都省了。但注意静态初始化顺序问题跨翻译单元的静态初始化顺序不确定好在全局注册表依赖不复杂我项目里用下来没遇到问题。也可以在main里显式调用一个注册函数牺牲一点自动化换回确定性。6.2 策略模式与策略参数化配置的组合很多实际项目里策略不只是硬编码在类里还需要通过配置文件、数据库来动态配置。这时策略模式的策略对象可以结合配置项一起生成。比如跨境电商的关税比例8%可能会调整你不用改代码只需要在配置中心把参数改到10%工厂创建策略时读取配置对象传入策略类。class CrossBorderConfig { public: double tariffRate; double baseFee; double unitWeightFee; }; class CrossBorderFreight : public FreightStrategy { public: explicit CrossBorderFreight(CrossBorderConfig config) : config_(std::move(config)) {} double calculate(const OrderInfo order) const override { return config_.baseFee order.weight * config_.unitWeightFee order.goodsValue * config_.tariffRate; } };策略模式本身不直接解决配置问题但它天然适合跟配置结合策略对象创建时从配置中心拉取参数注入具体策略实例。后续业务规则变化大概率不需要改代码只是改配置。这个组合在我看来是把策略模式价值放大最明显的方式。7. 聊聊我这几年的真实体会策略模式是我在实际项目中用得最频繁的设计模式之一。早期我刚接触设计模式时总觉得策略模式不过是个“if-else的语法糖”好像换了个姿势写代码该写的逻辑一个没少。后来接手过几个维护成本很高的模块才意识到策略模式真正的价值不是减少代码量而是划清了一条边界计算逻辑的边界。每一个策略类都是独立的单元测试可以单独写Bug可以单独定位几个人并行开发也不会互相干扰。我还记得一次实际经历线上运费计算突然出现错误订单金额只有几十块却被扣了二十多的运费。排查时我只需要看跨境电商策略类的代码五分钟内定位到了关税参数取值错误而不用去翻那个上千行的旧函数。那一次我对“拆分”的价值有了真切的感受——好的代码结构不是为了好看是为了在一堆烂摊子里能快速找到问题。后来我也开始有意识地引入std::function和模板来实现策略尤其是在写工具库和框架代码时。现在回过头看C里做设计模式不要被传统的类和继承框住现代C提供的函数式能力让策略模式表达得更灵活、更经济。但也要记得设计模式是工具不是目的。什么时候用、怎么用最终还是看你要解决什么问题。如果你正准备在项目里引入策略模式我的建议很简单找到项目里那个最让你头疼的“大函数”看看里面是不是藏着几个可以通过统一接口抽象出来的算法分支。如果是就从那一个函数开始重构用策略模式把它拆开。别一次性大面积改造先尝到甜头再决定要不要推广。设计模式的引入始终应该以解决实际问题为导向而不是为了在代码里“展示”模式的优雅。
返回列表