恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++策略模式高级工程实践:从NVI到CRTP的架构演化与性能优化
首页
资讯中心
/
C++策略模式高级工程实践:从NVI到CRTP的架构演化与性能优化
C++策略模式高级工程实践:从NVI到CRTP的架构演化与性能优化
发布时间:2026/10/13 5:10:55
如果只能选一个设计模式讲给一个写过半年C的开发者我会选策略模式。这句话我每次带新人时都会说因为策略模式解决的运行时换算法问题几乎是所有业务系统都逃不掉的坎。但大多数教程里讲策略模式翻来覆去就是三个类、一个接口、一个上下文然后就是这样就能在运行时切换算法了——看得懂也用得上可一旦放到真实项目里你会发现教科书版本根本撑不住。早些年我在一个跨平台的图像处理组件里重构图像压缩模块那个模块最初的架构就是教科书式的策略模式一个抽象基类、三个具体策略、一个上下文类。功能上确实能跑但每加一种压缩格式就要动上下文代码而且策略对象和业务逻辑的耦合到处都是。后来重构完我意识到策略模式真正的高级用法不是把if-else替换成虚函数调用而是把变化点和稳定点彻底隔离再配合C的编译期能力、生命周期管理、工厂注册机制把策略模式做成一套可以插拔的生态。这篇文章就把我在那次重构里用到的思路和代码拆开来讲。1. 绕不开的运行时换算法策略模式到底在解决什么问题1.1 一个老朋友的基本形态先回到最朴素的策略模式定义。策略模式的核心意图是定义一系列算法把每个算法封装起来并且使它们可以互相替换。这里的关键词不是替换是封装的粒度。一个典型的教科书实现长这样// 所有策略的统一接口 class ICompressor { public: virtual ~ICompressor() default; virtual std::vectoruint8_t compress(const std::vectoruint8_t raw) 0; }; // 具体策略AZIP压缩 class ZipCompressor : public ICompressor { public: std::vectoruint8_t compress(const std::vectoruint8_t raw) override { // 具体zip实现 return {}; } }; // 具体策略BLZ4压缩 class LZ4Compressor : public ICompressor { public: std::vectoruint8_t compress(const std::vectoruint8_t raw) override { // 具体lz4实现 return {}; } }; // 上下文类 class ImageProcessor { public: explicit ImageProcessor(std::unique_ptrICompressor comp) : compressor_(std::move(comp)) {} void setCompressor(std::unique_ptrICompressor comp) { compressor_ std::move(comp); } std::vectoruint8_t process(const std::vectoruint8_t raw) { // 做预处理... auto result compressor_-compress(raw); // 做后处理... return result; } private: std::unique_ptrICompressor compressor_; };这个版本最大的优势在于ImageProcessor不用关心压缩算法具体怎么实现它只依赖ICompressor这个抽象接口。调用方可以在运行时通过setCompressor任意切换压缩算法。这个结构本身没问题问题出在往真实项目里塞的时候。1.2 为什么教科书版本撑不住真实项目真实项目的第一个冲击来自参数差异。ZIP压缩需要配置压缩级别LZ4需要关心是否开启HC模式还有不同算法对内存缓冲区的偏好完全不同。如果你严格按照上面的接口设计compress只有一个参数raw那么这些参数差异会被活活压进具体策略的内部调用方根本无从控制。更要命的是有些策略是有状态的——比如做增量压缩、流式压缩压缩器内部要维护上一次的滑动窗口状态这时候ICompressor的生命周期管理和状态重置就成了大问题。第二个冲击来自策略组合。真实场景里很少有只用一种压缩算法的纯情需求更多的是图片质量优先时走A算法速度优先时走B算法均衡场景走C算法。教科书模式把策略选型完全交给调用方结果就是创建策略对象的逻辑散落在各处一旦新增算法还是要满项目找new XxxCompressor的调用点。第三个冲击是C特有的纯虚基类带来的虚函数开销和内存布局碎片化。实时音视频场景里每一帧图像要在几毫秒内完成压缩虚函数调用本身虽然只有几纳秒开销但虚函数背后的间接跳转会破坏分支预测而且策略对象散落在堆上缓存局部性很差。所以真正的高级应用不是问策略模式怎么实现而是问策略模式怎么在一套完整架构里活下来。2. NVI把怎么调用和怎么实现彻底拆开2.1 NVI的结构长什么样Non-Virtual InterfaceNVI是一个经常被误解的技巧。它把接口类里的虚函数设成private对外暴露的public接口是非虚的由基类统一控制调用流程。这样做的核心收益是基类可以在调用虚函数前后插入统一的逻辑比如加锁、计时、参数校验、日志埋点。策略模式结合NVI后策略接口变得更健壮因为调用策略的骨架被基类锁死了派生类只能实现算法细节。class ICompressor { public: virtual ~ICompressor() default; // 对外非虚接口 std::vectoruint8_t compress(const std::vectoruint8_t raw) { if (raw.empty()) { return {}; } // 统一计时 auto start std::chrono::steady_clock::now(); auto result doCompress(raw); auto end std::chrono::steady_clock::now(); last_cost_us_ std::chrono::duration_caststd::chrono::microseconds(end - start).count(); return result; } long long lastCostUs() const { return last_cost_us_; } protected: virtual std::vectoruint8_t doCompress(const std::vectoruint8_t raw) 0; private: long long last_cost_us_ 0; };注意看compress非虚接口里做了空入参校验和耗时统计派生类不需要关心这些横切逻辑。这比让每个派生类自己在doCompress里加计时逻辑要优雅得多——至少不会出现三个策略各自计时、统计口径不一致的情况。2.2 用NVI版本重构支付策略例子我重构图像压缩模块时把NVI的思路带到了策略接口设计里。当时有个场景是不同压缩策略需要不同大小的缓冲区而且策略内部需要暴露推荐的输入块大小给上层做分块。于是我把接口改成了这样class BlockCompressor { public: virtual ~BlockCompressor() default; // 非虚接口统一处理分块逻辑 std::vectoruint8_t compressStream( const uint8_t* data, size_t size, size_t block_size) { std::vectoruint8_t output; size_t offset 0; while (offset size) { size_t cur std::min(block_size, size - offset); auto block doCompressBlock(data offset, cur); // 统一在block头部写入长度信息 uint32_t len static_castuint32_t(block.size()); output.insert(output.end(), reinterpret_castuint8_t*(len), reinterpret_castuint8_t*(len) sizeof(len)); output.insert(output.end(), block.begin(), block.end()); offset cur; } return output; } protected: virtual std::vectoruint8_t doCompressBlock(const uint8_t* data, size_t size) 0; };这个设计的好处非常直接分块、写长度前缀这些通用流程被基类牢牢控制住派生类只需要关心怎么压缩一块数据。如果后续要加入新的分块策略或者改变长度字段的字节序只需要改基类一处所有策略同时生效。我在实际重构中发现这样的设计至少减少了30%的重复代码。2.3 NVI策略的边界和陷阱NVI也有翻车的时候。第一个陷阱是过度统一。如果你把流程骨架定得太死某些特殊策略就没办法插进来了。比如我遇到过一种加密压缩器它需要在每个块的开头预留4字节的salt而我的基类统一写的长度前缀逻辑跟salt挤在一起最后只能为这个策略单独写一个override版本NVI的统一骨架就名存实亡了。第二个陷阱是NVI接口的非虚函数本身不可扩展。compressStream里的块大小如果必须要根据策略内部状态自适应调整那这个骨架逻辑就会变成所有策略的阻碍。我的建议是NVI适合那些流程稳定但算法多变的场景一旦流程本身也是变化点就别强行套NVI。第三个陷阱涉及性能。NVI在调用链上多了一层非虚函数编译器的内联通常能消除这层开销但如果你的策略类图很深基类构造函数里做了重量级初始化那派生类构造时的成本会被放大。这个点等讲到性能排查时再细说。3. 策略模式的高级应用一结合CRTP实现编译期策略分发3.1 从虚函数表到模板静态多态的取舍运行时策略模式面对的是程序运行中动态切换算法。但很多场景里算法选型在编译期就确定了——比如针对不同硬件平台选择不同的内存拷贝策略、针对不同编译器选择不同的整数溢出处理策略。这时候再用虚函数就是杀鸡用牛刀。CRTPCuriously Recurring Template Pattern奇异递归模板模式提供了一种编译期多态的解法。CRTP的核心思路是基类模板通过派生类类型来调用派生类实现的方法把虚函数调用替换成编译期的静态绑定。这里有个关键点策略方法本身不是虚的但基类的模板接口会在编译期依据派生类型解析出正确调用。来看一个典型的实现template typename Derived class BaseCompressor { public: std::vectoruint8_t compress(const std::vectoruint8_t raw) { return static_castDerived*(this)-doCompress(raw); } }; class FastCompressor : public BaseCompressorFastCompressor { public: std::vectoruint8_t doCompress(const std::vectoruint8_t raw) { // 快速压缩实现 return {}; } }; class SlowCompressor : public BaseCompressorSlowCompressor { public: std::vectoruint8_t doCompress(const std::vectoruint8_t raw) { // 高压缩比实现 return {}; } };调用时模板函数会在编译期就知道Derived是FastCompressor还是SlowCompressor所以直接内联展开doCompress调用不会出现虚函数跳转。在性能敏感的内层循环里这个收益非常显著。我做过一个图像缩放模块用CRTP替换虚函数策略后单帧处理耗时下降了约15%——主要来自分支预测命中率的提升。3.2 回调风格与策略体的组合CRTP策略模式的一个进阶玩法是把它跟回调组合。假设我要设计一个图像处理管线管线里包含多个处理步骤缩放、色彩空间转换、滤波。每个步骤都可以看成一种策略但这些策略是串行组合的。用CRTP可以这样建模template typename Impl class Stage { public: void process(Frame frame) { static_castImpl*(this)-apply(frame); } }; class ScaleStage : public StageScaleStage { public: void apply(Frame frame) { // 缩放实现 } }; class FilterStage : public StageFilterStage { public: void apply(Frame frame) { // 滤波实现 } }; template typename... Stages class Pipeline { public: void run(Frame frame) { (void)std::initializer_listint{(processStageStages(frame), 0)...}; } private: template typename S void processStage(Frame frame) { S stage; stage.process(frame); } }; // 编译期确定管线 using MyPipeline PipelineScaleStage, FilterStage;这里每个Stage都是无状态的、在栈上构造的管线运行时不会有任何堆分配。相比运行时vectorIStage*、依次调虚函数的方案这个版本更底层、更快。代价是管线组合在编译期固定不能运行时动态调整。但很多嵌入式图像处理场景恰恰需要这种编译期确定、运行期零开销的策略组合方式。3.3 编译期策略与运行期策略的混用第三种玩法是混合策略。有些策略维度需要编译期固定有些则需要运行期切换。我遇到的实际场景是图像编解码器需要支持多种颜色格式RGB、YUV420、YUV422这些可以在编译期定死但压缩级别快速、均衡、高质量需要用户在运行时通过配置切换。于是我把两种模式混在一起用// 编译期确定颜色格式策略 template typename ColorFormat class ColorConverter { public: void convert(const Frame src, Frame dst) { ColorFormat::convert(src, dst); } }; struct RGBFormat { static void convert(const Frame src, Frame dst) { /* ... */ } }; struct YUV420Format { static void convert(const Frame src, Frame dst) { /* ... */ } }; // 运行期选择压缩级别策略 class CompressionLevel { public: virtual void configure() 0; virtual ~CompressionLevel() default; }; class FastLevel : public CompressionLevel { public: void configure() override { /* 设置快速模式参数 */ } }; class HighQualityLevel : public CompressionLevel { public: void configure() override { /* 设置高质量模式参数 */ } }; // 总装配颜色格式编译期固定压缩级别运行期可换 template typename ColorFormat class Encoder { public: void setLevel(std::unique_ptrCompressionLevel level) { level_ std::move(level); level_-configure(); } void encode(const Frame src, std::vectoruint8_t out) { ColorFormat::convert(src, tmp_); // 使用当前level_做压缩 } private: Frame tmp_; std::unique_ptrCompressionLevel level_; }; using RGBEncoder EncoderRGBFormat; using YUVEncoder EncoderYUV420Format;这种混合模式的高明之处在于把哪些维度需要运行期变化想清楚了让编译期和运行期的分界线恰好落在真正的变化点上。颜色格式在业务里基本不会动态切换而压缩级别用户随时可能拉一下进度条。这种思考方式比单纯套用模板或虚函数都更重要。4. 策略模式的高级应用二策略上下文与工厂组合的落地范式4.1 从裸new到注册表工厂光有策略模式你还是需要一个怎么创建策略的入口。最常见的低级写法是在上层代码里到处写new ZipCompressor()一旦加一个策略所有调用点都得跟着改。更好的做法是引入注册表工厂把策略的创建集中管理同时支持通过字符串或枚举在运行时取策略。class CompressorFactory { public: using Creator std::functionstd::unique_ptrICompressor(); static CompressorFactory instance() { static CompressorFactory factory; return factory; } void registerCreator(const std::string name, Creator creator) { registry_[name] std::move(creator); } std::unique_ptrICompressor create(const std::string name) { auto it registry_.find(name); if (it registry_.end()) { throw std::runtime_error(unknown compressor: name); } return it-second(); } private: std::unordered_mapstd::string, Creator registry_; }; // 在某个初始化函数里注册 void registerCompressors() { CompressorFactory::instance().registerCreator(zip, [] { return std::make_uniqueZipCompressor(); }); CompressorFactory::instance().registerCreator(lz4, [] { return std::make_uniqueLZ4Compressor(); }); } // 使用 auto comp CompressorFactory::instance().create(lz4);这种注册表模式的威力在于新增压缩算法时不需要改动任何已有调用点只需要新写一个策略类再在注册表里加一行。配合静态初始化注册或者插件式注册可以实现运行时加载新算法。我在重构压测量模块时把原有六种压缩算法的创建全部迁到了这个工厂里删掉了大量散落的new调用。4.2 组合、初始化顺序和生命周期管理工厂模式引入后紧接着的问题是工厂本身什么时候初始化策略对象由谁管理上下文类持有什么样的策略对象这些问题看似琐碎但一旦做错轻则内存泄漏重则程序崩溃。先说生命周期。我的建议是上下文持有unique_ptr 因为策略对象通常只属于某一个处理管线如果有多个管线共享同一个策略那就用shared_ptr并且要明确读写并发的问题这点后面会提。再说初始化顺序。注册表工厂如果用静态注册即某个静态对象构造时自动调用registerCreator要留意跨编译单元的初始化顺序不确定问题。更稳妥的办法是在main函数早期调用一个显式的注册函数或者在工厂内部用局部静态变量延迟注册。还有一个细节容易踩坑策略对象的创建成本和初始化成本可能不一样。我见过LZ4Compressor的构造函数本来很轻但有人在构造函数里预分配了16MB的缓冲区结果一创建就卡顿。针对这种情况我会在工厂注册时允许传参把创建策略和初始化策略分开class CompressorFactory { public: std::unique_ptrICompressor create(const std::string name, const std::mapstd::string, std::string params) { auto it registry_.find(name); if (it registry_.end()) throw std::runtime_error(unknown); return it-second(params); } };这样每个策略可以根据params里的配置决定初始化多少缓冲区而不是一上来就吃满内存。4.3 依赖注入和测试替换策略模式配合工厂的另一个大优势是它天然支持依赖注入也就天然支持测试替换。当我在测试图像压缩模块时我可以注入一个空压缩策略它直接返回原数据以此来单独测试上层管线逻辑。这种测试替换在真实项目里百试百灵。class NoOpCompressor : public ICompressor { public: std::vectoruint8_t doCompress(const std::vectoruint8_t raw) override { return raw; // 原样返回 } }; // 测试代码 TEST(ImageProcessorTest, TestPipeline) { auto processor std::make_uniqueImageProcessor(std::make_uniqueNoOpCompressor()); std::vectoruint8_t input {1, 2, 3}; auto output processor-process(input); EXPECT_EQ(output, input); }也可以把NoOpCompressor注册成工厂里的noop类型测试环境里的配置文件直接指定noop这样整个系统都不需要改代码就能在测试模式下运行。这个模式在我做的另一个网络协议栈项目里也用过效果非常好。5. 策略模式的常见误区和性能排查5.1 策略模式与状态机的边界策略模式最容易被混淆的是与State模式的区别。策略模式是把算法封装成对象让调用方决定用哪个算法状态模式是让对象根据内部状态自动改变行为具体执行哪个分支对调用方来说是透明的。混用之后最容易出现的问题是一边在策略对象内部维护着一大堆状态机一边又试图在上下文里面切换策略两面下注结果谁也说不清当前到底是什么行为。我自己的经验法则是如果切换行为的触发条件来自外部用策略模式如果触发条件来自对象内部状态流转用状态模式。混合场景下策略对象内部可以再套状态模式但上下文的职责要非常纯粹——只负责持有和切换策略。5.2 虚函数开销到底有多大很多文章说虚函数开销可以忽略但这个结论对每帧调用几十万次的场景不成立。虚函数调用的开销主要由两部分组成一是间接跳转破坏CPU分支预测二是不容易内联导致错过很多编译优化机会。在策略模式重度使用的路径上比如图像像素处理循环里虚函数调用会被放大几倍。CRTP作为编译期多态可以消除这个开销但它不是银弹。CRTP策略模板的实例化会让调试信息爆炸并且代码生成的体积可能增大。我做过一个实验把20多个策略全部改成CRTP后编译后的二进制体积增加了接近10%。如果平台有体积限制需要在编译期多态和代码膨胀之间做权衡。5.3 小心策略对象的线程安全最后一个常见的坑是线程安全。我发现很多团队在策略模式落地时默认策略是无状态的、可并发调用的。但真实做压缩策略时为了性能策略对象内部往往会缓存中间缓冲区此时它就不是线程安全得了。如果两个线程共用同一个Compressor实例并发compress就会出现数据竞争。几个可行的处理方案按复杂度递增排序每次创建新的策略实例用完即丢简单但浪费。每个线程持有自己的策略实例ThreadLocal用线程局部存储管理。整个策略对象串行化用互斥量保护compress调用简单但损失并发性能。把策略拆成无状态的算法核心和有状态的会话对象算法核心可以共享会话对象每次创建。我在实际项目中常用方案四是状态会话模式。ICompressor只包含纯算法逻辑所有可变缓冲区放进单独的Session对象里在compressStream调用时创建。这样既保持了策略共享又避免了竞争。6. 最后分享一点我的实操心得策略模式的高级应用做多了你会发现设计模式的边界不是看UML图而是看代码变更频率。哪个维度变化最频繁就应该把哪个维度做成策略。C里做策略有两把尺子需要在运行期变化的交给虚函数或NVI变化点在编译期就确定的交给模板或CRTP。工厂注册表解决创建方式多变的问题状态会话解决策略有状态但要多线程安全的问题。如果让我总结一条最重要的经验别让策略模式成为你的唯一手段。真实项目里你需要同时管理多个维度可能有策略维度、状态维度、装饰维度正确做法是让每个模式各管一段而不是把八个模式塞进一个类结构里绕成一个毛线球。还有一句老生常谈但确实是从踩坑中得来的策略接口参数一旦定了后面想加参数就是灾难所以设计策略接口时宁可参数复杂一点、粒度细一点也别先省着后面再补。这行当里改接口的代价永远是写接口的好几倍。