前两天在 Code Review 一个游戏项目的伤害计算模块二十多个 if-else 堆在一个函数里看得头疼。基础攻击加距离判定血量低加暴击暴击还要看装备装备又分物理魔法……新需求一进来函数越来越长review 的人越来越想骂娘。最后决定用策略模式重写。这篇就把我当时的设计思路、代码实现、以及后来踩过的坑完整记录一遍希望能给正在纠结“要不要上策略模式”、“到底用虚函数还是 std::function”的朋友一些参考。这篇内容围绕 C 策略模式实战展开从最古老的接口加继承写法到现代 C 惯用的 std::function 加 lambda再到追求极致性能的模板编译期多态三种方案都会给出可运行的完整代码和对比结论。适合对设计模式有一定了解、想在真实项目里动手重构的读者也适合刚学完《设计模式》正愁不知道怎么写进项目里的入门者。1. 需求场景拆解为什么伤害计算这种代码需要策略模式1.1 一个典型的 if-else 地狱先说清楚问题长什么样。假设要做一个角色攻击伤害计算初始需求很简单普通攻击伤害等于基础攻击力。int calculateDamage(int baseDamage) { return baseDamage; }业务加得很快。角色可以冲锋距离近有加成血少了会狂暴伤害提高有 20% 概率暴击……需求一多代码就变成这样int calculateDamage(int baseDamage, int distance, int healthPercent, bool isBerserk, bool isCritical) { int result baseDamage; if (distance 1) { result 20; } if (isBerserk || healthPercent 30) { result static_castint(result * 1.5); } if (isCritical) { result * 2; } return result; }这只是五个判断。真实项目里判断更快膨胀元素克制、技能倍率、目标防御、暴击伤害加成、buff 叠加……每个判断都往里塞参数参数列表越来越长调用方根本不知道要传什么。而且所有玩家的攻击逻辑都在同一个函数里不同职业的差异也被强行拧在一起。这种代码最大的问题不是性能而是“变化被埋没了”。每个 if 代表一种算法策略但算法之间没有清晰边界没有名字无法单独测试也无法动态替换。新来一个同事想加一种攻击方式得在十几个 if 里找地方插一脚改完自己都心虚。1.2 策略模式解决的问题边界策略模式的核心思想就是一句话把算法封装成独立的对象让它们可以互相替换。换成大白话把上面 if-else 里的每个分支都抽成一个“策略对象”角色只负责持有和调用当前策略。战斗逻辑不关心具体怎么算只关心“算出结果给我”。为什么要这么做因为它同时解决了三个现实问题开闭原则新增一种攻击方式不需要改 GameCharacter 的代码只需要新增一个策略类然后单独注册。改动集中在新增代码上而不是修改已有逻辑。可测试性策略是独立类直接构造出来传参数测试。不用先创建一个全角色再调一大串前置条件。动态切换能力很多业务场景策略不是固定的。狂暴后切换、装备切换、状态切换策略模式可以在运行期更换算法这比硬编码 if-else 灵活得多。不过也要说清楚策略模式不是万能药。判断逻辑只有两三个分支、并且基本不会新增的硬迁过去反而增加类的数量。它是“算法族”层面的封装工具不是“少写 if”的工具。实际判断是否使用我一般看两个信号这个函数日后大概率会持续新增算法实现或者调用方需要在运行期切换算法。都不满足就别折腾。2. 最经典的写法虚函数接口加 unique_ptr 管理2.1 基类接口的设计细节策略模式的教科书写法是定义一个抽象接口类各种策略继承它再让使用者持有基类指针。代码长这样#include memory class IAttackStrategy { public: virtual ~IAttackStrategy() default; virtual int calculateDamage(int baseDamage, int distance, int healthPercent) const 0; }; class NormalAttack : public IAttackStrategy { public: int calculateDamage(int baseDamage, int /*distance*/, int /*healthPercent*/) const override { return baseDamage; } }; class ChargeAttack : public IAttackStrategy { public: int calculateDamage(int baseDamage, int distance, int /*healthPercent*/) const override { if (distance 1) { return baseDamage 20; } return baseDamage; } }; class BerserkAttack : public IAttackStrategy { public: int calculateDamage(int baseDamage, int /*distance*/, int healthPercent) const override { if (healthPercent 30) { return static_castint(baseDamage * 1.5); } return baseDamage; } }; class GameCharacter { public: explicit GameCharacter(std::unique_ptrIAttackStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptrIAttackStrategy strategy) { strategy_ std::move(strategy); } int calculateDamage(int baseDamage, int distance, int healthPercent) const { return strategy_-calculateDamage(baseDamage, distance, healthPercent); } private: std::unique_ptrIAttackStrategy strategy_; };有两个细节值得留意。一是析构函数必须是虚函数。因为通过基类指针删除派生类对象时如果析构不是虚函数属于未定义行为实际表现往往是派生类资源没释放或直接段错误。C 里这个坑非常经典很多新手写策略模式时漏了 virtual 析构跑起来偶尔崩溃排查半天最后发现是这里的问题。我习惯的写法是virtual ~IAttackStrategy() default;自己实现析构函数一定要加分号后置的空函数体。二是成员函数加 const。策略对象不修改自身状态加 const 一方面表达了语义另一方面允许策略作为共享常量对象传进来避免误写内部状态。比如某些策略只需要读配置加 const 后整个函数体强制只读编译器帮你把关。这个习惯对后续维护很有好处。2.2 策略切换与生命周期问题有了 setStrategy 接口运行期切换策略就很简单了GameCharacter character(std::make_uniqueNormalAttack()); int damage1 character.calculateDamage(100, 3, 80); character.setStrategy(std::make_uniqueChargeAttack()); int damage2 character.calculateDamage(100, 0, 80);这里用std::unique_ptr而不是裸指针是为了明确所有权语义。策略对象的所有权归属 GameCharacter角色析构时自动释放不会泄漏。如果不希望策略被外界篡改unique_ptr 也避免了两处同时 delete 的问题。但有个地方很多人会忽略setStrategy 的参数不建议用裸指针。例如void setStrategy(IAttackStrategy* strategy) { strategy_ strategy; }调用方很容易传入栈上对象NormalAttack attack; character.setStrategy(attack); // 危险如果 character 内部又用 shared_ptr 保存栈对象析构后 shared_ptr 再去 release直接崩溃如果只存裸指针那调用方必须保证对象活得比 character 久这个约束极易被破坏。所以我一直强调策略模式里用智能指针管理策略对象就像出门带伞一样看着多余真下雨了才知道救急。2.3 什么时候还选这个老办法既然现代 C 有了 std::function 和 lambda虚函数版本是不是就过时了说句实话不是。我自己的经验是这几种情况下仍然优先用接口版本策略需要提供多个相关方法。比如一个策略既要算伤害又要算暴击文案还要算命中动画把它们放一个接口里内聚性更强。策略本身是有状态的对象。比如策略内部要积累调用次数、缓存计算结果那对象比无状态函数更合适。需要协变返回类型或者模板方法模式接口这种“骨架 钩子”的写法更自然。团队里有不少对 lambda 和 std::function 不熟悉的成员接口版本更直观可读性更高。除此之外接口版本还有个隐含代价每个策略一个类类数量多。一个游戏项目光攻击策略可能就七八个类文件创建、命名、维护成本都不小。这也就是为什么现代 C 社区更流行下面这种写法。3. 现代 C 的惯用法std::function 加 lambda3.1 从虚函数到 std::function 的转变std::function 是一种“类型擦除”包装只要可调用对象的签名匹配不管是普通函数、lambda、函数对象都能塞进去。它底层对调用方隐藏了具体类型使用体验就像“存了一个函数变量”。接口版本的问题在于每个策略都要定义类哪怕是几行逻辑。而 std::function 允许直接在创建角色时用 lambda 定义策略大部分简单策略根本不需要独立类。改造后的代码#include functional class GameCharacter { public: using AttackCallback std::functionint(int, int, int); explicit GameCharacter(AttackCallback strategy) : strategy_(std::move(strategy)) {} void setStrategy(AttackCallback strategy) { strategy_ std::move(strategy); } int calculateDamage(int baseDamage, int distance, int healthPercent) const { return strategy_(baseDamage, distance, healthPercent); } private: AttackCallback strategy_; };调用方也很清爽GameCharacter character( [](int baseDamage, int, int) { return baseDamage; } ); character.setStrategy( [](int baseDamage, int distance, int) { return distance 1 ? baseDamage 20 : baseDamage; } );我在实际项目里超过一半的策略场景都是这样实现。lambda 天然支持捕获比如在函数体内用到的配置值、状态值可以直接捕获进来不用像接口版本那样单独建成员变量传入策略对象。3.2 闭包捕获与性能陷阱lambda 捕获是 std::function 的优势但也是隐患来源。最常见的 bug 是按引用捕获局部变量int bonus 20; character.setStrategy( [](int baseDamage, int, int) { return baseDamage bonus; } ); // bonus 生命周期结束后策略仍然可调用但捕获引用已悬空这里的 bonus 如果属于某个局部作用域作用域结束后再调用策略捕获的引用就是悬空引用轻则读到垃圾数据重则崩溃。排查这种问题非常费劲因为崩溃现场和捕获处相隔很远。我的建议是lambda 作为策略捕获局部变量时优先按值捕获确实需要按引用捕获外部的长生命周期对象时一定要确保那个对象的生命周期覆盖整个策略使用周期。这是 std::function 版策略模式最值得警惕的坑之一。性能方面std::function 不是免费的。它相比裸函数调用多了一层间接调用部分实现还可能涉及小对象优化分配、分配失败时的堆分配。在热路径里这个差异能测出来。我试过在一个游戏伤害循环里每秒调用上万次策略对比虚函数和 std::functionstd::function 慢 10%~20% 左右。游戏场景需要压榨性能时这个差异不能无视。普通业务系统、消息队列处理、配置计算这类场景std::function 的开销完全可以忽略。3.3 std::function vs virtual 怎么选这里给出一张对照表是我实际选型时用的参考维度虚函数接口std::function lambda定义成本每个策略一个类文件多函数体即策略灵活快速闭包捕获需通过成员变量传入原生支持捕获语义直观多方法组合一个策略可提供多个接口一个 callable 只能表达一个调用状态保持策略对象成员变量天然支持需捕获共享状态绕一层对象语义强调类型与继承关系强调行为与调用约定性能开销间接调用 虚表查找间接调用可能堆分配代码可读性类名即文档结构清晰短策略直白长策略难读我自己的默认选择是简单单方法策略用 std::function策略有多个方法、或者策略之间有明显层级关系时用虚函数接口追求极致性能的热路径用下一节说的模板方式。4. 编译期策略模板与静态多态4.1 模板策略的写法与特点上面两种方案都属于运行期多态程序运行时才决定调用哪个策略实现。而模板方案把策略的绑定提前到编译期业界常称为“静态多态”。写法非常直接template typename AttackStrategy class GameCharacter { public: explicit GameCharacter() default; int calculateDamage(int baseDamage, int distance, int healthPercent) const { return strategy_(baseDamage, distance, healthPercent); } private: AttackStrategy strategy_{}; }; struct NormalAttackStrategy { int operator()(int baseDamage, int /*distance*/, int /*healthPercent*/) const { return baseDamage; } }; struct ChargeAttackStrategy { int operator()(int baseDamage, int distance, int /*healthPercent*/) const { return distance 1 ? baseDamage 20 : baseDamage; } };使用方式GameCharacterNormalAttackStrategy normalChar; GameCharacterChargeAttackStrategy chargeChar;模板方案的最大优势就是没有任何运行期开销。没有虚表、没有间接调用编译器内联后生成的代码可以媲美手写 if-else。性能敏感场景下这是一个非常强的理由。代价也明显策略在编译期就固定了运行期无法切换。上面代码里的 GameCharacter 实例化后策略类型是模板参数的一部分类型不同就是两个完全不同的类型。你没法让 normalChar 在运行期变成 ChargeAttackStrategy。4.2 三种风格的性能边界与适用场景接口版本、std::function 版本、模板版本本质上是一条“性能-灵活性”光谱上的三个点。我实测下来三者的关系大概是虚函数版本调用多一层虚表查找C 里通常不可内联。现代 CPU 分支预测对稳定虚调用有加成实际开销较小。std::function 版本同样不可内联但可能多一次内存分配取决于实现和对象大小。模板版本编译期内联跑起来等价于普通函数调用性能最好。选择原则不复杂运行期不需要切换策略优先模板。比如某个模块只服务一种算法算法变更是代码变更不需要用户交互切换运行期需要切换策略但切换频率不高优先 std::function策略本身结构复杂、需要状态和多方法协作优先虚函数接口。实际项目里这三种方式可以共存。我就见过一个游戏引擎里渲染管线用的模板策略性能敏感编译期固定技能特效用的 std::function不同技能绑定不同 callbackAI 行为树用的虚函数接口策略复杂、需要多方法协作。没有哪种是“最正确”的只有“最匹配当前场景”的。5. 实战完整案例与扩展思路5.1 完整可运行的案例代码说了这么多最终还是要落到一个能跑起来的例子上。下面这个案例模拟了一个角色攻击计算模块包含三种攻击策略、策略切换和简单的调用演示。代码基于 C17可以直接编译运行#include functional #include iostream #include memory // 数值辅助防止 double 转 int 时精度损失 inline int toInt(double value) { return static_castint(value); } // 攻击上下文传给策略的数据统一封装新增字段时策略签名不用改 struct AttackContext { int baseDamage; int distance; // 与目标的距离越小越有利 int healthPercent; // 自身血量百分比 bool isMagicResist; // 目标是否魔法抗性 }; // 策略类型接收上下文返回最终伤害 using AttackStrategy std::functionint(const AttackContext); class GameCharacter { public: explicit GameCharacter(AttackStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(AttackStrategy strategy) { strategy_ std::move(strategy); } int calculateDamage(const AttackContext ctx) const { return strategy_(ctx); } private: AttackStrategy strategy_; }; // 策略工厂根据角色类型和环境返回合适的攻击策略 AttackStrategy makeStrategyForRole(const std::string role) { if (role warrior) { return [](const AttackContext ctx) { int result ctx.baseDamage; // 战士距离小于等于 1 时冲锋加成 if (ctx.distance 1) { result 20; } return result; }; } if (role berserker) { return [](const AttackContext ctx) { int result ctx.baseDamage; // 狂战士血量低于 30% 狂暴伤害提升 50% if (ctx.healthPercent 30) { result toInt(result * 1.5); } return result; }; } if (role mage) { return [](const AttackContext ctx) { // 法师目标有魔法抗性时伤害减半 return ctx.isMagicResist ? toInt(ctx.baseDamage * 0.5) : ctx.baseDamage; }; } // 默认普通攻击 return [](const AttackContext ctx) { return ctx.baseDamage; }; } int main() { GameCharacter character(makeStrategyForRole(warrior)); AttackContext normalCtx{100, 3, 80, false}; std::cout warrior normal: character.calculateDamage(normalCtx) \n; AttackContext closeCtx{100, 0, 80, false}; std::cout warrior close: character.calculateDamage(closeCtx) \n; // 运行期切换策略角色觉醒为狂战士 character.setStrategy(makeStrategyForRole(berserker)); AttackContext lowHpCtx{100, 3, 20, false}; std::cout berserker low hp: character.calculateDamage(lowHpCtx) \n; }编译运行后输出warrior normal: 100 warrior close: 120 berserker low hp: 150注意几个设计细节AttackContext把多项输入封装成一个结构体策略签名不用每加一个参数就改一遍。真实项目中参数只会越来越多这个封装能省很多编译噪音。makeStrategyForRole是策略工厂负责根据条件生成策略。它跟策略模式经常搭配使用职责是“隐藏策略创建细节”上层只需要拿到策略不需要知道它具体是怎么构造的。策略的 context 传入而不是分别传多个 int还有个好处后续增加参数比如目标护甲值时策略 lambda 只访问自己需要的字段其他策略签名不用变。这让代码演进容易得多。5.2 测试、组合策略与后续扩展代码写完了怎么验证正确性策略模式的优点之一就是便于测试。每个策略都是独立可测单元void testBerserkStrategy() { auto berserk makeStrategyForRole(berserker); AttackContext lowHp{100, 3, 29, false}; int result berserk(lowHp); assert(result 150); AttackContext highHp{100, 3, 80, false}; result berserk(highHp); assert(result 100); } void testMageStrategyOnMagicResist() { auto mage makeStrategyForRole(mage); AttackContext resistCtx{100, 5, 80, true}; int result mage(resistCtx); assert(result 50); }这种测试不需要构造完整环境直接调 lambda 就行。另一个实用扩展是组合策略。比如策划希望“战士冲锋时有一定概率触发暴击”你不用改 GameCharacter也不用改 ChargeAttack而是写一个组合策略AttackStrategy chargeWithCritical(AttackStrategy base, double criticalChance) { return [base, criticalChance](const AttackContext ctx) { int result base(ctx); // 伪随机这里用 rand 仅做演示实际项目建议用 mt19937 if ((rand() % 100) criticalChance * 100) { result * 2; } return result; }; }组合策略的价值在于策略模式不只是“多选一”也可以“一层包一层”。每个策略只负责一件事通过组合构建复杂行为比在单个策略里堆条件判断清晰得多。实际项目里我见过把组合策略做成装饰链的比如“攻击策略 - 暴击装饰 - 元素加成装饰 - 破甲装饰”每个装饰只负责一个维度的修改加新效果时只需要新增装饰不动其他代码。这种写法很值得试试。再往后扩展还可以把策略跟配置系统打通根据玩家职业、装备、技能等级动态从配置表生成策略。这部分逻辑放工厂里GameCharacter 完全不感知扩展点集中在工厂。我当时重构完这个模块新加一种攻击方式80% 的改动都集中在新增策略文件和工厂分支GameCharacter 和调用方一行没改。6. 常见问题与排查技巧实录6.1 典型问题速查表把我在项目里遇到、以及带新人时常看到的问题整理成表格排查速度能快不少现象可能原因排查方法解决方案程序启动后偶发崩溃策略对象生命周期不对悬空引用或悬空指针检查策略的捕获列表和存储方式开启 ASANlambda 优先按值捕获策略用 unique_ptr 管理策略全部表现一致好像没生效策略对象被拷贝内部状态未按预期隔离打印策略对象地址对比切换前后使用 unique_ptr禁用拷贝性能比预期慢尤其热路径std::function 导致额外分配和间接调用用 profiler 采样统计调用开销热路径改用模板策略或虚函数虚函数版本调用时段错误基类析构不是虚函数删除派生对象出错检查析构函数声明virtual ~IAttackStrategy() default;构建时报“未定义引用”虚函数未实现或纯虚函数未全部 override编译日志逐行查确保所有纯虚函数都有实现setStrategy 后立刻崩溃传入栈对象栈对象析构后悬空检查 set 调用处对象类型统一使用 make_unique 构造6.2 几个相对冷门的陷阱表格里是通用问题下面是几个我真正花过时间精力才明白的经验分享出来希望别人少走弯路。第一不要用策略模式去硬套内部不含变化的小逻辑。我见过一个项目连“两个 int 相加”这种固定逻辑都抽成策略类。结果类数量爆炸新增一个策略要建文件、注册、写测试光 thumb 就翻不完。策略模式解决的是“算法族”的变化问题不是“任何函数都要可插拔”。我一般给自己定的标准是这个策略至少有两种以上现实会切换的实现或者有明确的新实现会持续加入才值得抽出来。第二以共享状态为核心的策略一定要搞清楚“谁拥有这个状态”。策略对象内部有成员变量时两个 GameCharacter 共享同一个策略对象一个改了状态另一个也会被影响。如果策略状态是每个角色独立的务必每个角色持有一个独立策略实例如果是全局配置类状态才考虑共享。这跟智能指针引用计数的思维一脉相承所有权模型不搞清楚早晚出现灵异数据。第三策略模式有一个很容易跟状态模式混淆的边界。策略是“怎么做”状态是“现在是什么”。同样是切换策略切的是算法实现状态切的是对象内部状态导致的整体行为变化。如果代码的核心是“根据状态决定策略”那状态模式可能比纯策略模式更自然。我在游戏项目中见过把角色状态正常、眩晕、死亡全用策略来实现结果策略之间还互相依赖非常痛苦。判断依据简单策略之间不会因为“对象处于不同阶段”而互相转换状态模式则天然描述状态迁移。第四调试时一定要能看清“当前用的是哪个策略”。我踩过最深的坑是游戏运行期间策略被某个地方偷换上线后用户反馈数据不对排查好久发现是某个回调里悄悄改了角色策略。后来养成习惯策略切换点必须打日志记录 from 和 to 的名字。调试策略问题先看切换日志很多问题能缩小到“是谁在什么时机换掉策略”这一个问题。别嫌日志啰嗦真实排障时它是第一救星。写在最后的一点心得写到这里三种 C 策略模式实战方式都过了一遍。我个人在实际项目里的体会是策略模式是最容易“用对”的设计模式之一因为它的意图非常直白把变化的部分与稳定的部分分离。但也是最容易被滥用的一种因为一旦把不该变的逻辑抽走类数量膨胀带来的维护成本远超收益。我现在的默认选型流程很简单先问自己“这段逻辑未来会不会持续出现新实现”不会就别抽会再问“运行期需要动态切换吗”需要就用 std::function 加 lambda不需要就上模板。只有策略本身还需要多方法协作、带状态、或团队更习惯面向对象写法时才回头用虚函数接口。这套判断下来每次都能少纠结很久。最后分享一个我这几年养成的实用习惯不管用哪种实现都顺手在策略对象里加一个名字标识不管是接口版本的virtual const char* name() const还是 std::function 版本在工厂里附加一段解释文本。调试策略问题的时候这个标识能让你在五分钟之内定位到“当前角色跑的是哪套规则”而不是在代码堆里翻半天。这些看似不起眼的小设计往往比模式本身更值钱。