ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++策略模式进阶:模板、std::function与组合实战

C++策略模式进阶:模板、std::function与组合实战 如果说哪个设计模式在 C 里被讲得最浅我会投策略模式一票。原因很简单网上的教程翻来覆去就是抽象基类、几个派生类、构造函数里传接口看完以为自己懂了但真到项目里写策略模式立刻会遇到一堆书上没写的问题——策略对象到底归谁拥有虚函数调用在性能敏感路径上能不能扛住策略多了以后怎么组合、怎么排查是谁改了结果这些问题才是“进阶”的真正门槛。这篇不是给新手补概念课而是把我在实际项目里用 C 写策略模式时踩过的坑、比较过的方案、沉淀下来的套路一次性讲清楚。你会看到同一个需求用虚函数、模板、std::function 三种方式实现的差别也会看到一个完整的多策略组合案例以及生命周期、线程安全这些容易翻车的地方。适合已经把基本策略模式跑通、想在生产环境里把它用好的人也适合准备 C 面试、想回答得有深度的同学。1. 告别教科书经典策略模式在 C 里为什么不够用先花两分钟把教科书版本的代码复述一遍因为后面所有讨论都建立在这个基础之上。教科书里通常是这样写的struct IStrategy { virtual ~IStrategy() default; virtual void execute() const 0; }; struct ConcreteA : IStrategy { void execute() const override { /* do A */ } }; struct ConcreteB : IStrategy { void execute() const override { /* do B */ } }; class Context { public: explicit Context(std::unique_ptrIStrategy strategy) : strategy_(std::move(strategy)) {} void run() const { strategy_-execute(); } private: std::unique_ptrIStrategy strategy_; };这套代码单看没有毛病接口清晰、实现解耦。但在 C 项目里真正复用它的经验是它只是入场券连门槛都算不上。1.1 教科书没讲的三个现实问题第一个问题是接口会越长越胖。项目初期你只需要一个execute()跑着跑着调用方开始问“能不能传参”“能不能拿到返回值”“能不能感知执行进度”于是IStrategy里一个一个加纯虚函数从 1 个变成 7 个、8 个每个实现类都被迫实现一堆和自己无关的接口。教科书不会告诉你接口是要按单一职责拆的也不会告诉你execute()的参数和返回值最好收拢成一个请求结构、一个结果结构而不是无限增加函数签名。第二个问题是所有权和生命周期没有答案。Context持有std::unique_ptrIStrategy只是其中一种选择。真实项目里策略对象可能还要被日志模块读取、被监控模块上报、被多个 Context 共享。一旦出现多个使用者unique_ptr就不够用于是你换成shared_ptr但又有循环引用、又有性能开销。这些问题不是策略模式本身能回答的却是用策略模式必然要面对的。第三个问题是虚函数调用不是免费午餐。现代 CPU 的分支预测和间接跳转预测已经很强但策略对象往往还牵涉到多态对象的内存布局、缓存友好性。在高频回调路径上比如网络包里每来一条消息都走一次策略分发virtual的代价就会被放大。你在 Java 里没得选只有运行时多态在 C 里还有编译期多态和函数对象两条路这也是 C 策略模式真正有意思的地方。1.2 为什么 C 的策略模式容易写出“Java 味”很多 C 开发者写策略模式的时候脑子里装的是 Java 的写法——定义一个接口、写一堆实现类、通过构造函数注入。这套模式在 C 里不是不能用而是把 C 的表达力浪费了一大半。C 的策略模式至少有三套独立的手段运行时多态虚函数 继承 智能指针教科书路线。编译期多态模板参数注入策略或者 CRTP把策略绑定在类型上零虚函数开销。类型擦除std::function、std::any这类手段把“策略到底是什么类型”藏起来只保留可调用性。真正进阶的第一步是把这三套手段放进同一个工具箱然后在具体场景里选型。我见过太多人一辈子只用第一套遇到性能问题就怪“策略模式慢”其实慢的不是模式是选型。2. 模板策略编译期注入把多态开销降为零如果你的策略集合在编译期就能确定而且运行过程中不需要替换那么第一选择不应该是虚函数而是模板策略。这种设计有一个更广为人知的名字基于策略的设计Policy-Based DesignC 标准库里的分配器就是它的经典实例。2.1 基础版模板策略先看一个最简单的模板策略 Contexttemplate typename Policy class Context { public: explicit Context(Policy policy) : policy_(std::move(policy)) {} void run() const { policy_.execute(); } private: Policy policy_; }; struct FastPolicy { void execute() const { // 快速路径 } }; struct SafePolicy { void execute() const { // 安全路径比如加锁、检查、重试 } }; ContextFastPolicy fastCtx{FastPolicy{}}; ContextSafePolicy safeCtx{SafePolicy{}};这段代码和虚函数版本最大的区别是什么没有虚函数表没有间接跳转。policy_.execute()在编译器眼里就是一次普通函数调用甚至会被内联成 inline 代码。如果你想做更极端的事情还可以让execute()是static的连对象都不用构造template typename Policy class Context { public: void run() const { Policy::execute(); } };模板策略最爽的地方在于它把“选择哪个策略”从运行期决策变成了编译期决策。这带来两个直接收益第一编译器可以做全量内联和常量传播性能上限远高于虚函数方案第二如果某个策略类型上压根没有execute()成员函数编译器会直接报错而不是等到运行期才炸出来。2.2 模板策略的代价模板策略不是银弹。我自己用下来的体感是它有四个明显的代价策略集合必须编译期确定做不到运行时动态替换。如果你的程序要从配置文件里读策略名运行时再决定用哪种策略模板这条路基本走不通。编译时间变长、二进制体积变大。每实例化一种策略组合编译器就会生成一份新代码策略组合一多代码膨胀是肉眼可见的。可读性下降。模板策略的 Context 头文件会变得非常长报错信息更是灾难尤其是模板嵌套深的时候编译器吐出来的几百行错误能把人看晕。接口约束需要靠 SFINAE 或者 C20 的 concept 来保证否则错误信息会藏在实例化深处。这也是为什么现代 C 更推荐写 concept。我给一个自己的选型经验策略组合少于 5 种、运行期不需要切换、且在热点路径上优先用模板策略。我做过一个序列化模块内部有 Binary、Json、Protobuf 三种序列化策略由于每个连接在建立时就已经确定了协议类型运行期根本不需要切换于是直接模板化最后序列化热路径的耗时比原来虚函数版本下降了接近 20%。这个提升主要来自内联和去虚表访问代码逻辑一点没变。2.3 CRTP 变体默认行为 步骤定制模板策略的进阶玩法是 CRTPCuriously Recurring Template Pattern。它和普通模板策略的区别在于CRTP 通常在基类里提供通用逻辑骨架把可变的部分交给派生类去定制。这其实是“模板方法模式的想法 策略模式的实现”。template typename Derived struct DamagePolicyBase { int compute(int base) const { int value static_castconst Derived*(this)-applyBase(base); value static_castconst Derived*(this)-applyBuff(value); return value; } int applyBase(int base) const { return base; } int applyBuff(int value) const { return value; } }; struct NormalPolicy : DamagePolicyBaseNormalPolicy { int applyBase(int base) const { return base; } }; struct CriticalPolicy : DamagePolicyBaseCriticalPolicy { int applyBase(int base) const { return base * 2; } };CRTP 的精髓是基类写死处理流程派生类只负责覆写自己关心的“策略点”没有被覆写的部分走基类默认实现。编译器在实例化时会把static_castconst Derived*(this)-applyBase(...)直接解析成派生类的版本依然没有虚函数开销。这种写法在处理“一组策略拥有相同骨架但部分步骤不同”的场景下非常实用比每个策略都从头写一个完整类要省很多代码。3. std::function 与运行时策略从接口到函数的进化C11 以后很多原本必须用抽象基类实现的策略其实可以改用std::function。这背后的变化不只是语法糖而是思维模型的转换策略从“一个类型”变成了“一个可调用对象”你不再需要为了一个函数去定义类和继承关系。3.1 为什么 std::function 让策略模式轻了一整个量级假设你现在要写一套伤害计算器需要支持自定义“额外加成”。如果用经典策略模式你先要定义IDamageModifier再写几个派生类最后塞进容器。如果这个加成就是一个 lambda 能表达的逻辑这套流程显得非常笨重。std::function版本只需要class DamageCalculator { public: using DamageFunc std::functionint(int); void setBase(DamageFunc func) { base_ std::move(func); } void addModifier(DamageFunc func) { modifiers_.push_back(std::move(func)); } int apply(int input) const { int result base_ ? base_(input) : input; for (const auto modifier : modifiers_) { result modifier(result); } return result; } private: DamageFunc base_; std::vectorDamageFunc modifiers_; };调用方可以这样用DamageCalculator calc; calc.setBase([](int v) { return v; }); calc.addModifier([](int v) { return v * 2; }); // 暴击 calc.addModifier([](int v) { return v 50; }); // 固定加成 int damage calc.apply(100); // 结果是 250这套写法的好处非常明显策略定义成本接近于零业务代码里一个 lambda 就是一个策略策略数量可增长std::vectorDamageFunc天然支持组合调用方甚至可以不通过类继承体系直接就地定义策略。3.2 多种策略的组合与优先级编排结合“java策略模式多种组合”这个高频搜索方向我想单独展开一下“组合策略怎么设计才不乱”。很多项目做到后面策略不是一个而是一堆。比如伤害计算里有基础职业策略、装备加成策略、Buff 策略、暴击策略它们之间的执行顺序会影响最终结果。最直接的做法是给每个策略配一个优先级struct Modifier { int priority 0; std::string name; std::functionvoid(Stat) apply; }; class ModifierChain { public: void add(Modifier modifier) { modifiers_.push_back(std::move(modifier)); std::sort(modifiers_.begin(), modifiers_.end(), [](const Modifier a, const Modifier b) { return a.priority b.priority; }); } void applyAll(Stat stat) const { for (const auto modifier : modifiers_) { modifier.apply(stat); } } private: std::vectorModifier modifiers_; };我给这个方案补一个非常实用的经验不要省略name字段。策略一多线上排查“为什么伤害数值不对”的时候你需要一个手段把策略链的执行过程打印出来。给每个 Modifier 加上名字再开一个 trace 开关出问题时把整条链子打出来一眼就能看到是谁在什么优先级位置改了数值。没有名字的策略组合器排查成本会成倍上升。优先级冲突则是另一个坑。两个插件都设了 priority 100排序后的顺序就是不稳定的。实际项目里我习惯把优先级范围划分成几个大区间基础计算用 100 以下全局加成用 100~500最终修正用 500 以上每个区间内尽量避免重复数值。如果实在避免不了就再加一个seq序号作为稳定性兜底。3.3 std::function 的隐藏成本std::function用起来爽但它不是免费的。它有类型擦除的开销虽然大多数实现有小对象优化存一个普通函数指针或者小的 lambda 不会触发堆分配但调用过程通常是间接调用内联基本无望。在性能敏感路径上大量std::function调用会让 profiler 报告一串你根本认不出来的std::_Function_handler符号这个我深有体会。调试体验也是它的短板。虚函数版本你还能在断点里看到类型名std::function的内部结构在调试器里几乎是黑盒你只知道它“能调用”看不到它到底封装了什么。所以在关键路径上我会偏向模板策略或虚函数只有在策略数量少、逻辑简单、方便现场写 lambda 的场景才用std::function。4. 策略组合实战一场伤害计算的重构历程前面把三种实现方式拆开讲了一遍现在把它们串起来看一个完整案例。我拿游戏服务器里最常见的“伤害计算”来演示因为它的演化路径非常典型——从分支爆炸到经典策略再到组合策略每一步都是被真实需求逼出来的。4.1 起点一个能跑但很糟糕的 switch很多系统的伤害计算最初长这样int calcDamage(const Character c, int base) { int damage base; switch (c.role()) { case RoleType::Warrior: damage base * 2; break; case RoleType::Mage: damage static_castint(base * 0.8); break; case RoleType::Rogue: damage base c.dexterity() * 3; break; default: damage base; break; } if (c.hasBuff(BuffType::Vulnerable)) { damage * 2; } if (c.hasBuff(BuffType::Strengthen)) { damage 50; } return damage; }这段代码的问题不是缩进而是扩展性。每加一个新职业就要往 switch 里塞一个 case每加一个新 Buff就要往函数底部塞一个 if。等职业和 Buff 各到十几个的时候这个函数就变成几百行的大泥球测试用例也根本没法写——你测calcDamage就得测排列组合全量。4.2 第一步重构职业策略用经典虚函数抽取第一轮重构先处理最恶心的职业分支。定义一个IDamagePolicy接口每种职业一个实现class IDamagePolicy { public: virtual ~IDamagePolicy() default; virtual int compute(int base, const Character c) const 0; }; class WarriorPolicy final : public IDamagePolicy { public: int compute(int base, const Character) const override { return base * 2; } }; class MagePolicy final : public IDamagePolicy { public: int compute(int base, const Character) const override { return static_castint(base * 0.8); } }; class RoguePolicy final : public IDamagePolicy { public: int compute(int base, const Character c) const override { return base c.dexterity() * 3; } };调用方只需要根据角色类型查表拿到IDamagePolicy实例然后调用compute。到这里职业扩展不再需要改原函数新增一个类就行。这个阶段我用了final关键字因为它没有继续派生的必要可以让编译器在更多场景下做去虚化优化。4.3 第二步重构Buff 模块抽成策略链职业策略抽取完之后发现 Buff 处理才是真正的增长点。Buff 的私人定制程度很高有的加固定值有的乘系数有的改变计算顺序。这时候再把它们全塞进一个函数就不现实了。我做了第二个抽象把所有 Buff 效果建模成策略链。class DamageContext { public: int apply(int base) const { int value base; for (const auto policy : policies_) { value policy-apply(value); } return value; } void addPolicy(std::shared_ptrIDamagePolicy policy) { policies_.push_back(std::move(policy)); } private: std::vectorstd::shared_ptrIDamagePolicy policies_; };这里有个设计取舍要解释一下为什么apply返回的是新值而不是直接改一个对象因为伤害计算是一条数据流水线每个策略的输入是上一个策略的输出这种链式风格配合不可变数据逻辑最容易追踪。如果策略之间共享一个可变对象那就要定义严格的字段读写顺序复杂度完全不同。4.4 第三步用 std::function 把策略定义成本打下来经典策略做完了我又发现一个新痛点很多一次性的小策略比如“某活动期间伤害额外加 5%”如果也要建一个类项目里会出现大量只有几行的类文件。后来我把DamageContext::addPolicy改成接受std::functionint(int)让活动规则直接以 lambda 形式注入class DamageContext { public: using ModifierFunc std::functionint(int); void addModifier(ModifierFunc func) { modifiers_.push_back(std::move(func)); } int apply(int base) const { int value base; for (const auto mod : modifiers_) { value mod(value); } return value; } private: std::vectorModifierFunc modifiers_; }; // 活动期间直接注入 lambda ctx.addModifier([](int v) { return v static_castint(v * 0.05); });这一次重构之后系统里“正式策略”继续用经典抽象“一次性策略”用 lambda两者的边界非常清晰。这个边界是这类系统的生命线没有边界抽象会腐烂成满城尽带 lambda边界太死临时需求又不得不改核心代码。5. 生命周期、所有权与线程安全容易被忽略的三座大山如果说前面的内容是教你怎么把策略模式写“活”这一章就是教你怎么把它写“稳”。我在代码评审里看到过太多次策略模式翻车翻车原因大多不在设计本身而在生命周期和并发。5.1 策略对象到底归谁管先看最基础的所有权选择。教科书版本的unique_ptr是最清晰、最推荐的方式Context 独占策略对象析构时自动释放生命周期一目了然。但它有一个限制——策略不能同时被多个 Context 使用。我在做多语言支持模块时就遇到过同一个翻译策略需要同时被 5 个会话共享unique_ptr直接不够用只能换shared_ptr。shared_ptr的问题在于你不知道策略什么时候真的被析构。调试多线程程序时偶尔会遇到“策略还在被调用但内部资源已经释放”的诡异问题查到最后往往是因为控制块和资源生命周期不一致。我的建议是能用unique_ptr就不要用shared_ptr如果必须共享策略对象优先保证策略内部不可变。如果你实在想用裸指针或者引用请先确保严格按“非拥有”语义来理解它。Context 只借用策略不负责释放策略的生命周期由外部保证长于 Context。这种方案在嵌入式或者生命周期完全可控的场景也成立但在面向对象的大项目里是危险信号——因为它把维护责任落在程序员自觉上而自觉在凌晨两点加班时是靠不住的。5.2 多线程环境下策略的共享与隔离策略模式本身不涉及线程但策略对象一旦在多线程环境里共享问题就来了。关键问题是策略内部有没有可变状态如果策略是无状态的只有一个const成员函数那它在多线程下天然安全随便共享。如果策略内部维护了可变状态比如累加器、计数器、最近一次计算结果那共享同一个策略实例等于共享了一段可变内存必须加锁或者改成原子变量否则就是数据竞争。一个更稳妥的思路是把有状态策略的持有权按线程隔离。遍历一个线程池给每个线程发一份策略副本这就是“线程本地存储”配合策略模式的组合。实现上可以用thread_local来保存每个线程专属的策略实例class ThreadLocalDamageContext { public: void init(std::functionstd::shared_ptrDamageContext() factory) { thread_local std::shared_ptrDamageContext context_ factory(); context_ factory(); } std::shared_ptrDamageContext get() { thread_local std::shared_ptrDamageContext context; return context; } };这段代码里thread_local保证每个线程看到的是自己那份策略链不会互相污染代价是每个线程构造策略的开销按次数计如果策略构造很重、线程又很多就要评估这个方案是否值得。5.3 悬垂引用lambda 捕获 this 是最大隐患std::function策略里有一个高频坑捕获了this指针结果对象先析构了。我在一个定时任务系统里就遇到过——策略捕获了某个配置对象的this对象生命周期结束之后定时任务触发策略等于在野指针上调用成员函数程序崩溃得毫无征兆。解决方案有两条路。一条是保证捕获的对象的生命周期至少不短于策略本身这个靠所有权设计去兜底。另一条是用weak_ptr替代this执行时先 lockclass MyService : public std::enable_shared_from_thisMyService { public: void start() { timer_.addTask([weakSelf weak_from_this()]() { if (auto self weakSelf.lock()) { self-doWork(); } }); } private: void doWork() {} };weak_from_this()是 C17 提供的能力比传统shared_from_this()安全因为它不会在对象已经析构时抛异常只是返回一个空的weak_ptr。这个模式我在回调类策略里用了很多次每次都能把“崩溃”降级成“静默跳过”虽然没有根治问题但至少不会把进程搞挂。6. 策略模式的边界它和状态模式、模板方法、命令模式的真正区别很多人学了一堆设计模式之后发现它们怎么长得都差不多尤其策略模式、状态模式、模板方法、命令模式代码写出来经常很像。这一章我把它们放在一起对比说清楚它们到底改变了什么。6.1 一张表看懂四个模式的差异模式核心意图状态是否切换典型 C 实现例子策略模式算法族可替换客户端决定用哪个算法外部主动换策略虚函数 / 模板参数 / std::function排序算法、伤害计算、压缩算法状态模式对象内部状态改变时行为同步改变内部自动切换状态状态基类 状态机切换TCP 协议状态机、订单状态流转模板方法父类定义算法骨架子类重写部分步骤无CRTP / 继承 protected 方法游戏 AI 框架、加载流程命令模式请求封装成对象支持排队、撤销、重做无重点是动作可记录命令接口 命令队列编辑器 undo/redo、任务队列这张表的核心区别就一行字策略模式改变的是“算法”状态模式改变的是“对象状态”模板方法改变的是“算法的某一步骤”命令模式改变的是“动作的触发与记录方式”。6.2 用代码简单感受区别状态模式的关键是状态对象内部会引发状态迁移比如订单状态机里PayState处理完支付后可能把自己切换成ShippedState这个切换逻辑在状态类内部发生调用方只是触发事件并不知道状态会怎么流转。策略模式则完全不同客户端的角色是主动的——我就指定用这个算法你执行完就完了不会产生去切换另一个策略的副作用。模板方法最经典的体现其实是 C 里的 CRTP 基类前面 2.3 小节接触过。它和策略模式的区别在于策略模式是“把算法整体注入”模板方法是“算法骨架固定只把可变步骤暴露给子类”。用一句话记忆策略是换引擎模板方法是调参数。命令模式你几乎可以把它理解成“可以排队、可以反悔的策略”它和策略模式的代码结构很像但语义完全不同。策略答案的是“怎么做”命令问的是“事情记下来没有、能不能撤销”。6.3 什么时候不应该用策略模式写到这里我想泼一盆冷水——不是所有用 if/switch 的地方都应该改造成策略模式。策略模式的核心价值是可替换性和可组合性如果一个分支只可能出现两三种情况、且未来一年内没有扩展预期那直接写 if/switch 反而更简单。策略模式不是免费的它要付出类数量增加、调用间接化、代码分散化的成本。我给自己的纪律是三条策略变体少于 3 个不急着上策略模式先保持 if/switch。策略不存在运行期替换和组合需求优先考虑模板策略或普通函数而不是抽象基类。策略只在一个函数内部使用没有跨模块复用价值直接用 lambda 或者局部函数就行。身边有过不少反面案例团队里有人喜欢“预防性设计”明明只有 A 和 B 两种算法非要抽一个IXXStrategy接口再建两个实现类。半年后传来传去接口加了一堆虚函数实现类谁也没维护代码量翻倍灵活性反而变差了。设计模式是解决问题的不是用来炫技的。最后给一个我个人的选型顺序确定性高、数量少、性能敏感的策略用模板数量中等、运行时可能替换的策略用虚函数策略定义极其轻量、主要靠 lambda 现场拼装的用 std::function。这三者之间没有高下之分只有合不合适。你可以把这一章的边界判断和前面几章的选型经验合并起来当作自己项目里策略模式的使用 checklist实际动手写之前过一遍能省下后面好几天的排查时间。
返回列表