前两天在 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_cast<int>(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_cast<int>(baseDamage * 1.5); } return baseDamage; } }; class GameCharacter { public: explicit GameCharacter(std::unique_ptr<IAttackStrategy> strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptr<IAttackStrategy> strategy) { strategy_ = std::move(strategy); } int calculateDamage(int baseDamage, int distance, int healthPercent) const { return strategy_->calculateDamage(baseDamage, distance, healthPercent); } private: std::unique_ptr<IAttackStrategy> strategy_; };有两个细节值得留意。一是析构函数必须是虚函数。因为通过基类指针删除派生类对象时,如果析构不是虚函数,属于未定义行为,实际表现往往是派生类资源没释放,或直接段错误。C++ 里这个坑非常经典,很多新手写策略模式时漏了 virtual 析构,跑起来偶尔崩溃,排查半天,最后发现是这里的问题。我习惯的写法是virtual ~IAttackStrategy() = default;,自己实现析构函数一定要加分号后置的空函数体。
二是成员函数加 const。策略对象不修改自身状态,加 const 一方面表达了语义,另一方面允许策略作为共享常量对象传进来,避免误写内部状态。比如某些策略只需要读配置,加 const 后整个函数体强制只读,编译器帮你把关。这个习惯对后续维护很有好处。
2.2 策略切换与生命周期问题
有了 setStrategy 接口,运行期切换策略就很简单了:
GameCharacter character(std::make_unique<NormalAttack>()); int damage1 = character.calculateDamage(100, 3, 80); character.setStrategy(std::make_unique<ChargeAttack>()); 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 加 lambda
3.1 从虚函数到 std::function 的转变
std::function 是一种“类型擦除”包装:只要可调用对象的签名匹配,不管是普通函数、lambda、函数对象,都能塞进去。它底层对调用方隐藏了具体类型,使用体验就像“存了一个函数变量”。
接口版本的问题在于:每个策略都要定义类,哪怕是几行逻辑。而 std::function 允许直接在创建角色时用 lambda 定义策略,大部分简单策略根本不需要独立类。改造后的代码:
#include <functional> class GameCharacter { public: using AttackCallback = std::function<int(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::function,std::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; } };使用方式:
GameCharacter<NormalAttackStrategy> normalChar; GameCharacter<ChargeAttackStrategy> chargeChar;模板方案的最大优势就是没有任何运行期开销。没有虚表、没有间接调用,编译器内联后生成的代码可以媲美手写 if-else。性能敏感场景下这是一个非常强的理由。
代价也明显:策略在编译期就固定了,运行期无法切换。上面代码里的 GameCharacter 实例化后,策略类型是模板参数的一部分,类型不同就是两个完全不同的类型。你没法让 normalChar 在运行期变成 ChargeAttackStrategy。
4.2 三种风格的性能边界与适用场景
接口版本、std::function 版本、模板版本,本质上是一条“性能-灵活性”光谱上的三个点。我实测下来三者的关系大概是:
- 虚函数版本:调用多一层虚表查找,C++ 里通常不可内联。现代 CPU 分支预测对稳定虚调用有加成,实际开销较小。
- std::function 版本:同样不可内联,但可能多一次内存分配(取决于实现和对象大小)。
- 模板版本:编译期内联,跑起来等价于普通函数调用,性能最好。
选择原则不复杂:运行期不需要切换策略,优先模板。比如某个模块只服务一种算法,算法变更是代码变更,不需要用户交互切换;运行期需要切换策略,但切换频率不高,优先 std::function;策略本身结构复杂、需要状态和多方法协作,优先虚函数接口。
实际项目里这三种方式可以共存。我就见过一个游戏引擎里,渲染管线用的模板策略(性能敏感,编译期固定),技能特效用的 std::function(不同技能绑定不同 callback),AI 行为树用的虚函数接口(策略复杂、需要多方法协作)。没有哪种是“最正确”的,只有“最匹配当前场景”的。
5. 实战:完整案例与扩展思路
5.1 完整可运行的案例代码
说了这么多,最终还是要落到一个能跑起来的例子上。下面这个案例模拟了一个角色攻击计算模块,包含三种攻击策略、策略切换和简单的调用演示。代码基于 C++17,可以直接编译运行:
#include <functional> #include <iostream> #include <memory> // 数值辅助:防止 double 转 int 时精度损失 inline int toInt(double value) { return static_cast<int>(value); } // 攻击上下文:传给策略的数据统一封装,新增字段时策略签名不用改 struct AttackContext { int baseDamage; int distance; // 与目标的距离,越小越有利 int healthPercent; // 自身血量百分比 bool isMagicResist; // 目标是否魔法抗性 }; // 策略类型:接收上下文,返回最终伤害 using AttackStrategy = std::function<int(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 典型问题速查表
把我在项目里遇到、以及带新人时常看到的问题整理成表格,排查速度能快不少:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 程序启动后偶发崩溃 | 策略对象生命周期不对,悬空引用或悬空指针 | 检查策略的捕获列表和存储方式,开启 ASAN | lambda 优先按值捕获;策略用 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 版本在工厂里附加一段解释文本。调试策略问题的时候,这个标识能让你在五分钟之内定位到“当前角色跑的是哪套规则”,而不是在代码堆里翻半天。这些看似不起眼的小设计,往往比模式本身更值钱。