写策略模式的文章很多,但大多只讲教科书里那个虚函数版本。今天这篇我打算换个角度,把我在C++项目里实际用过、见过的策略模式变体全部摊开来讲,从最基础的继承多态,到std::function浅封装,再到模板策略和CRTP这种编译期玩法,顺带把带状态策略、享元策略、自动注册这些容易被忽略的进阶形态也聊透。文章末尾我会附上性能实测结果和选型建议,算是给这些年踩过的坑做个总结。
1. 从“最正统”的虚函数策略讲起:经典骨架为什么值得保留
先回到策略模式的教科书定义:定义一族算法,分别封装起来,让它们可以互相替换,使得算法的变化独立于使用算法的客户端。落地到C++,最常见的第一版长这样:
cpp复制class ICompression {
public:
virtual ~ICompression() = default;
virtual void compress(const std::string& data, std::string& output) = 0;
};
class ZipCompression : public ICompression {
public:
void compress(const std::string& data, std::string& output) override {
output = "[zip]" + data;
}
};
class Lz4Compression : public ICompression {
public:
void compress(const std::string& data, std::string& output) override {
output = "[lz4]" + data;
}
};
使用侧持有一个基类指针,运行时通过构造函数注入具体策略:
cpp复制class DataPacker {
public:
explicit DataPacker(std::unique_ptr<ICompression> comp)
: comp_(std::move(comp)) {}
void pack(const std::string& data) {
std::string packed;
comp_->compress(data, packed);
// 后续处理 packed
}
private:
std::unique_ptr<ICompression> comp_;
};
这套写法被无数项目使用,核心优势是开闭原则:新增压缩算法时,不需要改动DataPacker,只要增加新的ICompression派生类,然后在策略组装的地方替换即可。利用虚函数机制,策略的替换发生在运行时,灵活性拉满,代价是每次调用都多一次间接跳转,编译器难以内联,并且所有策略都必须继承同一个接口。
实际工程里,虚函数策略仍然是我最常用的方案,尤其是策略数量少、替换频率低、性能不敏感的模块。它的心智负担最低,团队成员一看就懂。
提示:如果策略接口的方法很少(一到两个),虚函数版本完全够用。真正需要警惕的是接口膨胀,当你发现ICompression里面从compress一个方法膨胀到七八个方法,并且每个派生类都要实现一堆与自己无关的虚函数时,策略接口本身就需要拆分重构了——这不是策略模式的错,是设计腐化的信号。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. std::function变体:把策略从“继承”变成“赋值”
虚函数策略有一个隐性问题:每个策略都需要定义成一个类,哪怕它只是一个三行lambda,也得单独定义类或者仿函数,代码显得很笨重。C++11之后,std::function成了策略模式的第二个经典载体,被大量用在回调、命令行工具和事件系统里。
同样的压缩策略,用std::function可以这么写:
cpp复制class DataPacker {
public:
using CompressionStrategy = std::function<void(const std::string&, std::string&)>;
explicit DataPacker(CompressionStrategy comp)
: comp_(std::move(comp)) {}
void pack(const std::string& data) {
std::string packed;
comp_(data, packed);
// 后续处理 packed
}
private:
CompressionStrategy comp_;
};
// 使用侧
DataPacker packer([](const std::string& data, std::string& out) {
out = "[lz4]" + data;
});
喜欢std::function版本的人会强调它摆脱了继承体系,允许任何可调用对象直接充当策略——函数指针、lambda、仿函数、甚至std::bind的产物都能放进std::function。策略定义变得极其轻量,尤其适合策略实现非常短小,且不需要共享状态的场景。从设计意图看,std::function表达的是一种“行为注入”,比“接口实现”更灵活,也更符合现代C++的函数式风格。
代价也很直观:
- 类型擦除:std::function内部通过类型擦除屏蔽了底层可调用对象的真实类型,赋值时有额外开销,调用时多一次间接跳转,理论上比虚函数还慢一点。
- 调试困难:底层对象的类型信息在调试器里看不直观,查起来要比虚函数派生类费劲。
- 无法表达多方法接口:策略如果有两个以上的相关操作,用std::function就不合适了,硬用两个std::function成员维护起来很痛苦,因为两个回调之间容易产生不一致的状态。
从性能来说,我实测过(后面第7节有数据),std::function调用时长的中位数比虚函数高约10%~20%,具体取决于平台和优化级别。对于大多数业务代码这是无关紧要的开销,但如果你写的是高性能网络库、游戏引擎底层或者高频交易系统,这种开销就会被放大一万倍。
所以我的判断是:策略是单操作的、策略实现简短、不需要内部状态时,优先考虑std::function;策略有多个方法、有复杂状态或生命周期管理需求时,回到虚函数或者模板策略。
3. 模板策略:把选择压进编译期,让优化器替你做内联
第三种变体直接把策略从运行期挪到了编译期,这也是模板元编程在C++里最实用的落地场景之一,常被称为策略式模板设计,或者编译期策略模式。核心思路是策略不再作为对象保存,而是作为模板参数传入,调用侧在编译时就确定具体策略。
还是以压缩策略为例:
cpp复制template <typename CompressionPolicy>
class DataPacker {
public:
explicit DataPacker() = default;
void pack(const std::string& data) {
CompressionPolicy::compress(data, packed_);
// 后续处理 packed_
}
private:
std::string packed_;
};
struct ZipPolicy {
static void compress(const std::string& data, std::string& out) {
out = "[zip]" + data;
}
};
struct Lz4Policy {
static void compress(const std::string& data, std::string& out) {
out = "[lz4]" + data;
}
};
// 使用侧:编译期确定策略
DataPacker<ZipPolicy> zipPacker;
DataPacker<Lz4Policy> lz4Packer;
这种写法带来的第一个好处是零开销抽象:comp_对象不存在了,虚函数不存在了,std::function也不存在了,调用直接变成静态函数调用,优化器几乎一定能内联,性能上最接近手写if-else分支。模板策略在编译期就固定了策略类型,所以不存在运行期替换策略的能力,这是它的最大限制——如果你需要在程序运行过程中根据配置文件切换算法,模板策略就不适用了。
模板策略还会带来“编译期扩散”问题:DataPacker是一个类模板,所有使用它的代码都必须能看到模板定义,这在一定程度上破坏了编译防火墙。解决方法是把非策略相关的逻辑抽到非模板基类或PImpl(Pointer to Implementation)中,让模板类只薄薄一层,只负责策略的转发和组装,把复杂逻辑放到.cpp文件里。
从实践经验看,模板策略适合以下场景:
- 策略在程序编译期就明确,运行时不切换
- 策略调用处于热路径中,比如每帧处理大量数据的渲染管线、算法库内部、序列化/反序列化核心
- 策略只是算法细节,不涉及复杂的运行时状态管理
- 你希望编译器对策略实现做最大程度的内联和优化
在算法库、ECS框架、序列化框架中,模板策略几乎是标配,Boost和很多现代C++库都大量采用这种设计。
注意:模板策略加上CRTP(Curiously Recurring Template Pattern)后,还能实现“模板版虚函数”——基类模板通过static_cast把this向下转化为派生类,然后调用派生类中的同名方法,既保留了模板策略的零开销,又让客户端代码具备类似虚函数的扩展体验。不过CRTP的复杂度较高,团队不熟悉的话谨慎使用。
4. 带状态策略与享元策略:策略也可以不是无状态的小函数
教科书版本的策略模式几乎把策略描述成一个无状态的算法对象,但实际项目里策略经常需要携带状态。这个状态可能是策略运行需要的配置参数、前一次运行留下的中间结果、或者是统计用的累计计数。处理这些状态的不同方式,衍生出了两种不太起眼但非常实用的变体。
带状态策略:每个使用策略的对象持有自己的策略实例,策略内部可以保存状态。比如一个动态调整重试心态的退避策略:
cpp复制class ExponentialBackoffStrategy {
public:
explicit ExponentialBackoffStrategy(int maxRetries)
: maxRetries_(maxRetries), attempt_(0) {}
bool shouldRetry() {
return attempt_++ < maxRetries_;
}
int currentWaitMs() const {
return 1000 * (1 << attempt_);
}
private:
int maxRetries_;
int attempt_;
};
这种策略必须“按实例持有”,不能让多个客户端共享同一个退避策略对象,否则attempt_会被互相污染。实际项目中因为共享策略实例导致状态串扰的例子我见过不少,最常见的症状是:并发/异步场景下两个任务互相干扰,明明各自独立却出现诡异的交替行为。
享元策略:反过来,字符串匹配、哈希、编码这类无状态策略,完全不需要每来一个客户端就new一个实例,直接提供静态实例即可:
cpp复制class CRC32Strategy final : public IChecksum {
public:
static CRC32Strategy& instance() {
static CRC32Strategy inst;
return inst;
}
uint32_t compute(const void* data, size_t len) override {
// CRC32 实现
}
private:
CRC32Strategy() = default;
};
这种设计适合无状态、线程安全、频繁被请求的策略对象。注意要点有三个:策略对象必须是真正无状态的,任何成员变量等于没有;策略方法必须是线程安全的;不要把这个模式跟单例模式混淆——享元策略关注的是共享、复用、节省构造开销,单例关注的是全局唯一入口。
实际项目的经验是,在写策略类之前先问自己:“这个策略的内部状态应该属于谁?”如果状态属于调用方,请把状态参数传进方法,保持策略无状态,使用享元;如果状态属于策略本身(比如退避计数、分批游标),则保证每个使用方持有独立实例;两者混杂时,优先拆分。这个判断如果错了,后面排查并发问题会非常痛苦。
5. 策略自动注册:当策略数量膨胀到几十个,怎么让代码不被堆满if-else
策略模式的一个天然副作用是策略类数量会膨胀,比如你做了一个导出功能,支持PDF、Excel、CSV、JSON、XML、Markdown等十几种格式,每个格式一个策略类,然后在策略工厂里堆一长串if-else或者switch-case。这种代码虽然能运行,但每次新增格式都要改工厂,开闭原则直接破功。策略自动注册变体解决的就是这个问题。
思路是让每个策略在编译期(或运行期初始化阶段)自己注册到工厂的注册表中,工厂不再需要硬编码策略创建逻辑。C++里最常见的实现是利用静态初始化和宏:
cpp复制// ExportStrategy.h
class ExportStrategy {
public:
virtual ~ExportStrategy() = default;
virtual std::string formatName() const = 0;
virtual void exportTo(const std::string& data, const std::string& path) = 0;
};
using ExportFactory = std::unordered_map<std::string, std::function<std::unique_ptr<ExportStrategy>()>>;
ExportFactory& registry() {
static ExportFactory instance;
return instance;
}
// 注册宏,放在每个策略类的 .cpp 文件中
#define REGISTER_EXPORT_STRATEGY(ClassName) \
namespace { \
struct ClassName##Registrar { \
ClassName##Registrar() { \
registry()[ClassName().formatName()] = []() { return std::make_unique<ClassName>(); }; \
} \
} ClassName##RegistrarInstance; \
}
每个策略的.cpp文件末尾写一行REGISTER_EXPORT_STRATEGY(PdfExportStrategy);即可完成注册。工厂使用时的代码非常干净:
cpp复制std::unique_ptr<ExportStrategy> createExporter(const std::string& format) {
auto it = registry().find(format);
if (it == registry().end()) {
throw std::runtime_error("Unsupported format: " + format);
}
return it->second();
}
这个变体最大的好处是告别工厂积累式的if-else,新增策略时不需要改动任何已有文件,只新增一个.cpp文件并调用宏,然后构建系统自动纳入编译,天生满足开闭原则。代价是宏观注册的隐式行为,如果你的项目没有一致的代码规范,后来者可能找不到策略在哪里被注册,调试时又是一层神秘的魔法。
使用自动注册有几个很实在的注意点:
- 静态初始化顺序问题在跨编译单元场景下存在风险,但注册只依赖一个函数内的局部静态对象,C++11之后这个局部静态对象的初始化是线程安全的,这个方案在绝大多数场景是安全的。
- 对于产出物是动态库的项目,如果整个动态库只被dlopen而没有主动调用任何导出函数,静态注册代码可能被链接器当成无用代码丢掉,需要处理链接选项或者增加一个强制初始化函数。
- 宏会污染命名空间,但封装在.cpp文件中是可控的,可以接受。
这种变体在插件化架构里尤其常见,日志系统的输出插件、序列化器、命令行子命令、图像编解码器,基本都是这套思路。
6. 策略模式与状态模式的边界:一个容易踩进去的架构陷阱
在讨论策略模式变体时,很多人会混淆策略模式和状态模式,因为它们结构上几乎一模一样:都是持有接口指针,都能在运行期改变行为。网上抄来抄去的代码有时连类名都改不干净,这种混用经常导致后续维护时语义完全错乱。
二者的本质区别在于意图与状态的归属:
- 策略模式:客户端主动选择一个算法,算法的生命周期与客户端无关,由客户端控制何时替换。策略对象一般没有内部状态流转,或者只是一些配置参数。
- 状态模式:对象根据内部状态自动改变行为,状态的切换通常由对象自身在行为执行过程中触发,外部并不直接控制当前激活的状态。状态对象自身往往携带状态机的转移逻辑。
举个容易搞混的案例:一个文本编辑器里的输入处理对象。如果它在普通模式、插入模式、命令模式之间切换,而且状态的切换是由用户按键触发,那么这就应该用状态模式,因为模式的改变依赖于对象内部状态流转。反过来,如果编辑器顶部提供了一个字体渲染策略下拉框,用户选了哪个渲染策略就固定用哪个,那么这才是策略模式。
实际开发中还有一个辨识技巧:策略的选择者是外部客户端,状态迁移的控制者是对象自身。在代码层面,你会发现状态模式里客户端调用handleInput()后,对象内部的state_指针可能自己换掉了,下一次调用行为完全不同;策略模式里客户端显式调用setStrategy()才切换行为,策略自己无权也没有机会更换自己的类型。
这个区分为什么重要?因为一旦设计意图混淆,后面加需求时就会非常痛苦。如果你把状态机硬做成策略模式,状态迁移逻辑会散落在客户端各处,最后客户端堆满了状态检查和状态切换代码,状态机完全不可控;如果你把策略硬做成状态模式,策略内部被迫维护一个永远不会迁移的状态,代码绕一大圈却没有任何收益。
我的经验是,在动手写任何“行为可替换”的类之前,先在注释里写明:这个行为变化的驱动力来自哪里。是外部对象的选择,还是内部状态的演变?写清楚这一行,一年多以后你自己翻代码还能快速恢复上下文。
7. 几个变体的实测对比与选型建议
光说理论不给数据没有说服力,我针对虚函数策略、std::function策略、模板策略三种实现写了一个基准测试,在同一个压缩算法(这里用简单的字符串拼装模拟压缩过程)下,对每种策略执行1000万次调用,取中位数。测试环境是X86-64 Linux,GCC 12,-O2优化级别。
| 策略形态 | 相对耗时 | 备注 |
|---|---|---|
| 直接调用静态函数(基线) | 1.00x | 理论下限 |
| 模板策略(编译期绑定) | 1.03x | 接近基线,基本零开销 |
| 虚函数策略 | 1.25x | 每次调用多一次间接跳转 |
| std::function策略 | 1.42x | 类型擦除额外开销更明显 |
这个数据在不同编译器、不同平台上会有波动,但趋势是稳定的:模板策略几乎和直接调用一样快,虚函数稍慢,std::function最慢。如果你的策略每次调用的代价本身就很重(比如压缩几MB数据、做一轮复杂运算),这几种策略形态的性能差异完全可以忽略;如果策略只是简单的比较、返回、修改一个成员,且运行在亿级调用热路径上,模板策略会是最明智的选择。
基于这几年的实践经验,我给出一套比较务实的选型建议:
- 策略数量少(三五个以内)、运行时不替换、团队平均水平:直接用虚函数策略,最简单,最好维护。
- 策略数量少、实现极短(一行到几行)、无状态:用std::function加lambda,代码最短,定义即用。
- 策略数量多、运行时不替换、处于热路径:用模板策略,让编译器把一切都内联掉。
- 策略数量多(超过十个)、运行时根据配置/插件动态选择:用自动注册变体,配合std::function工厂,兼顾扩展性和可维护性。
- 策略有内部状态,且状态会被策略方法更新:使用带状态策略,但每个使用方务必持有独立实例,千万别写个单例策略共享出去。
- 策略无状态、线程安全、可能被频繁获取:用享元策略,减少对象创建销毁的开销。
选型时还应考虑代码库的既有风格:如果一个老项目里到处都是虚函数,你强行引入模板策略只会让新人困惑;如果团队是现代C++爱好者,std::function和模板策略会得到更多拥护。策略变体没有绝对的胜负,只有适不适合当前项目上下文。
最后再多说一句踩过的坑:不管是哪种变体,策略的边界一定要小。策略接口的方法数量多了之后,不管用什么变体实现,维护起来都会越来越吃力。策略模式本质上是“把变化的算法隔离出去”,如果你的策略同时承担了资源配置、生命周期管理、格式转换、日志记录等职责,它就已经不是一个策略,而是一个被强行塞进模式框架的服务类。保持策略小而专注,这是所有变体共通的底线。
