资讯动态

C++策略模式高级应用:模板注入、std::function、std::variant实战

发布时间:2026/10/13 5:10:55 来源:尧图企业网站定制
凡是写过业务系统的朋友恐怕都逃不过策略模式这个话题。不过说句得罪教科书的话很多C开发者对策略模式的理解基本停留在“抽象基类加虚函数子类各自实现算法”这一层。这套思路本身没错但在真实工程里它带来的类爆炸、运行期开销、策略对象生命周期管理经常让人头疼。今天我想用自己这几年在C项目里摸爬滚打的经验聊聊策略模式的高级应用逻辑重点讲编译期模板注入、std::function类型擦除、std::variant值语义多态这三条路线以及它们和工厂、状态机、组合等模式的真实配合方式。后面还有我在实际项目中踩过的坑和排查套路希望对正在设计通用框架的同学有点帮助。1. 策略模式的基本盘与C特色全景1.1 策略模式到底在解决什么问题策略模式这个名字很直白就是把一类可相互替换的算法或行为封装成独立对象让使用方在不改动自身代码的前提下自由切换。它解决的核心痛点是“变化”。举个例子很多业务系统里都有支付逻辑我早期见过一版代码是这样的enum class PayType { Card, MobilePay, Wallet }; void pay(PayType type, double amount) { if (type PayType::Card) { // 银行卡支付流程 } else if (type PayType::MobilePay) { // 手机支付流程 } else if (type PayType::Wallet) { // 钱包支付流程 } }每次新增一种支付方式都要改这个业务函数加一个分支。时间一长这个函数可能就是几百行各种支付渠道的特殊处理逻辑全揉在里面看着就头皮发麻。策略模式的做法是定义一个策略抽象把每一种支付流程放进独立的策略类业务函数只面向策略接口编程。这样新增支付方式就是新增一个类原有代码不用动符合开闭原则。这个例子解释了策略模式最基础的应用场景但C的场景要比教科书复杂得多。C里实现策略模式其实有多条技术路线可选每一条都有自己的决策时机、性能特征和维护成本没有绝对的银弹。1.2 C实现策略模式的技术全景我先用一个表把C里常用的策略实现方式拉通对比一下后面再逐项拆解。实现方式决策时机灵活性性能特征适合场景抽象基类 虚函数运行期高策略集合可扩展虚函数调用有间接跳转无法内联教科书经典策略对象有丰富状态函数指针 / std::function运行期高可动态切换std::function有类型擦除开销函数指针较小无状态算法回调式策略模板策略编译期多态编译期低策略类型固定零额外开销可内联算法族固定、性能敏感的模块std::variant std::visit编译期中策略集合封闭值语义无堆分配通常比虚函数快已知有限策略集合穷尽匹配lambda表达式轻量策略编译期/运行期高取决于捕获类型和调用方式局部算法注入、快速原型很多人一提到策略模式就默认用虚函数其实在C里虚函数只是最普通的一条路。对于一个只包含一个纯虚算法的策略接口虚函数机制带来的间接调用往往是不必要的。你说你封装一个“计算折扣”的函数接口里就一个纯虚拟方法没必要非搞一个Strategy基类出来直接一个std::functiondouble(double)不是更清爽吗1.3 经典虚函数策略的三个隐藏陷阱虚函数策略不是不能用我自己也在不少项目里用过。但它有几个问题教科书里一般不会写。第一个问题是类爆炸。每个策略都强制定义一个类策略稍微多一点文件数量就蹭蹭涨。我见过一个项目各种压缩算法策略、校验策略、编码策略类文件堆了上百个很多策略类其实就一个方法。这种结构看着“设计感”很强实际维护起来很累。第二个问题是生命周期。虚函数策略的对象通常由上下文持有引用或指针策略对象要不要释放、是共享还是独占、会不会并发访问这些都得自己处理。一旦策略对象内部保存了一些状态比如累计计数、临时缓存多线程环境下的所有权问题就会冒出来。第三个问题是虚函数本身的开销。单个虚函数调用通常只有几纳秒级别的开销多数业务系统根本不用在意。但在高频调用的循环里虚函数无法内联编译器没法跨调用做优化这个损失是实打实的。如果一个策略接口里就一个两行计算的函数为它付出虚函数调用的代价不值得。所以我的建议是虚函数策略适合“策略对象本身有状态、有多个相关方法、且需要在运行时动态扩展”的场景。如果你的策略只是一个算法函数请优先考虑后面的现代C方案。2. 现代C下的策略注入实战模板、function、variant2.1 编译期策略模板参数的零开销注入C的模板机制可以把策略绑定从运行期提前到编译期这就是所谓的编译期多态。核心思路是把策略类型作为模板参数传入上下文类编译器在实例化时直接把策略代码内联进来。举个实际例子我在某个数据处理模块里需要支持不同的日志输出策略template typename LoggerPolicy class DataPipeline { public: void process(const std::string raw) { auto result transform(raw); LoggerPolicy::log(process finished, result); } private: std::string transform(const std::string raw); }; class FileLogger { public: static void log(const std::string msg, const std::string detail) { // 写文件日志 } }; class NullLogger { public: static void log(const std::string msg, const std::string detail) { // 什么都不做 } }; // 使用 DataPipelineFileLogger pipelineWithLog; DataPipelineNullLogger pipelineWithoutLog;这里的策略是静态绑定编译后NullLogger的log调用会整个被优化掉连函数调用都不存在。这种“零成本抽象”是虚函数方案永远给不了的。用模板策略有个代价就是上下文类型因为模板参数不同而互不兼容。DataPipelineFileLogger和DataPipelineNullLogger是两个不同的类型不能在同一个容器里用同一个类型名存放。所以模板策略适合那些在编译期就能确定策略、运行时不需要动态换策略的场景比如嵌入式、高吞吐计算、游戏客户端里固定逻辑的算法变体。再用一个比较器的例子加深印象。排序时想切换升序、降序、按字段排序模板加lambda可以写得很干净template typename Compare void sortAndPrint(std::vectorint data, Compare cmp) { std::sort(data.begin(), data.end(), cmp); for (int v : data) std::cout v ; std::cout \n; } sortAndPrint(v, std::lessint{}); sortAndPrint(v, std::greaterint{}); sortAndPrint(v, [](int a, int b) { return (a % 10) (b % 10); });这就是策略模式在C里最优雅也最高效的一种形态不需要任何抽象基类不需要虚函数表交给编译器去拼装。2.2 运行期策略std::function与lambda的组合拳如果策略必须在运行期根据用户输入动态切换模板这条路就走不通了因为运行期条件无法影响编译期类型。这时候我首选std::function。std::function本质是一个类型擦除的可调用对象容器它可以持有函数指针、lambda、函数对象只要调用签名匹配就行。用它来实现策略上下文类可以长这样class OrderContext { public: using DiscountStrategy std::functiondouble(double); void setDiscountStrategy(DiscountStrategy s) { strategy_ std::move(s); } double calculateFinalPrice(double original) const { return strategy_ ? strategy_(original) : original; } private: DiscountStrategy strategy_; }; OrderContext order; order.setDiscountStrategy([](double price) { return price * 0.85; // 85折 }); order.setDiscountStrategy([](double price) { return price 200 ? price - 30 : price; // 满减 });这种写法最大的优势是不用为每个策略单独定义类几个lambda一写策略就出来了。而且lambda可以捕获上下文状态等于把策略的参数直接打包带走省去策略对象传参的麻烦。很多人会担心std::function的性能。我实测下来一条间接调用的开销在现代CPU上大概是几纳秒级别除非你的模块每秒要调用这个策略几十万次否则完全不用担心。std::function还有一个小对象优化小尺寸的lambda直接存在对象内部不会触发堆分配只有捕获大量数据的lambda才可能走堆。真到了性能敏感的地方可以改用一个自定义的function_ref轻量封装或者直接用template方案而不是因噎废食地退回虚函数。顺便聊一个工程心得我喜欢把std::function策略跟共享状态配合使用。如果是无状态的策略比如“金额四舍五入”“字符串转大写”可以直接用全局函数或静态函数指针如果策略需要携带配置就用lambda捕获一个配置对象。这种组合几乎能覆盖绝大多数运行期策略需求。2.3 值语义多态std::variant封闭策略集合还有一种比较高级的玩法是用std::variant配合std::visit实现策略切换。它本质上不是面向对象的多态而是用代数数据类型表达“当前策略是这几种之一”。来看一个图形处理的例子。不同形状的面积计算策略如果按传统虚函数来实现需要定义基类Shape和各子类。用std::variant是这样struct Circle { double radius; }; struct Rectangle { double width; double height; }; using Shape std::variantCircle, Rectangle; double area(const Shape shape) { return std::visit([](const auto s) - double { using T std::decay_tdecltype(s); if constexpr (std::is_same_vT, Circle) { return 3.1415926 * s.radius * s.radius; } else if constexpr (std::is_same_vT, Rectangle) { return s.width * s.height; } return 0.0; }, shape); }std::variant的方案有几个很有意思的特性。第一它是值语义Shape对象直接排在栈上或者嵌在容器里没有堆分配也没有指针悬空的问题。第二std::visit是用生成的跳转表或条件链来处理切换编译器能拿到全部类型信息往往比虚函数调用更有优化空间。第三如果你加了一种新策略进去所有std::visit的访问器都会被编译器要求处理新分支否则编译报错这让代码“穷尽匹配”很安全。但代价也很明显策略集合是封闭的。想扩展策略得改variant的类型列表这跟策略模式的“开放扩展”理想有冲突。所以我一般只在策略集合明确、变化不频繁的模块里用它比如编译器前端的分词策略、协议解析器里的帧类型分发这种场景下variant比虚函数舒服太多。3. 三个真实工程场景中的高级策略设计3.1 场景一图像处理框架中的编解码策略设计前两年我参与过一个图像处理框架的重构当时设计目标是支持多种图像格式的读取和写入。按传统思路会设计一个ImageCodec抽象基类PNG、JPEG、BMP各建一个子类工厂负责创建。结果发现两个痛点一是格式相关的选项参数五花八门塞进构造函数或者方法签名都很别扭二是某些格式内部有细分变种比如同一格式下有不同压缩级别、不同色彩空间如果都用子类表达类数量直接爆炸。重构后的设计采用了策略分层思路。外层暴露std::function注册机制调用方传lambda进去把“格式识别”和“像素数据转换”解耦内层再用模板策略封装具体的编解码核心算法。class ImagePipeline { public: using DecoderStrategy std::functionBitmap(const std::vectoruint8_t); using EncoderStrategy std::functionstd::vectoruint8_t(const Bitmap); void registerDecoder(std::string_view fmt, DecoderStrategy decoder) { decoders_[fmt] std::move(decoder); } void registerEncoder(std::string_view fmt, EncoderStrategy encoder) { encoders_[fmt] std::move(encoder); } Bitmap decode(std::string_view fmt, const std::vectoruint8_t fileData) { auto it decoders_.find(fmt); if (it decoders_.end()) { throw std::runtime_error(unsupported format); } return it-second(fileData); } private: std::mapstd::string, DecoderStrategy, std::less decoders_; std::mapstd::string, EncoderStrategy, std::less encoders_; };这么调整以后新增格式只需要注册对应的lambda不用动框架代码。而且lambda可以捕获该格式特有的配置对象每个格式的私有参数都在注册侧闭包携带不需要污染对外接口。这个方案后来维护起来很轻松核心框架代码半年没怎么改过。3.2 场景二机器人控制中的运动策略动态切换另一个很有代表性的场景是机器人控制。某机器人项目需要根据环境输入在几种运动模式之间切换直线巡线、避障绕行、原地旋转。每种模式都用一套不同的控制算法运行期根据传感器读数实时切换。我最初设计了一个纯虚函数策略接口每种运动模式一个策略类控制器持有一个策略指针。代码能跑但有两个不舒服的地方。第一运动控制算法会涉及很多临时状态比如避障的绕行角度累计值这些状态放策略类里切换策略时状态要不要清、保留到什么时候很难说清。第二同一个运动模式可能有多种调参版本不同传感器阈值、不同速度参数用子类表示太笨重。最后改成std::function加共享配置的方式class MotionController { public: using MotionStrategy std::functionvoid(double sensorInput, MotionState state); void setStrategy(MotionStrategy strategy) { currentStrategy_ std::move(strategy); } void update(double sensorInput) { if (!currentStrategy_) return; currentStrategy_(sensorInput, state_); } private: MotionStrategy currentStrategy_; MotionState state_; };每个运动模式实现为一个函数接收传感器数据和统一的状态结构。策略可以捕捉自身的配置状态统一放在MotionState里切换策略时只替换函数本身。避障绕行和巡线虽然有完全不同的算法但对控制器而言都是“一个策略函数”逻辑瞬间简化了很多。后面项目里甚至把策略切换本身交给一个简单的状态机状态机决定什么时候调用setStrategy策略模式和行为模式的边界一下子就清晰了。3.3 场景三金融行情终端中的协议适配策略再分享一个偏商业的场景。我在某个金融行情终端模块里需要同时对接多个数据源每个数据源的协议字段、消息封装、心跳机制都不太一样。这个场景有个特点数据源协议是明确已知的短期内不会新增但每个协议的处理逻辑很复杂涉及很多状态和私有数据结构。这种情况下我反而推荐用传统的虚函数策略因为每个协议适配器都是个完整的类内部有几十个成员函数和复杂状态。用std::function把协议处理逻辑塞进一个lambda里会让对象膨胀得没法维护用std::variant把所有协议类型写死又要维护一个巨大的visit分支。class ProtocolStrategy { public: virtual ~ProtocolStrategy() default; virtual void handleFrame(const std::vectoruint8_t raw) 0; virtual std::vectoruint8_t buildLoginPacket(const Credentials creds) const 0; virtual bool validateChecksum(const std::vectoruint8_t raw) const 0; }; class VendorAProtocol final : public ProtocolStrategy { // 完整实现VendorA的私有协议 };但这里有一个关键工程细节这些策略对象是无状态的还是轻状态的因为协议适配器内部没有太多动态状态主要依赖外部注入的会话信息所以我把它们做成可复用的共享对象用单例管理多个连接共享同一个适配器实例。如果以后出现有状态的适配器比如需要保存序列号即刻改成按连接实例化放进连接对象的unique_ptr成员里。这个案例想说明的是策略模式的实现方式从来没有绝对的优劣关键在于跟场景匹配。协议适配器这种重策略虚函数最合适运动控制这种动态切换的轻策略std::function最好用编解码框架这种策略集合靠注册扩展的场景函数映射表最灵活。熟练使用多种策略形态你才能在设计时真正游刃有余。4. 性能、可维护性与工程取舍4.1 性能对比不同实现的真实代价很多技术团队在代码评审时经常因为“性能”争得面红耳赤有人说std::function慢有人说虚函数有开销还有人说要彻底避免运行时多态。我觉得先分清楚量级再争论否则只是感觉。我把四种实现方式的性能特征粗略拉了个对比实现方式调用开销可内联性附加成本模板策略 lambda几乎等于直接调用完全可内联无函数指针一次间接跳转通常不可内联无虚函数查虚表 间接跳转不可内联除非去虚拟化对象需要vptrstd::function类型擦除 潜在堆分配 间接调用一般不可内联可能堆分配大捕获std::variant visit分支或跳转编译器可见所有类型有一定优化空间占用为最大类型尺寸高吞吐的场景下我做过粗略测试同样一百万次调用模板策略耗时趋于零优化函数指针比直接调用慢百分之十几虚函数和std::function差不多在这个量级。但请注意这个数据跟编译器优化级别、CPU架构都相关我只能给个量级参考不建议把某个数字当成绝对真理。工程上的结论是策略模式的性能差异在大多数业务系统里可以忽略。真正的性能杀手从来不是那一次间接调用而是策略内部算法本身的质量。如果你在写一个每秒几十万次的调度循环那就用模板策略顺便让编译器帮你把算法内联如果一天就调用几千次直接怎么舒服怎么来没必要为了省几纳秒把代码搞复杂。4.2 常见设计陷阱与维护红线用策略模式用多了也会发现一些反模式。我总结了几类频繁踩坑的情况每条都是真金白银换来的教训。第一类是策略粒度过细。一个系统里到处都是策略每个策略类就一两个方法类文件比业务类还多。策略本身成了复杂度而不是封装复杂度。我的经验是策略的粒度应该跟“变化的单元”对齐。如果变化的是整个算法流程策略接口可以大一些包含多个方法如果只变一个比较规则一个std::function就够了不要硬造类。第二类是策略对象内部保存可变状态导致并发和重复调用问题。假设一个折扣策略类内部设计了一个“本月已用次数”计数器这个对象在多个订单线程间共享结果就是你调了十次API计数器被并发改得面目全非。解决方式很简单要么把策略设计成无状态纯函数可变数据通过参数传入要么明确策略对象的所有权每个线程或每个任务独占实例。第三类是在策略体系中混入dynamic_cast。一旦你的策略上下文开始用dynamic_cast去判断具体策略类型就说明策略抽象设计失败了你在让调用方依赖具体的策略类型。遇到这种代码应该考虑把差异化接口提到策略抽象里或者干脆换成一个更直接的分发方案比如std::variant。第四类是策略模式和过度设计纠缠在一起。有的项目为了“以后扩展”给一个现在只有两种实现的功能加了一套完整策略框架接口文档写了一大堆实际项目半年没有任何新策略加入。这种提前抽象的代码维护成本远大于收益。策略模式解决的是“现在就能看到的扩展点”不是幻想里的扩展点。4.3 策略模式与工厂、组合、状态机的配合单独使用策略模式很多时候感觉不够用需要跟其他模式打组合拳。这里聊几个我最常用的搭配。策略加工厂是处理策略创建逻辑的标准组合。策略使用方不关心策略怎么来只关心拿到一个可用策略。工厂根据配置字符串、枚举或动态库加载结果返回对应策略创建细节全部封装。C里我一般用函数表实现工厂std::unordered_mapstd::string, std::functionstd::unique_ptrStrategy()比一堆if else清晰得多。策略加组合可以构造出责任链式的策略链。比如一个文本处理管线先做转码再做敏感词过滤再做排版格式化每个环节都是一个策略整个处理流程是把策略串起来。组合方式和策略模式的区别在于意图策略模式强调算法互换组合模式强调部分整体的树状结构但两者结合起来可以很好地把处理流程拆成可独立修改的小策略。策略加状态机是我在动态切换场景里常用的配合。状态机负责“什么时候切换策略”策略负责“切换到策略后怎么执行”。状态机本身不需要知道策略的算法细节策略也不需要理解状态机的转移条件。这样两个维度都各自封闭系统才能灵活演进。5. 常见问题与调试技巧实录5.1 高频踩坑对照表我把实际项目里遇到过的策略模式相关高频问题整理成一个速查表排查同类问题时可以直接对照参考。现象根本原因排查思路解决方案策略总是切不过去上下文里持有的是旧策略拷贝改的是临时对象检查setStrategy是否作用于同一个实例确认上下文持有策略的是引用或拷贝赋值而不是返回局部对象多线程下单量计算不对策略对象有内部状态且被共享多线程环境下策略实例被并发访问无状态化或每个线程独立实例新增策略类型后编译错误一堆第8种新增了未处理的分支std::visit要求穷尽匹配给默认分支或统一接口而不是每个visit都改策略类文件数量爆炸策略粒度过细一个类一个文件统计每个策略类的行数用lambda或函数指针替代无状态简单策略std::function调用比预期慢大lambda触发堆分配用sizeof(std::function)看是否超过小对象阈值改用function_ref或模板策略策略切换后状态残留策略对象保存了上一次运行的状态检查策略对象的成员变量明确状态生命周期切换时重置这张表里最让我印象深刻的是第二种并发问题。当时项目里一个计价策略被多个线程共享计价结果忽高忽低排查了很久才发现是策略对象内部缓存了上一次的计算中间值。从那以后我在设计策略对象的时候会先问一句这个策略能不能写成纯函数能的话绝不存状态。5.2 我常用的策略调试三板斧第一板斧给策略增加可观测性。不需要每个策略都暴露接口但至少要能在日志里确认“当前生效的策略是哪一个”。我在很多系统里加过一个std::string name() const方法或者typeid(T).name()的包装切换失败时先看日志马上定位是不是策略设置时机的问题。第二板斧把策略的输入输出写成可打印的结构。策略模式调试麻烦因为代码面得很开一个调用要跳好几个文件。所以我会让策略接口的入参和返回值尽量可序列化调试时直接把输入输出dump出来对比预期结果算法问题一眼就能看出来。第三板斧模板策略出问题优先怀疑类型不匹配。模板策略的编译期错误信息往往很长很吓人实际就是某类缺少需要的成员函数或调用签名对不上。在C20里我会用concept约束策略接口template typename T concept LoggerLike requires(T t) { { t.log(std::string{}, std::string{}) } - std::same_asvoid; };这样编译器给出的错误信息会友好得多接口约束也变成了可读的文档。用这个概念约束模板策略比看一屏模板实例化报错舒服太多。我后来把所有模板策略接口都补了concept约束团队协作时基本不会再出现“谁偷偷改了一个接口签名其他人编译报错看不懂”的情况。我个人在实际操作中的体会是策略模式在C里的选择边界从来不是“哪个最正宗”而是“哪个最省事、最可控”。刚开始可以保守一点先写几个lambda试试手感当策略开始有明显状态和多个方法时再升级成类再用模板或variant时把性能约束想清楚。这样设计出来的体系既不会过度设计又能保持足够的扩展弹性。最后分享一个小技巧很多策略在初版里只是一个临时的算法分支可以先不建类直接在Context里用std::function塞lambda跑起来观察稳定后再决定要不要抽成正式策略。反向操作往往很危险——为了效率一开始就铺开继承体系后来发现大多数策略根本不需要类结构反而被框架绑死。策略模式的本质是解耦变化不是制造框架记住这一句你在C里的设计工夫就不会走偏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑