策略模式在C++面试题里出现的频率,高到已经快和八股文画等号了。但我在代码评审里见过太多反面案例——有人为了用策略模式硬造出五六个类,有人把策略对象生命周期管得稀碎导致线上偶发崩溃,还有人一提到性能就认定虚函数是洪水猛兽。今天不聊定义和UML图,我用一个支付折扣的实战场景,从最朴素的if-else一步步重构到策略模式,再把虚函数、std::function、模板策略三种实现方式的取舍一次说透。这篇更适合真正要写代码、要过面试、要维护老项目的人。
1. 一个支付场景把if-else逼到墙角:从三段式代码看策略模式要解决的本质问题
1.1 你手上大概率有过这样的代码
假设你在做一个电商系统的计价模块,需求很简单:根据会员等级计算折扣。第一版往往长这样:
cpp复制double calcDiscount(double price, int memberLevel) {
if (memberLevel == 0) {
return price;
} else if (memberLevel == 1) {
return price * 0.95;
} else if (memberLevel == 2) {
return price * 0.90;
} else if (memberLevel == 3) {
return price * 0.85;
}
return price;
}
这段代码能跑,也够简单。但凡是维护过半年以上这种代码的人,心里都清楚它埋着多少雷。产品经理跟你说这周五要加一个"618大促黑金会员折上折"的规则,你怎么办?在函数末尾再塞一个else if?那"中秋节活动会员"、"双十一跨店满减"接踵而来的时候,这个函数会膨胀到上百行,每个分支里还掺杂着不同的日志逻辑、埋点逻辑、风控判断。更糟糕的是,你每次改动这个函数,都是在改动所有会员等级共享的代码路径——明明只想动黄金会员的规则,结果白银会员的测试用例也要全部回归一遍。
1.2 变化的方向不止一个:开闭原则不是口号
我们从这段代码里能提炼出两个角色:一个是"计算折扣"这个动作本身,另一个是"根据会员等级选择不同规则"的决策逻辑。if-else的问题在于,它把"动作的具体实现"和"动作的选择机制"死死耦合在同一个函数体里。一旦规则的数量和维度增加,代码的复杂度会呈指数级上升。
策略模式的核心思想,就是把这层耦合拆开:定义一族算法,把它们各自封装起来,并且让它们之间可以互相替换。用导航软件来类比就更直观了——从A到B,你脑子里清楚"最短路线""最快路线""避开高速"是三条不同的路线策略,你不会把三条路线的计算逻辑写在一个巨型函数里,而是让导航引擎持有一个"路线策略"对象,想换策略就换对象。这就是策略模式。
所以策略模式解决的本质问题,不是"消除if-else"——消灭分支不该成为目的,而是"让算法的提供者和算法的使用者解耦,让新增算法不修改既有代码"。理解到这一层,后面所有实现方式的选择才有依据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典虚函数策略实现:C++98时代的标准答案,至今仍是压舱石
2.1 三步重构:定义策略接口、实现具体策略、注入上下文
我们先按最正统的方式重构支付折扣场景。第一步定义抽象策略接口:
cpp复制class IDiscountStrategy {
public:
virtual ~IDiscountStrategy() = default;
virtual double calcDiscount(double price) const = 0;
};
第二步实现具体策略类。每个会员等级一个类,互不干扰:
cpp复制class NormalStrategy : public IDiscountStrategy {
public:
double calcDiscount(double price) const override { return price; }
};
class SilverStrategy : public IDiscountStrategy {
public:
double calcDiscount(double price) const override { return price * 0.95; }
};
class GoldStrategy : public IDiscountStrategy {
public:
double calcDiscount(double price) const override { return price * 0.90; }
};
class PlatinumStrategy : public IDiscountStrategy {
public:
double calcDiscount(double price) const override { return price * 0.85; }
};
第三步让上下文(Context)持有策略对象,客户代码只跟上下文打交道:
cpp复制class PriceCalculator {
public:
explicit PriceCalculator(std::unique_ptr<IDiscountStrategy> strategy)
: strategy_(std::move(strategy)) {}
void setStrategy(std::unique_ptr<IDiscountStrategy> strategy) {
strategy_ = std::move(strategy);
}
double calc(double price) const {
return strategy_->calcDiscount(price);
}
private:
std::unique_ptr<IDiscountStrategy> strategy_;
};
使用起来效果立竿见影:
cpp复制auto calc = PriceCalculator(std::make_unique<PlatinumStrategy>());
double finalPrice = calc.calc(1000.0); // 850
以后要加黑金会员,只需要新增一个类,改一行调用处的创建逻辑,就完成了扩展。这比新增else if安全得多——你完全不会碰到其他等级的代码。
2.2 为什么析构函数必须是虚的,以及unique_ptr的正确姿势
这段代码里最容易被忽略、又最容易在面试里被追问的,是那个 virtual ~IDiscountStrategy() = default;。做了十几年C++开发,我见过太多次因为基类析构函数不是虚函数导致的内存泄漏和未定义行为。当通过基类指针delete派生类对象时,如果析构函数不是虚函数,程序只会调用基类的析构函数,派生类中申请的资源——比如成员string、vector、unique_ptr——都不会被正确释放。
有的同学会问,这里的策略类全是简单类型没有资源,不写虚析构是不是也没事?在当前的类结构上确实不会崩,但这是极其危险的惯性。一旦将来某个策略类里加了文件句柄、网络连接、缓存池,问题就悄然出现了。所以我的建议很明确:所有作为基类的接口类,一律声明虚析构函数,没有例外。
另外注意到我在上下文里用的是 std::unique_ptr<IDiscountStrategy>,这是有讲究的。策略对象所有权明确从外部转移进上下文,上下文析构时策略自动析构,完全不涉及拷贝和共享,这正是独占所有权语义的最佳使用场景。千万不要用裸指针——裸指针意味着调用方和上下文要约定谁来delete,一旦两边的维护者没对齐这个约定,漏删是必然的。也尽量别用shared_ptr,除非策略对象确实需要多个持有者共享生命周期,而这种情况在我们这个场景里几乎不存在。用unique_ptr交出去所有权,语义清晰,也把悬空指针和泄漏的隐患从类型层面就消灭了。
2.3 构造函数注入与Setter注入:什么时候用setter
上面代码里我同时提供了构造函数注入和setter注入两种方式。构造函数注入的优点是策略一旦设置就不允许中途改变,对象的不变性更强,适合策略在对象生命周期内固定不变的场景。setter注入则允许运行时动态换策略,适合策略需要根据状态切换的场景。
比如你做一个订单状态机,订单刚创建时用"待支付计费策略",支付完成后换"已支付计费策略",那就该用setter。但很多代码把setter当成万能药,明明策略从头到尾不变,却开放了setter接口,结果就是给后续维护挖坑——别人可能在某个角落偷偷换了策略,排查问题时一脸懵。默认使用构造函数注入,只有确实需要运行时切换才暴露setter。 这个原则在代码评审里值得反复强调。
3. std::function方案:轻量策略的正确打开方式,但不是万能钥匙
3.1 虚函数策略的两个难言之隐
虚函数方案有个绕不开的问题:哪怕你的策略只有一个方法,也必须定义一个完整的类,接口、实现、源文件、头文件四件套一个不少。想象一下你有一整套促销规则,其中一半规则只是把价格乘个系数,为它们各建一个类未免太重了。
另一个问题是可组合性。虚函数策略类之间没法自然地组合。比如"双十一折扣"和"会员折扣"同时生效,你要么组合成新类,要么在上下文之外再套一层逻辑,都不是很优雅。
这些都是std::function大显身手的场景。C++11之后,std::function可以包装任何可调用对象——函数指针、lambda、函数对象。策略从"类"变成了"可调用对象",轻量了很多。
3.2 用std::function重构折扣策略
cpp复制#include <functional>
#include <memory>
using DiscountStrategy = std::function<double(double)>;
class PriceCalculator {
public:
explicit PriceCalculator(DiscountStrategy strategy)
: strategy_(std::move(strategy)) {}
void setStrategy(DiscountStrategy strategy) {
strategy_ = std::move(strategy);
}
double calc(double price) const {
return strategy_(price);
}
private:
DiscountStrategy strategy_;
};
使用时的灵活度瞬间上来了:
cpp复制PriceCalculator calc([](double price) {
return price * 0.95;
});
// 组合策略:会员9折后再叠加满1000减100的优惠券
PriceCalculator complexCalc([](double price) {
double memberPrice = price * 0.90;
return memberPrice > 1000 ? memberPrice - 100 : memberPrice;
});
lambda把策略定义和使用放在一起,代码读起来就像一段"规则描述"而不是"类定义",这在规则类策略多的场景里非常舒服。
3.3 什么时候该用std::function,什么时候坚决别用
我见过不少人把std::function当成strategic pattern的替代品,一上来就把虚函数方案全部推翻,这种情绪化选择是要吃亏的。这两种方案各有明确的适用边界。
std::function适合策略数量多但每个策略都很薄的场景。 比如你有几十条优惠规则,每条就一行算式,类定义带来的心智负担远大于std::function的灵活收益。虚函数方案适合策略逻辑复杂、策略内部有大量私有状态和辅助函数、需要真正建模"一类对象"的场景。 比如一个完整的排序引擎,策略涉及多阶段处理、共享缓存、状态累积,那就不适合塞进lambda里。
这两个方案可以共存。我在实际项目里见过很漂亮的设计:核心策略用虚函数定义接口,保证可扩展性和类型安全;外围的临时策略、组合策略、测试替身用std::function包装。两者之间通过适配层互转。接口定义面向逻辑,策略注入面向使用场景,互不冲突。
3.4 std::function的性能实话:捕获了多大,代价就有多大
面试和社区里对std::function的性能有很多夸张说法,我说点实在的。std::function的底层本质是类型擦除,内部通过虚表或小型缓冲区优化来调用目标可调用对象。未捕获任何状态的lambda通常走小对象优化,性能接近裸函数指针。捕获了大对象的lambda会导致动态内存分配,每次调用多一次间接跳转。
如果策略被用在热路径上——比如每帧执行上千次的碰撞检测策略、每次网络包都要调用的压缩策略——std::function的性能损耗可能从噪声变成可感知。这时候要么回到虚函数方案,要么用模板策略,也就是编译期多态。这个方向在下一个小节展开。
4. 模板策略模式:把多态挪到编译期,性能和灵活性的终极博弈
4.1 策略作为模板参数:零成本抽象的实现方式
如果你已经确定策略集合在编译期就完全可知,不需要运行时切换,那模板策略是性能最极致的选择。思路很直接:让策略成为模板参数,上下文在编译期就知道具体用的是哪个策略,直接调具体类型的成员函数,连虚函数的间接跳转都省了。
cpp复制template <typename Strategy>
class PriceCalculator {
public:
explicit PriceCalculator(Strategy strategy) : strategy_(std::move(strategy)) {}
double calc(double price) const {
return strategy_.calcDiscount(price);
}
private:
Strategy strategy_;
};
struct VIPDiscountStrategy {
double calcDiscount(double price) const { return price * 0.85; }
};
struct MemberDiscountStrategy {
double calcDiscount(double price) const { return price * 0.92; }
};
使用示例:
cpp复制PriceCalculator<MemberDiscountStrategy> calc1(MemberDiscountStrategy{});
double p1 = calc1.calc(1000.0); // 920
PriceCalculator<VIPDiscountStrategy> calc2(VIPDiscountStrategy{});
double p2 = calc2.calc(1000.0); // 850
在这个方案里,calc调用strategy_.calcDiscount的时候,编译器在模板实例化阶段就已经确定了具体函数地址,不会生成虚函数调用。配合内联,90%以上的场景可以做到全内联展开。这种"编译期多态"在C++社区有个专门名字叫CRTP(Curiously Recurring Template Pattern)的变体,不过我们这里用的是更简洁的模板参数方案。
4.2 模板策略的硬边界:编译期确定、调试期噩梦、二进制膨胀
你必须清醒地认识到,模板策略用起来爽,代价也都在暗处。
第一个边界是策略集合必须在编译期确定。你没法从配置文件读一个字符串然后切换策略,除非你把所有候选策略都实例化出来再用分发逻辑来选。这本质上已经回到了运行时多态的范畴。
第二个边界是编译期错误极不友好。如果你给PriceCalculator传入一个没有calcDiscount方法的类型,编译器的报错会展开好几层模板实例化上下文,第一次见到这种报错的新手基本是懵的。我自己的经验是:写模板策略时一定要用static_assert配SFINAE做类型约束,把错误信息卡在"这个类型没有calcDiscount"这个层面,而不是让编译器自由发挥。
第三个边界是二进制膨胀。每种策略类型都会实例化一份独立的PriceCalculator代码。如果你真有100种策略,那就有100份几乎相同的代码副本。在嵌入式、移动端这类对体积敏感的场景,要谨慎评估。
比较稳妥的工程判断是:策略数量少(个位数)、策略实现稳定、运行频率极高的场景,优先考虑模板策略。策略数量多或需要配置驱动的,老老实实用虚函数或std::function。多数业务项目到不了为这点性能牺牲工程可维护性的程度。
5. 生命周期与工厂注册表:策略模式从Demo到上线的最后一公里
5.1 策略对象的命名、状态与线程安全:三个隐蔽的坑
策略模式在教科书Demo里完美无瑕,一上工程就出幺蛾子,多半是栽在这三个坑里。
第一个坑是策略对象中藏了状态。策略模式要求策略对象最好不要持有调用相关的可变状态,因为同一个策略对象可能被多个上下文共享,或者在同一上下文中被多线程并发调用。如果你的DiscountStrategy里有一个totalDiscount成员变量用来累计折扣总额,那并发环境下就是数据竞争。策略应当是"无状态的服务对象",或者至少要内部自行加锁保证线程安全。
第二个坑是策略对象的生命周期管理混乱。我们前面用unique_ptr把所有权交给上下文,这是正确的默认。但有的代码喜欢让策略对象在外部创建,上下文中存裸指针,然后外部策略提前析构,上下文里就悬空了。这种问题排查起来极其痛苦,因为崩溃往往发生在策略解引用的时候,而那个地方看起来什么都没错。规则很清晰:要么上下文拥有策略,用unique_ptr;要么上下文只借用策略,而且借用方必须保证策略的生命周期严格长于上下文,这种情况下也应该明确注释甚至用引用而不是裸指针。
第三个坑是策略构造器开销被忽略。有些策略内部需要加载配置、建立连接、拉取模型,把它放在构造里意味着每次创建都要付出完整开销。这种策略应该单例化或者池化,而不是频繁构造销毁。
5.2 用工厂+注册表让策略动态可配
如果你的业务需要根据配置文件的字符串来选择策略,那么单一的策略类还不够,需要一个工厂来管理创建过程。最简单的实现是用注册表模式:
cpp复制class DiscountStrategyFactory {
public:
using Creator = std::function<std::unique_ptr<IDiscountStrategy>()>;
void registerStrategy(std::string_view name, Creator creator) {
creators_.emplace(name, std::move(creator));
}
std::unique_ptr<IDiscountStrategy> create(std::string_view name) const {
auto it = creators_.find(name);
if (it == creators_.end()) {
throw std::invalid_argument("unknown strategy: " + std::string(name));
}
return it->second();
}
private:
std::unordered_map<std::string, Creator> creators_;
};
注册表的妙处在于,它让策略的选择不再硬编码在业务代码里,而是变成一张可自由增删的映射表。新增策略时你只需要调用一次registerStrategy,不需要改动计价模块的代码。配合配置中心或者JSON配置,你可以实现"不改代码、不重启服务,只改配置就切换营销策略"的效果。
注册策略的时机可以是静态初始化阶段,也可以在配置文件加载后统一注册。我遇到过不少团队把注册逻辑散落在各个模块的静态初始化里,导致启动顺序依赖很难排查。我的建议是集中注册:一个模块负责把该模块内所有策略注册进全局工厂,模块之间不共享工厂实例,彻底规避静态初始化顺序地狱。
5.3 策略工厂和依赖注入容器:边界在哪里
很多现代C++项目引入了依赖注入容器(DI Container),这时候策略模式怎么融合?
我的观点是,策略工厂是DI容器在特定场景的轻量替代品。如果项目已经引入完整DI框架,那策略类当然可以作为普通组件注册进容器,按需解析。但这会带来一个问题:DI方案通常对组件生命周期和依赖关系的约束更严格,策略对象的拆装没有独立工厂那么直观。
对于策略模式这种"一组算法互相替代"的场景,我仍然倾向用独立的策略工厂,而不是DI容器全局接管。理由很简单:策略工厂的职责边界清晰——根据名称创建策略,不需要了解业务上下文;DI容器通常要管理整个对象图的依赖关系,复杂度和隐式约定都会增加。在小型项目和中间层组件里,一个不超过200行的注册表工厂是高度可控且易于测试的。
6. 实测对比:虚函数、std::function、模板策略的性能真相与选型建议
6.1 三种实现方式的开销究竟差多少:别被"虚函数很慢"忽悠了
关于虚函数性能的讨论,网上经常跑偏。我根据多年的实测经验给个相对靠谱的判断:在绝大多数业务系统里,虚函数调用(一次间接跳转加上可能的缓存未命中)相比普通函数调用的开销,在纳秒到几十纳秒这个量级。而你一次数据库查询动辄几毫秒、一次网络IO动辄几十毫秒,在这些场景里谈虚函数开销,基本等于讨论一滴水对太平洋水位的影响。
但有两种情况需要认真对待。第一种是超高频调用——每秒百万次甚至千万次级别的算法路径。第二种是延迟极度敏感的场景,比如高频交易、实时信号处理。这两种情况用模板策略最稳妥,因为它能把调用解析到编译期,消除了运行时的间接跳转。
std::function在捕获了非平凡状态对象时,会有额外的动态内存分配,这个开销在超高频调用下会被放大。如果非要用std::function,尽量让lambda不捕获或者只捕获POD类型,触发小对象优化,这样性能会和函数指针接近。
我整理了一个选型速查表:
| 实现方式 | 运行时开销 | 灵活性 | 策略数量多 | 代码量 | 适用场景 |
|---|---|---|---|---|---|
| 虚函数方案 | 一次间接跳转 | 运行时切换 | 适合 | 中等 | 多数业务场景、需要运行时策略切换 |
| std::function | 与捕获状态相关 | 极高,lambda零定义成本 | 适合 | 少 | 规则轻薄、策略频繁增删、测试替身 |
| 模板策略 | 理论为零,常全内联 | 编译期确定,运行时不可换 | 不适合过多 | 中等 | 超高频调用、嵌入式、算法核心 |
6.2 面试里怎么答策略模式才不落俗套
策略模式几乎是C++面试必考,但大多数候选人只会背定义:"定义一族算法,分别封装,可以互相替换"。这种回答在初级岗位可能够用,到高级岗位就显单薄了。
我在面试中更希望听到的是这样的思考轨迹:先用一句话点明策略模式解决的问题是"算法使用者和算法实现的解耦";然后指出在这个问题面前,if-else的架构缺陷在于开闭原则被破坏,新增需求要修改既有代码;再讲三种实现——C++98时代继承多态、C++11之后std::function、模板元编程编译期多态——各自适用什么场景;最后落到生命周期管理和工厂注册表这些工程细节上。能走到这一层,说明你真的在项目里用过策略模式,而不仅仅是看过书。
面试官如果追问"什么时候不该用策略模式",这是个高频陷阱题。我的答案是:当算法集合固定且永远不会变化,直接用传统函数或函数对象就好,策略模式带来的抽象反而增加了理解成本。策略模式是对"变化"的投资,如果"变化"根本不存在,投资就是浪费。另一个不该用的情况是策略太多太碎,几十个策略类让人眼花缭乱,这时候可以考虑用数据驱动的规则引擎代替。
6.3 一个反直觉的经验教训:策略模式不是银弹,滥用比不用更危险
写到最后,我必须泼一盆冷水。策略模式是设计模式里最容易上手的几个之一,但它也是最容易被滥用的几个之一。我做过一个项目,刚引入策略模式时团队热情高涨,把所有逻辑都塞进策略类里,结果代码库从"一个大函数+if-else"变成了"二十个小类+一个巨大的工厂"。拆是拆开了,但整体流程反而看不清了——因为每个策略类的组合和切换关系散落在各处,比原来的if-else更难追踪。
从那以后我总结出一条经验:策略模式是给"大概率会有新算法加入"的场景准备的。 如果算法一两个且稳定不变,老老实实写清楚分支,比什么都强。如果算法超过三个且改动频繁,策略模式的收益才开始显现。迁移的时机,应该是你第三次需要修改同一个分派点的时候,而不是第一次。
另一个心得是关于可测试性。策略模式最大的隐藏收益之一,是让单元测试变得极其舒服——每个策略类可以独立测,上下文可以用mock策略来测,不需要走完整的业务链路。我设计策略接口的时候,会刻意让策略方法接收所有依赖参数,而不是让策略自己去查全局配置。这样测试一个策略类,不需要搭环境、准备一堆前置状态,直接传参断言结果就行。面向测试设计策略接口,比面向实现简单去设计,要顺滑得多。
这些经验不是一天得来的。早年我写策略模式,也在生命周期上栽过跟头——策略对象被提前释放导致线上偶发崩溃;也曾一股脑把所有逻辑塞进策略类,搞得代码库比重构前更臃肿。踩过坑之后才慢慢明白,设计模式是工具,是服务业务的,不是业务来服务设计模式的。你手里的每一个策略类,都应该是真的承载了一类可独立变化的行为,而不是为了一份"看起来更优雅"的执念。用到这个程度,策略模式才真正开始在工程里发挥它应有的价值。
