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

现代C++策略模式实战:从继承到std::function的轻量重构

现代C++策略模式实战:从继承到std::function的轻量重构 ★ FEATURED ARTICLE
接手过一个订单计费模块的重构里面跑着十几套优惠规则满减、会员折扣、季节活动、积分抵扣、组合套餐……前任用继承方式写了一套策略类每个规则一个派生类。表面看很“教程标准”真维护起来却处处别扭。加一个规则要新建文件、注册工厂、改配置跨模块改动频繁几个规则还要按顺序叠加类与类之间耦合越来越多。那次重构让我彻底意识到教科书里的策略模式只是一个起点真正决定代码质量的是你在现代C里怎么选实现路线、怎么管理策略生命周期、怎么编排多个策略。这篇文章就把我这两年整理下来的经验完整写出来适合写过基本策略模式、但想在工程里用得更顺手的C开发者。1. 教科书里的经典实现为什么放到真实工程里总是别扭1.1 标准写法其实只解决了一类问题先回顾一下大多数教程给的标准姿势定义一个抽象基类作为策略接口具体策略各自继承并覆写算法方法调用方持有一个基类指针运行期调用。class IPriceStrategy { public: virtual double calc(double base) const 0; virtual ~IPriceStrategy() default; }; class FullCutStrategy : public IPriceStrategy { public: FullCutStrategy(double threshold, double discount) : threshold_(threshold), discount_(discount) {} double calc(double base) const override { return base threshold_ ? base - discount_ : base; } private: double threshold_; double discount_; }; class VipDiscountStrategy : public IPriceStrategy { public: explicit VipDiscountStrategy(double rate) : rate_(rate) {} double calc(double base) const override { return base * rate_; } private: double rate_; };这种写法的价值在于调用方只依赖抽象接口不感知策略内部细节新策略可以独立扩展不需要改动已有代码。对教学和简单场景它完全没问题。1.2 三个“不对劲”的真实场景但工程场景会很快戳破这种理想模型。第一个场景策略数量很多但行为极简单。订单计费里可能有几十种“小规则”比如“满100减5”“满200减12”它们通常只是阈值和结果的差异算法骨架一模一样。为每个组合创建派生类类数量会膨胀到难以管理更别提还要为每个类想一个不重名的名字。第二个场景策略需要依赖运行期上下文的大量数据。折扣计算往往不只一个入参还要看用户等级、商品类目、地区、时间。教科书例子里接口只有一个double入参真实里你可能要传一个几十字段的上下文对象或者策略内部还要反向查询其他服务。这时候把策略写成一个纯虚函数类往往比直接传lambda麻烦得多。第三个场景策略不是“单次调用”的它可能被反复构造、拷贝、存储进容器甚至跨线程共享。虚函数类要做值拷贝时不得不用unique_ptr或shared_ptr包装内存碎片、所有权问题、线程安全问题会冒出来。只是想要“一个可调用对象”却被迫背上整个类体系。1.3 别扭的根源策略被绑进了类型体系这些不对劲让我想通了一件事经典策略模式把“算法族”这个概念强绑定到了“类继承树”上。可实际上大部分策略就是一段独立的逻辑它需要的只是一个统一的调用入口并不需要一个完整的类型层级。继承模型带来了三个隐性负担编译期依赖增加所有策略头文件必须可见、类型膨胀每个算法都要有个类名、装配成本高调用方要负责构造具体对象。而这些在大部分情况下都不是我们真正想要的东西。真正想要的只是能像传递参数一样把一段算法传来传去并保持统一调用方式。这就引出了下面要讲的几种更轻的实现路线。2. 拆掉继承思维换成更轻的三种现代写法2.1 用std::function承载策略轻量无继承C11之后std::function成了策略模式最自然的载体。它本质是一个可调用对象的类型擦除包装lambda、函数指针、函数对象、绑定表达式都可以塞进去。#include functional using PriceStrategy std::functiondouble(double); double applyStrategy(double base, const PriceStrategy strategy) { return strategy(base); } // 使用示例 PriceStrategy fullCut [](double base) { return base 300 ? base - 40 : base; }; PriceStrategy vipDiscount [](double base) { return base * 0.85; }; double finalPrice applyStrategy(1200, fullCut);这种写法把策略从“类”降级成了“值”。策略不再需要单独的头文件和派生类可以就地定义也可以从配置工厂里取出来。由于std::function是可拷贝的存进vector、map都非常顺手这让策略的“组合”和“管理”容易了很多。需要注意std::function不是完全零成本。它在内部可能发生堆分配拷贝时有开销调用时比直接函数调用多一层间接跳转。但在绝大部分业务场景里这些开销都小到可以忽略。真正需要注意的是别在热循环里反复拷贝它见第四章的实测数据。2.2 用模板参数做静态策略运行期零成本如果你的策略在编译期就能确定而且不需要运行期切换那模板策略是更优解。策略本身不再是一个运行时对象而是一个类型通过模板参数传入。struct FullCutPolicy { static double calc(double base) { return base 300 ? base - 40 : base; } }; struct VipDiscountPolicy { static double calc(double base) { return base * 0.85; } }; template typename Policy double applyStrategy(double base) { return Policy::calc(base); } double result applyStrategyFullCutPolicy(1200); // 编译期确定这段代码经过编译器优化后几乎等同于直接调用FullCutPolicy::calc没有虚函数开销、没有std::function的间接跳转甚至可以内联展开。它的缺点是类型在编译期就要确定无法在运行期根据配置切换。所以它适合那些“策略选择发生在编译期”的场景比如不同模块固定使用不同规则或者策略通过编译期参数配置。如果既想要模板的零成本又想要运行期切换可以结合上虚函数或std::function做“类型擦除边界”在边界处保留随机选择的灵活性进入内部后再按模板方式派发。2.3 三张路线的对比与选型建议这里把三种方案放在一起比较方便你根据场景做取舍。实现方式运行期切换单次调用开销编译期依赖代码复杂度适用场景虚函数类支持中等虚表跳转高需包含全部策略头文件中策略种类少且稳定、需要完整类型体系std::function支持较高类型擦除可能堆分配低只需包含函数类型定义低策略数量多、逻辑碎片化、需要容器管理模板策略不支持最低可内联中模板实例化中编译期确定策略、追求极致性能我自己的选型习惯是默认用std::function因为它最灵活和现代C的lambda生态无缝衔接只有当策略出现在高频调用路径、且性能实测确实成为瓶颈时才考虑改造为模板策略只有当策略体系非常稳定、并且需要承载复杂状态或私有辅助方法时才保留经典虚函数类写法。3. 策略模式在真实工程里的组合打法3.1 策略和上下文状态怎么共享才不混乱策略往往不是纯函数它需要读取上下文数据。一个常见的错误是把策略的输入参数越加越多最终接口变成“万能参数列表”。更好的做法是定义一个只读的上下文结构策略从上下文里取所需字段。struct DiscountContext { double orderAmount; int userLevel; std::string region; bool isFirstOrder; }; using DiscountStrategy std::functiondouble(const DiscountContext); DiscountStrategy firstOrderStrategy [](const DiscountContext ctx) { if (ctx.isFirstOrder) { return ctx.orderAmount * 0.9; } return ctx.orderAmount; };这里有个原则上下文对象应当是只读的。策略不会修改它所有中间结果通过返回值或独立的结果结构传递。否则多个策略叠加时你根本搞不清楚哪个策略修改了什么字段这是状态混乱的起点。如果策略内部需要更复杂的依赖比如访问数据库或远程配置我一般不把这种依赖直接塞进策略构造函数而是通过上下文携带一个“服务访问器”引用。这样策略测试时只需替换这个访问器不必构造整个服务栈。3.2 配置驱动的策略工厂让扩展点收敛策略多的项目需要有一个统一的地方根据名称或配置创建策略。std::function让这件事变得非常简洁。用注册表模式业务方注册策略工厂统一查询。class StrategyRegistry { public: void registerStrategy(std::string name, DiscountStrategy strategy) { strategies_[std::move(name)] std::move(strategy); } const DiscountStrategy* find(const std::string name) const { auto it strategies_.find(name); return it strategies_.end() ? nullptr : it-second; } private: std::mapstd::string, DiscountStrategy strategies_; };这样业务规则配置只需要一个字符串标识工厂内部映射到具体策略。新增策略不需要改调用方只需要在一个地方注册。我习惯把注册动作集中到一个初始化函数里用静态初始化或显式调用保证工厂在业务请求之前就绪。这种模式的额外好处是策略可以被配置序列化。优惠活动的规则可以从配置中心下发配置就是一个“策略编号参数”的JSON运行时工厂根据编号查策略再用参数构造具体策略实例。业务上新活动经常不需要发版改配置就能生效。3.3 叠加式策略链多个规则一起生效的编排真实工程里多个折扣规则往往需要叠加执行。把策略链串起来的常见做法是先定义一组策略按顺序执行后一个策略的输入是前一个的输出。using DiscountStrategy std::functiondouble(double); double applyStrategyChain(double originalPrice, const std::vectorDiscountStrategy strategies) { double price originalPrice; for (const auto s : strategies) { price s(price); } return price; } std::vectorDiscountStrategy chain { [](double p) { return p 300 ? p - 40 : p; }, // 满减 [](double p) { return p * 0.85; } // 会员折扣 }; double finalPrice applyStrategyChain(1000, chain);这种链式执行比“类继承递归组合”直观得多。策略之间没有强耦合每个策略只关心上一个输出的值以及自己需要的上下文。我在订单计费里就是用类似方式把满减、折扣、积分抵扣依次编排新增规则只需要往链里插一个元素。要注意策略链必须明确执行顺序和优先级。有些规则必须在前比如先满减再打折与先打折再满减结果不同所以要为链上的节点定义明确的排序规则不能依赖容器插入顺序的偶然性。4. 实测虚函数、std::function、模板策略的真实开销4.1 一次热路径微基准的结果总有人担心std::function的性能。我之前在项目里专门做过一次微基准单线程循环调用一千万次策略分别用虚函数、std::function、模板策略实现同样的“打八折”逻辑在普通x86机器上的相对结果大致如下。实现方式相对耗时说明直接函数调用基线1.0x编译器完全内联模板策略1.0x ~ 1.1x全内联几乎无开销虚函数调用约1.8x一次虚表跳转无法内联std::function约2.0x ~ 2.5x类型擦除间接调用且可能堆分配std::function比虚函数慢一些但绝对量级都在“纳秒”级别单次调用也就几纳秒的差异。对你一天执行几万次的业务逻辑来说这点差异根本不值得关注。真正会造成数量级差异的是你在热循环内部反复构造std::function对象、反复拷贝、引发大量堆分配。4.2 什么时候才值得为性能改变策略实现只有两种情况我建议认真考虑把std::function替换成模板策略或手写类型擦除。一种是策略调用处在每秒百万次的采集、编解码、网络包处理等真实热路径上。这时候连虚函数调用都可能成为瓶颈更别提std::function的额外间接层。另一种是策略对象被大量拷贝的场景比如把策略作为值存储在百万级容器中。std::function的拷贝可能涉及引用计数和堆内存分配内存放大效应非常明显。这种情况下可以改用shared_ptr 或换成虚函数类配合对象池。其余情况一律优先考虑代码可维护性。性能分析工具给出的数据比“感觉std::function慢”可靠得多。我见过不止一次团队为了性能把函数式策略改成手写虚函数继承后来发现瓶颈根本不在策略调用上反而白白损失了代码灵活度。5. 进阶阶段最容易翻车的几个坑5.1 策略爆炸新增一个能力要改所有策略策略模式的隐藏成本出现在接口新增方法时。假设你的策略接口从“计算价格”扩展到“计算价格校验是否可用”。虚函数类方案下所有现有派生类都要去实现新方法std::function方案下相对好一点但依然需要重新组装策略链。要避免这个问题设计策略接口时就要想清楚变与不变不变的稳定能力放接口里可能演化的部分用组合或扩展点隔离而不是直接加虚函数。我的经验是不做“大而全的策略接口”每个策略只负责一件极窄的事。如果发现需要新增能力优先通过新的独立策略链去实现而不是去改旧接口。坚持单一职责策略爆炸的概率会小很多。5.2 捕获this与状态泄漏生命周期比想象中脆弱用std::function承载策略时最大的坑来自lambda捕获。捕获外部对象的指针或引用等于把外部对象的生命周期和策略绑定在一起。class DiscountService { public: DiscountStrategy makeStrategy() { return [this](double p) { // 危险捕获this return p * defaultRate_; }; } private: double defaultRate_ 0.85; };这段代码在DiscountService析构后再调用策略就会触发未定义行为。解决方案有几种优先按值捕获必要数据必须捕获外部依赖时改用shared_ptr托管或用weak_ptr并在策略内部检测有效性策略长期存储的场景尽量让策略自包含不依赖外部对象生命周期。实测里这种问题很难靠测试发现因为它往往在特定时序下才触发等线上出问题再排查就非常痛苦。5.3 接口漂移把算法步骤暴露出去就难改了策略接口另一大坑是——设计接口时把算法的内部步骤暴露了出来。比如折扣策略接口不只有calcResult()还暴露了validate()、calculateBase()、calculateFinal()每步一个方法。表面看很灵活实际把所有策略内部结构都定死了。以后某类新策略根本无法适配这个接口因为它的执行步骤和旧模型完全不一样。接口设计应该是最小粒度的对外只暴露“执行算法”这一个动作其余细节全部封装在内。如果确实需要组合多个算法步骤那也是策略内部自己决定不要让调用方看见。5.4 滥用策略两个固定分支不需要策略最后一个坑是过度设计。有些场景明明只有两种固定策略而且几乎不会变也硬要套一个策略接口加两个实现类。这种抽象对可维护性没有收益反而增加了间接层次让代码路径难以跟踪。判断标准很简单如果你不确定未来会不会增加第三种策略就不要提前做策略模式等第三种策略真正出现时再重构成本通常比预先抽象低得多。我一般只在策略数量稳定地大于等于三、策略本身会频繁变化、或存在运行期动态选择需求时才引入策略模式。其余情况用普通函数或if-else代码反而更可靠。说回我自己那次订单计费模块重构之后我把规则全部改成了std::function策略链再加新活动时只需要在注册表里新增一个lambda配合配置中心的参数半小时就能验证上线不再需要新建类、改工厂、改上游调用。如果你现在正被一堆继承策略类拖得难受强烈建议挑一个非核心模块先试试函数式策略的改造体验一下“算法像值一样传来传去”的轻快感再决定要不要全面铺开。对了还有一个实用小技巧如果担心std::function的堆分配可以用自定义的小对象优化容器替代它把容量阈值设为sizeof(shared_ptr)左右大多数小lambda都可以零堆分配存储性能直逼模板策略。
阅读完成 · 觉得有帮助?
咨询建站