
策略模式可能是设计模式里被讨论最多、也最容易被C玩出花的一个名字。它解决的问题说起来很简单把一种“怎么做”的算法从“谁来做”的上下文里拆出来让两者可以各自变化。但在C这种既要性能又要抽象、还要兼容历史包袱的语言里“怎么拆”从来不止一种答案。虚函数接口是一种策略模式函数指针是一种变体std::function加lambda是更现代的变体模板和CRTP还能把策略变成编译期的概念。这篇文章从实际代码出发把C里的策略模式变体从头到尾捋一遍看看每一种变体的适用场景、性能取舍和坑在哪。如果你是写业务逻辑、做框架设计、或者天天泡在C性能优化里的人这篇文章应该能帮你省下不少翻文档和试错的时间。我会把代码直接贴出来配上我自己在实际项目里踩过的坑和取舍心得尽量做到看完就能用。1. 为什么要让“算法”和“上下文”分开1.1 没有策略模式时代码会变成什么样想象一个排序功能。最朴素的做法是在需要排序的地方直接写死一种算法比如冒泡排序。今天需求说数据量大要改成快速排序你就得在调用处改代码后天又要支持插入排序又得改一遍。更麻烦的是排序的调用方——比如一个数据展示类——它并不关心具体用哪种排序它只关心“你把数据排好”。算法和调用方硬绑在一起改动就会互相影响。策略模式的核心思路就是新增一层中间人调用方持有一个“排序策略”的抽象真正执行排序的对象是能在运行期替换的。这样调用方不变算法随便换。这不是什么高深理论本质就是“面向接口编程”那句话但在C里接口的形式决定了这个模式值多少性能、付多少维护成本。1.2 经典接口式策略所有人最先学会的写法大多数C教材会教下面这种写法。定义抽象基类派生类实现具体算法上下文类持有基类指针。class ISortStrategy { public: virtual ~ISortStrategy() default; virtual void sort(std::vectorint data) 0; }; class BubbleSort : public ISortStrategy { public: void sort(std::vectorint data) override { for (size_t i 0; i data.size(); i) { for (size_t j 0; j 1 data.size() - i; j) { if (data[j] data[j 1]) { std::swap(data[j], data[j 1]); } } } } }; class QuickSort : public ISortStrategy { public: void sort(std::vectorint data) override { std::sort(data.begin(), data.end()); } }; class Sorter { std::unique_ptrISortStrategy strategy_; public: explicit Sorter(std::unique_ptrISortStrategy s) : strategy_(std::move(s)) {} void setStrategy(std::unique_ptrISortStrategy s) { strategy_ std::move(s); } void sort(std::vectorint data) { strategy_-sort(data); } };这段代码能跑也没有原则性错误。它解决的问题是把“用什么算法排序”和“调排序的上下文”解耦了。调用方看的是ISortStrategy这张脸具体背后是冒泡还是快速它管不着。但我得说这种写法在C里有几个比较扎心的问题。第一多态调用是间接的一次策略调用至少经过一次虚函数分派在极端热路径上不是免费的虽然单次开销极小但积少成多。第二每个具体策略都是一个新类类一多就要维护一堆只包含一个函数的类命名、组织、头文件都是成本。第三这个设计的策略对象往往通过堆分配unique_ptr来持有即使策略本身是无状态的也得付出一次堆分配。第四如果策略之间有细微差异比如“带参数的排序”和“不带参数的排序”接口设计就会开始膨胀参数不同就得加虚函数或者加setter维护复杂度很快上来了。这不是说经典写法一无是处。跨模块边界、插件系统、需要RTTI场景下虚函数接口依然是C最正统的多态手段。但如果你只是想在一个内部组件里换一换排序算法为它开一个类继承体系确实有点牛刀杀鸡。C真正的魅力在于它允许你用更轻量、更贴合场景的方式实现同样的策略思想这就是下面要展开的变体。2. 函数指针与std::function把策略变成可传递的“值”2.1 函数指针最朴素的策略变体C语言里没有虚函数但C语言早就实现过策略模式的等价物。办法很简单函数指针。算法是一个普通函数上下文持有函数指针替换策略就是给指针重新赋值。using SortFn void (*)(std::vectorint); void bubbleSort(std::vectorint data) { for (size_t i 0; i data.size(); i) { for (size_t j 0; j 1 data.size() - i; j) { if (data[j] data[j 1]) { std::swap(data[j], data[j 1]); } } } } void stdSort(std::vectorint data) { std::sort(data.begin(), data.end()); } class Sorter { SortFn fn_; public: explicit Sorter(SortFn fn) : fn_(fn) {} void setStrategy(SortFn fn) { fn_ fn; } void sort(std::vectorint data) { fn_(data); } };这个变体比虚函数版本轻得多。策略对象就是一根函数指针赋值、传递、比较都直接按值拷贝完全没有堆分配调用成本基本等同于一次间接函数调用编译器还能根据上下文做推断优化很多时候跟直接调用函数没太大区别。代价也很明显。第一函数指针没有状态策略本身不能携带参数和配置数据。第二没有析构函数的概念也就没有策略生命周期管理。第三没有运行时类型信息代码的可读性弱一些遇到复杂策略时仅凭一个函数名字不太容易看出全貌。函数指针适合策略简单、要求极低开销、或者需要和C接口打交道的场景。比如一个内部的事件回调系统注册一个函数就够用。但说实话在纯粹的C项目里现在很少直接用函数指针做复杂策略了因为std::function提供了更完整的表达能力代价又没那么高。2.2 std::function与lambda现代C里最顺手的策略容器如果你希望策略能携带状态又不想把事情搞成接口继承std::function是你的第一选择。它就是类型擦除的可调用对象任何能“像函数一样调用”的东西都能装进去包括函数指针、函数对象、lambda表达式、bind表达式。using SortStrategy std::functionvoid(std::vectorint); class Sorter { SortStrategy strategy_; public: explicit Sorter(SortStrategy s) : strategy_(std::move(s)) {} void setStrategy(SortStrategy s) { strategy_ std::move(s); } void sort(std::vectorint data) const { if (strategy_) { strategy_(data); } } }; Sorter ascending([](std::vectorint v) { std::sort(v.begin(), v.end()); }); Sorter descending([](std::vectorint v) { std::sort(v.begin(), v.end(), std::greaterint{}); });这段代码精简了很多每个策略变成短短一个lambda不需要建类、不需要虚函数、不需要考虑继承关系。lambda可以捕获外部变量策略可以携带自己的配置。比如排序场景里一个策略需要排序键lambda里捕获一个键获取函数就行这在函数指针版本里根本做不到。std::function是怎么做到的核心机制是“小对象优化类型擦除”。如果可调用对象本身足够小比如一个无捕获的lambda它会被直接存在std::function对象内部不需要堆分配如果对象比较大比如捕获了一个大结构体std::function再在堆上分配空间存储。所以std::function的尺寸通常比函数指针大得多但大部分实现都能在“尺寸小”时避免动态分配现代标准库实现已经把这块优化得相当好。调用开销上std::function的一次调用相当于一次虚函数调用加上一次潜在的内存解除引用但现代编译器有内联优化一定程度上可以削减。我在实际项目里跑过简单基准std::function的调用一般比裸函数指针慢一点但差距经常在个位数百分比。除非你是写库的内核、执行几百万次调度的循环否则这个开销通常不需要操心。std::function还提供了显式的operator bool可以判断是不是“空策略”比虚函数指针好处理。也可以和lambda配合制造一种“默认行为”。比如class Sorter { std::functionvoid(std::vectorint) strategy_; public: explicit Sorter(std::functionvoid(std::vectorint) s {}) : strategy_(std::move(s)) {} void sort(std::vectorint data) { if (strategy_) strategy_(data); else std::sort(data.begin(), data.end()); // 默认策略 } };这个默认策略的设计用函数指针也能做但掺杂了“空指针判断”之后错误处理就容易糊在一起。std::function的语义让空策略成为一个合法状态很好表达。这个变体的适用面非常广。业务代码里的算法替换、回调、事件分发、菜单动作、测试桩替换基本都是首选std::function。我自己在好几个项目里都是拿std::function做策略容器代码写出来干净、好测试、后续加新策略只需要加一个lambda或者一个函数对象不用动类结构。2.3 用注册表做策略的“配额分发”当策略多到一定程度一个一个在代码里赋值反而不现实。更常见的情况是策略数量固定但需要根据配置、文件后缀或者用户输入选择。这时候可以做一个策略注册表把名字和策略映射起来就是一张map。class SortStrategyRegistry { public: using Creator std::functionstd::unique_ptrSorter(); void registerStrategy(std::string name, Creator creator) { registry_.emplace(std::move(name), std::move(creator)); } std::unique_ptrSorter create(const std::string name) const { auto it registry_.find(name); if (it registry_.end()) { throw std::runtime_error(unknown strategy: name); } return it-second(); } private: std::mapstd::string, Creator registry_; };这个模式在企业级项目里特别常见。新策略来了只要注册一行调用方永远通过字符串拿策略。它把“策略模式”和“工厂模式”叠在一起用本质上就是一种策略模式变体。好处是策略管理的部分完全独立业务代码里只有create一句配置文件驱动时尤其好用。坑也有一个注册表是全局的并发访问要处理同步要么注册都在启动阶段完成要么用双缓冲切换。3. 模板策略把决策从运行期搬到编译期3.1 模板参数策略零开销抽象经典策略的多态发生在运行期std::function也一样。C还有一条完全不同的路让编译器在看到代码的时候就确定用哪个策略这就是模板。templatetypename Strategy class Sorter { Strategy strategy_; public: explicit Sorter(Strategy s {}) : strategy_(std::move(s)) {} void sort(std::vectorint data) { strategy_.sort(data); } }; struct StdSortStrategy { void sort(std::vectorint data) const { std::sort(data.begin(), data.end()); } }; struct BubbleSortStrategy { void sort(std::vectorint data) const { // 冒泡实现 } };使用的时候Sorter 和Sorter 是完全不同的类型编译期就确定了调用目标。编译器能够把strategy_.sort(data)直接内联成实实在在的排序代码所有间接调用都被抹除性能上可以做到和手工把排序逻辑直接写进调用点一样。这就是“零开销抽象”的含义想要抽象但不付出额外运行成本。这个变体极其适合性能敏感的组件典型例子是标准库里的std::sort。std::sort本身就是一个函数模板接受任意比较器。比较器是类型参数不是虚函数排序循环里每次比较都能内联。如果用std::function传比较器每次比较都要间接跳转排序效率可能会下降很多。标准委员会当初在std::function刚引入时还专门讨论过这类问题结论很一致性能热点的抽象必须静态绑定。3.2 非类型模板参数与CRTP更省空间的策略模板如果策略类没有任何数据成员可以直接用一个函数指针或者一个“标记类型”作为模板参数进一步减少存储空间。函数指针作为非类型模板参数连对象都省了templatevoid (*SortFn)(std::vectorint) class Sorter { public: void sort(std::vectorint data) { SortFn(data); } }; void bubbleSort(std::vectorint data) { /* ... */ } SorterbubbleSort sorter;这个写法很“硬核”Sorter实例不存任何成员变量策略信息全在类型里。代价是这种写法无法使用带状态的策略而且模板参数必须是在编译期已知的函数地址灵活性比较有限。大多数情况下模板参数策略用“类型”而不是“函数指针”会更自然因为类型可以携带静态成员变量、类型别名等。另一个常见的编译期策略实现是CRTPCuriously Recurring Template Pattern。它把基类模板化派生类把自己作为模板参数传进去基类通过static_cast转型访问派生类的方法。这算是对经典继承关系的一次“反转向量”templatetypename Derived struct SortBase { void interface(std::vectorint data) { static_castDerived*(this)-sort(data); } }; struct BuiltInSort : SortBaseBuiltInSort { void sort(std::vectorint data) { std::sort(data.begin(), data.end()); } };这里没有虚函数却保留了“接口-实现”的层次感。调用interface时static_castDerived*被编译器解析成了具体类型的方法内联后可执行代码等同于一段普通排序调用。CRTP在模板库和框架设计里用得很多比如boost::iterator库里有它的影子现代项目里也偶尔用来做静态多态的策略基类。但是CRTP写起来不如普通模板策略直观新手容易绕晕对新手项目来说普通模板策略可能更划算。3.3 模板策略的边界在哪模板策略也不是银弹它的核心限制就是类型是编译期确定的。如果你有两个不同的策略类型Sorter 和Sorter就不能放在同一个容器里、不能存进同一个vector、也不能在运行期根据用户输入动态切换。策略模式的一个灵魂是“运行期可替换”模板策略把替换从运行期搬到了编译期换来的是性能和类型安全失去的恰恰是运行期灵活性。还有头文件与二进制兼容性。模板策略必须定义在头文件里所有调用点的编译单元都要看到完整实现这会拉长编译时间、增加二进制接口依赖。库的发布方如果改动了模板实现使用方经常要重新编译整个工程。相比之下经典接口策略允许基类接口在共享库里保持稳定派生类在另一个库里二进制兼容要好不少。所以我的经验是模板策略适合做“内聚在一个编译单元里的组件”比如GUI控件的排序器、数值算法库的组合子。一旦策略要跨二进制模块传递、或者要在配置系统里被动态创建首选还是跑回std::function或者接口策略。这个权衡是每一个C架构师都要学会做的。4. 策略模式的另类变体状态切换、策略容器与混合设计4.1 策略模式和状态模式的暧昧关系如果拿状态模式State Pattern和策略模式放一起对比会发现它们在结构上几乎长得一模一样都是持有抽象接口、都是运行期切换。区别只是意图策略模式关心“算法变了”上下文本身不改变行为状态模式关心“状态变了”上下文的行为跟着整体切换。C代码里经常出现状态机用策略接口实现的情况比如一个网络连接对象在“连接中”“已连接”“已断开”三种状态下收发数据的逻辑完全不同。你可以把这三个状态各写成一个策略类然后连接对象用一个指针指向当前状态切换状态就是换指针。class Connection; struct IConnectionState { virtual ~IConnectionState() default; virtual void send(Connection c, const std::string data) 0; }; struct ConnectedState : IConnectionState { void send(Connection c, const std::string data) override { // 真正发送 } }; struct ConnectingState : IConnectionState { void send(Connection c, const std::string data) override { // 等待连接完成 } };这种“类状态机”正是我所说的一种策略模式变体本质是把每个状态当作一个策略。当状态数量少、状态转换逻辑简单时这种方法很清晰。但当状态数量多比如超过七八个把所有转换逻辑都放在上下文里会让Context类膨胀成一个“上帝类”这时候更建议引入事件驱动或者实现真正的有限状态机框架策略在这里退位为状态机的处理单元。4.2 策略容器一次执行多个策略前面讨论的策略都是一个上下文一个策略。有些场景里“多个策略叠加”比“多选一”更合理比如数据处理器要对输入做清洗、转换、过滤、统计每一步都是一个策略策略之间有顺序。C里可以用vector 表达策略容器。using ProcessingPolicy std::functionvoid(std::vectorint); class DataPipeline { std::vectorProcessingPolicy steps_; public: void addPolicy(ProcessingPolicy p) { steps_.push_back(std::move(p)); } void run(std::vectorint data) { for (auto step : steps_) { step(data); } } };这是策略模式的一个自然延展策略模式最早解决的是“算法替换”但实际工程里“算法组合”的需求更常见。在图形学、机器学习、数据处理库里经常能看到这种流水线风格的设计。好处是新增一步只改配置不动代码。注意顺序依赖的场景里一定要把“策略有先后顺序”写进注释或者文档里否则后来者乱调addPolicy的顺序逻辑就全坏了。更复杂的策略容器还可以做成“责任链”每个策略对象可以选择“处理”或者“传给下一个”。这种变体在日志处理器、中间件、参数校验器里非常常见。C实现时只需要给策略的签名增加一个“next”参数或者返回一个bool表示是否中断。using FilterPolicy std::functionbool(std::vectorint); class FilterChain { std::vectorFilterPolicy filters_; public: void setFilters(std::vectorFilterPolicy f) { filters_ std::move(f); } bool apply(std::vectorint data) { for (auto filter : filters_) { if (!filter(data)) { return false; // 中断 } } return true; } };这种写法比策略替换灵活很多我感觉它才是C里真正被广泛使用的策略形态之一。4.3 type erasure与模板策略的混合设计很多人以为模板策略和std::function水火不容非要二选一。其实可以混着用模板策略做内层的高性能实现再用std::function做一层类型擦除封装让外部能动态切换。templatetypename StorageStrategy class GenericSorter { StorageStrategy s; public: void sort(std::vectorint data) { s.sort(data); } }; using SortHandle std::functionvoid(std::vectorint); class DynamicSorter { SortHandle handle_; public: templatetypename ConcreteStrategy void setStrategy(ConcreteStrategy st) { handle_ [st std::move(st)](std::vectorint data) mutable { st.sort(data); }; } void sort(std::vectorint data) { handle_(data); } };这样性能敏感的调用点可以使用模板策略享受内联而不太敏感的调用点换成DynamicSorter享受运行期可替换。两者共享同一个算法实现类不用重复造轮子。这个模式在大型C库里经常见到标准库里的std::function本身不就和“泛型-回调”紧密结合吗这也是C的生态特点不同类型的抽象可以嵌套不需要非黑即白。5. 选型指南、踩坑记录与我的实用建议5.1 六种变体怎么选我整理了一个表把前面讲的几种策略模式变体放在一起对照方便你直接按场景选。变体形态绑定时机灵活性性能状态支持典型场景经典接口虚函数运行期高/继承体系扩张快有虚调用开销支持插件系统、跨模块边界函数指针运行期中/不能捕获状态很高不支持C ABI、极简回调std::functionlambda运行期很高/可以捕获状态较高个别热点有损支持业务策略、事件回调模板参数编译期低/不能动态换最高支持需传对象性能热点的库内组件非类型模板参数编译期低/无状态最高不支持编译期固定流程注册表工厂运行期高/配置驱动多一次map查找支持插件加载、配置系统我的判断依据大致是调用点离性能瓶颈越近越应该往编译期绑定靠调用点离人越近越应该往std::function这种可读性强的方向靠。如果你的需求是“写一个框架给外部用”经典接口和std::function都要考虑如果你的需求是“自己项目内部的一个高频函数”模板策略会更好。5.2 实操里最常见的几个坑第一虚析构函数。经典接口策略的基类必须定义虚析构函数否则delete基类指针时派生类析构函数不会调用轻则内存泄漏重则踩坏内存。很多现代代码用unique_ptr持有策略以为这样安全了但是基类析构还是虚的检查一下不会错。第二lambda捕获引用导致的悬垂引用。std::function策略捕获了外部变量的引用一旦外部变量生命周期结束策略调用就是访问悬垂引用。常见于注册全局回调时捕获了局部变量。解决方式是按值捕获或者明确生命周期比策略更长再传引用。第三std::function的判空。默认构造的std::function是空的调用之前不判空会抛std::bad_function_call。我在一个项目里就是因为漏了判空偶发崩溃排查了很久。可以通过operator bool检查。第四模板策略的里氏替换问题。模板策略的类型绑定编译期如果后续想改成运行期替换需要动所有调用点成本不低。所以新项目里如果拿不准我建议先上std::function后续优化时再局部替换成模板策略别一开始就绑死在编译期。第五策略实现的线程安全。如果策略对象被多个线程共享策略内部的成员变量需要做好同步否则两个线程同时调用策略会竞态。切换策略时也要考虑旧策略是否还在被别的线程使用简单办法是双缓冲先把新策略放在一个变量里等所有旧调用退出后再切换。这个问题在配置热更新系统里尤其容易忽视。5.3 给真实项目的三条建议第一不要为了用设计模式而用。策略模式的核心目标是“防止算法变化导致调用方代码改动”。如果你的算法可能三个月不变你根本不需要策略。引入策略模式要付出接口设计、类数量增加、间接层级增多的代价一定要因为实际问题去引入而不是因为“它是GOF里的模式”。第二优先选择最简单的有效变体。很多业务代码里std::function加lambda已经能解决95%的策略需求。如果后续发现需要编译期绑定再换模板或者需要跨模块边界再换接口。C最大的特色是“演进式设计”你完全可以在代码演进过程中逐步替换实现形态而不需要一开始就搭建最复杂的架构。第三把策略的生命周期交给调用方。不管是哪种变体最重要的原则是策略的所有权清晰。我的习惯是上下文不拥有策略只引用或持有可拷贝的std::function具体策略的管理放在更上层的容器里。这样策略可以灵活组合、替换、测试不会被上下文锁死。最后再分享一个小技巧。在维护一个已经很老的大型C项目时我经常把经典接口策略慢慢迁移到std::function加lambda的写法。不是说虚函数不好而是老代码里那些“一个接口只有一个方法”的策略类用lambda在调用点写看上去更直观。迁移时先不动接口定义只把策略实现类改成lambda用测试兜底一个模块一个模块替换。整个过程没有一次大规模重构却把代码的可读性提了一大截。这就是策略模式变体最实际的价值不是让你炫技是让你在正确的地方用最顺手的工具。