ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++策略模式不止一种写法:从虚函数到std::function与variant

C++策略模式不止一种写法:从虚函数到std::function与variant 搞了这么多年C我最烦的就是一说到策略模式网上全是一模一样的虚函数示例——派生类一堆、基类一个纯虚接口仿佛设计模式就该长成那个样子。策略模式的核心诉求很简单把一组可替换的算法或行为从调用方里抽离出来让它们可以独立变化客户端只依赖一个稳定入口。但“抽离”这件事在C里可远不止一套继承写法。这些年我在实际项目里用过不下五种策略模式的变体有的解决性能问题有的让代码增量几乎为零有的纯粹是补丁打多了之后的无奈选择。这篇文章我把C里几个真正实用的策略模式变体都捋一遍包括经典虚函数方案、std::function函数式方案、模板静态多态、std::variant值语义方案还顺带把容易混淆的状态模式和模板方法拿出来做对比。每个变体我都会贴可直接编译的代码讲清楚它解决了什么问题、牺牲了什么、适合什么场景。如果你是刚接触设计模式、想搞明白“策略模式为什么会有这么多写法”或者是写了几年代码、正在为某个模块到底是“用类还是用函数、用虚函数还是用模板”纠结的朋友这篇文章应该能给你一个比较踏实的参考。1. 经典策略模式教科书方案但不是银弹1.1 原理解读为什么要有策略模式先回顾一下策略模式最原始的定义定义一族算法封装起来让它们可以互相替换算法的变化独立于使用算法的客户端。你不需要知道具体是哪个算法在跑只需要知道“入口”长什么样。这其实就是依赖倒置原则的具象化——高层模块不依赖低层模块两者都依赖抽象。一个最常见的例子是压缩程序。同一个文件根据扩展名、体积、用户偏好可能要选ZIP、GZIP、LZ4或ZSTD也可能是电商系统里的运费计算根据不同快递公司有完全不同的计价公式也可以是支付网关同一套下单流程可能是微信、支付宝、银行卡各自的手续费、回调逻辑都不一样。这些场景的共同点是主干流程稳定局部算法多变。策略模式就是为了让“多变的局部”不污染“稳定的主干”。1.2 教科书式实现的代码拆解经典写法长这样大致是抽象接口加具体实现// 策略接口 class Compressor { public: virtual ~Compressor() default; virtual bool compress(const std::string srcPath, const std::string dstPath) 0; }; // 具体策略A class ZipCompressor : public Compressor { public: bool compress(const std::string srcPath, const std::string dstPath) override { // 调用zip库处理zip格式 return true; } }; // 具体策略B class GzipCompressor : public Compressor { public: bool compress(const std::string srcPath, const std::string dstPath) override { // 调用gzip库处理gzip格式 return true; } }; // 上下文持有策略的入口 class CompressionManager { public: explicit CompressionManager(std::shared_ptrCompressor compressor) : m_compressor(std::move(compressor)) {} void setCompressor(std::shared_ptrCompressor compressor) { m_compressor std::move(compressor); } bool doCompress(const std::string srcPath, const std::string dstPath) { // 前处理检查源文件、准备目标目录等 return m_compressor-compress(srcPath, dstPath); } private: std::shared_ptrCompressor m_compressor; };客户端这样使用void foo() { auto manager CompressionManager(std::make_sharedZipCompressor()); manager.doCompress(file.txt, file.zip); // 运行中切换策略 manager.setCompressor(std::make_sharedGzipCompressor()); manager.doCompress(file.txt, file.gz); }这套逻辑很好理解CompressionManager只与Compressor抽象接口打交道新增一个算法只需要写一个新类实现该接口。这也是运行时切换策略的标准方式动态多态能力来自虚表。很多人把这种实现视为“标准答案”因为教科书就这么教的源码里也到处都是这种结构。1.3 这种方案的真实痛点实际项目里用一段时间你就会发现一套组合拳打下来有几个问题。第一是侵入性。你的策略必须继承自指定接口。如果想把第三方库里现成的某个函数变成策略就必须包一层适配类否则塞不进去。这种包装代码写起来很烦维护起来更烦类型一多全是“胶水类”。第二是类的数量膨胀。每加一个算法就是新建一个类。算法如果只是个十几行的函数为它单独开一个类文件显得非常浮肿。我接过不少老项目一个模块策略相关头文件十来个每个头文件里就一个类每个类就实现一个函数看着头皮发麻。第三是动态多态的隐性成本。虚函数调用会影响内联。一个虚函数调用本身就是一次间接跳转几个纳秒的成本但对某些高频调用路径来说这损失躲不掉。其次对象需要维护虚表指针如果你有大量策略对象内存占用也会稍微上升。更麻烦的是调试和可测试性——虚函数在编译期间不知道具体调的是哪个实现你常常得靠调试器看动态类型。我不是说经典方案没价值它在必须运行时可切换、策略数量有限、扩展性要求高的系统里仍然非常稳。只是C没理由只用这一种活法接下来的几种变体各有各的主场。2. 变体一std::function函数式策略2.1 从“类”到“函数”的思路转变用std::function实现策略模式思路很简单策略不一定要是“类”它可以是一个“可调用对象”。std::function能够存储任何可调用的目标——函数指针、仿函数、lambda表达式、std::bind表达式几乎你能想到的一切可调用体。它的底层用的是类型擦除虽然内部也有虚函数调用的开销按实现方式不同可能是虚调用或类似机制但使用体验上比继承轻得多。这个变体最大的改变是大幅度降低了策略的接入门槛。一个普通静态函数、一个lambda、一个仿函数的std::function实例都统一成了“策略”。引入新策略不再是“新建一个类”而可能是“多写一行lambda”。这种非侵入式的特性是经典继承方案无法替代的。2.2 适用场景与典型代码最常见的场景是回调式策略算法不是整体替换而是某个环节的“插口”需要灵活变化。比如解析文件时每解析出一个字段要执行某种处理比如下载任务完成后要执行清理或校验逻辑比如游戏技能释放前的命中判定策略。我用一个实际点的例子配置文件的解码器选择逻辑。根据文件头部魔数决定用哪个解码函数。每个解码函数就是一个独立策略。using DecoderFn std::functionstd::string(const std::string raw); // 策略A普通函数 std::string decodeJson(const std::string raw) { // json解析逻辑 return json parsed; } // 策略Blambda std::string decodeProto(const std::string raw) { // protobuf解析逻辑 return proto parsed; } class ConfigParser { public: void registerDecoder(const std::string magic, DecoderFn fn) { m_decoders[magic] std::move(fn); } bool parse(const std::string raw) { // 读取magic auto it m_decoders.find(raw.substr(0, 4)); if (it ! m_decoders.end()) { std::string result (it-second)(raw); // 后续处理 return true; } return false; } private: std::mapstd::string, DecoderFn m_decoders; }; void setupParser(ConfigParser parser) { parser.registerDecoder(JSON, decodeJson); parser.registerDecoder(PROT, decodeProto); }也可以直接用std::function作为策略成员class SortProcessor { public: explicit SortProcessor(std::functionbool(int, int) comparator) : m_cmp(std::move(comparator)) {} void setComparator(std::functionbool(int, int) cmp) { m_cmp std::move(cmp); } void process(std::vectorint data) { std::sort(data.begin(), data.end(), m_cmp); } private: std::functionbool(int, int) m_cmp; };在这个例子里comparator作为一个策略被注入而比较器本身就是std::sort算法的一个抽象——排序算法是通用的比较策略是变化的。你可以在运行时从“升序”切到“降序”再切到“按绝对值排序”而SortProcessor完全不知道具体规则。2.3 实践心得与重要注意事项std::function变体我从实战角度说几个要点。函数签名要设计得窄而明确。std::function的模板参数就是策略的“接口”这个接口一旦宽了后续每个策略都要迁就它。比如上面解析函数返回std::string如果某些解码器还要返回状态码那你接口就得改成返回一个Result结构体或者用输出参数。我的习惯是策略返回值尽量保持纯值语义避免输出参数否则组合起来很难读。性能要心里有数。std::function内部做类型擦除调用时会多一层间接常用的实现在大多数情况下会被编译器优化得不错但别拿它和直接函数指针硬比极限性能。高频路径上如果每个对象都要做一次擦除调用还是建议回到虚函数或者模板方案。小心捕获lambda的生命周期。std::function拷贝是可复制的但如果你捕获了指针或引用作为策略策略对象的生命周期得由你来管理。比如一个成员函数lambda捕获了this这个std::function被析构时如果lambda还被其他代码持有容易悬垂。我吃过亏的场景是把注册的lambda存进全局注册表之后对象析构了注册表里的std::function还在一调用就崩。解决办法很简单要么持有的对象生命周期由注册中心管理要么策略只捕获值。扩展性上有个隐藏好处策略组合变得很容易。因为std::function只是普通值类型你可以把它放进容器、作为map的value、放进struct甚至可以在运行中实现“策略链”——前一个策略处理完输出传给下一个策略。这种组合自由度继承体系下要拆好几个类才办得到。3. 变体二编译期策略——模板与CRTP的静态多态3.1 动态多态与静态多态的分野到现在为止前面两种变体都是运行时决定策略也就是说策略选择发生在运行期间。可实际工程里还有大量场景是策略在编译期就确定根本不需要运行时切换。例如一个日志库要同时支持输出到终端、文件、网络某个二进制可能只会编译其中一种或几种这种情况下不需要虚函数跳转也不需要std::function擦除。这就是模板和CRTP的用武之地。静态多态的核心思路是策略通过模板参数带入编译器在实例化时完成绑定所有调用都被内联没有虚表没有间接跳转是真正意义上的“零成本抽象”。代价也明确编译器看到什么策略你就用什么策略如果要在运行期动态切换这个变体做不到。3.2 模板策略的具体形式策略作为模板参数是最直观的形式。最经典的C标准库例子就是std::sort的第三个模板参数Compare以及标准容器的分配器Allocator。这俩其实都是策略模式的模板化变体。以std::sort为例看本质// 自定义比较策略 struct CaseInsensitiveCompare { bool operator()(const std::string a, const std::string b) const { return std::lexicographical_compare( a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { return std::tolower(x) std::tolower(y); }); } }; std::vectorstd::string names {b, A, c}; std::sort(names.begin(), names.end(), CaseInsensitiveCompare());这里CaseInsensitiveCompare就是比较策略do compress。// 通用压缩入口 template class CompressionManager { public: explicit CompressionManager(CompressorPolicy policy) : m_policy(std::move(policy)) {}bool doCompress(const std::string src, const std::string dst) { // 前处理逻辑是通用的 return m_policy.compress(src, dst); }private: CompressorPolicy m_policy; };class ZipCompressorPolicy { public: bool compress(const std::string src, const std::string dst) { // zip 逻辑 return true; } };class GzipCompressorPolicy { public: bool compress(const std::string src, const std::string dst) { // gzip 逻辑 return true; } };// 使用示例编译期定死策略 CompressionManager zipManager(ZipCompressorPolicy{}); zipManager.doCompress(file.txt, file.zip);这里CompressionManager不要求策略继承任何基类只要它有compress且签名对就行这就是鸭子类型。CRTP的做法则稍微不同它是反过来的——以派生类作为模板参数传给基类 cpp // CRTP 策略基类基类提供通用逻辑派生类实现具体算法 templatetypename Derived class CompressorBase { public: bool compress(const std::string src, const std::string dst) { return static_castDerived*(this)-compressImpl(src, dst); } // 可以在这里做公共的日志、校验 void log(const std::string msg) { // 公共日志 } }; class ZipCompressor : public CompressorBaseZipCompressor { public: bool compressImpl(const std::string src, const std::string dst) { // zip 逻辑 return true; } }; templatetypename Compressor bool processFile(Compressor c, const std::string src, const std::string dst) { return c.compress(src, dst); } void foo() { ZipCompressor zc; processFile(zc, file.txt, file.zip); }注意CRTP里用static_castDerived*来做下抛转换整个过程在编译期解析无需虚表。如果策略方法想覆盖默认行为也是直接提供和普通模板策略类似。CRTP的优点是策略类可以有自己的类型可以被容器持有在持有具体类型的同时避免了动态多态的开销。3.3 优缺点与适用范围优点相当明确性能最好——所有调用都能被内联没有虚表指针类型安全——策略类型不匹配在编译期就报错不存在运行期类型错误灵活性极高——策略只要符合接口要求即可根本不用继承什么公共基类。缺点也有三块。一是编译期定死没有运行时切换能力。你要么在编译期选好要么就得把这套模板静态策略包装在运行时分发逻辑后面相当于把动态多态又加了一层回来。二是二进制膨胀问题每个不同模板参数组合都会实例化出一份代码组合多了代码体积会明显增加。三是编译时错误信息页模板实例化报错经常是几千行的天书我在项目里遇到模板嵌套太深报错第一件事就是祈祷自己写的是C20以后的要求C20 concept约束能大幅改善这个问题。实际工程中这种变体最适合用在“类型在编译期明确、运行期不需要流动”的场景。典型例子有日志格式策略、加解密算法静态链接哪个算法就哪个、序列化协议策略、比较器、规则引擎里的静态规则集。还有一个很典型的应用是编译期选择的容器适配策略当你在写一个泛型库需要让使用方通过模板参数选择底层容器、排序算法、内存分配方式时这个变体几乎是最优解。4. 变体三std::variant std::visit 值语义策略4.1 为什么想到用variant先承认一个容易被忽视的事实策略模式的经典实现是围绕“指针语义”搭起来的。共享指针、虚函数、堆上分配都是为了给运行时切换腾空间。可现代C的风格越来越偏向值语义拷贝、移动、栈上分配、无堆、constexpr。于是很自然的想法就是——能不能把策略直接变成可枚举的值用std::variant装起来让策略拥有值语义可以而且这可能是C17之后最有现代感的策略变体。std::variant是个可辨识联合体在任意时刻恰好存储其中一种类型。配合std::visit可以安全地对当前存储的类型做访问。它把“策略”从一个多态对象变成了一组封闭的候选类型。4.2 完整代码示例假设一个图片编码器支持PNG、JPG、WebP三种格式。这里编码格式作为策略用std::variant存储#include variant #include iostream struct PngEncoder { void encode(const std::string image) const { std::cout encoding image as PNG std::endl; } }; struct JpgEncoder { void encode(const std::string image) const { std::cout encoding image as JPG std::endl; } }; struct WebpEncoder { void encode(const std::string image) const { std::cout encoding image as WEBP std::endl; } }; // 策略变体union of concrete strategies using EncoderStrategy std::variantPngEncoder, JpgEncoder, WebpEncoder; class ImageProcessor { public: explicit ImageProcessor(EncoderStrategy strategy) : m_strategy(std::move(strategy)) {} void setStrategy(EncoderStrategy strategy) { m_strategy std::move(strategy); } void process(const std::string image) { // 公共前置逻辑读取图片、压缩到临时目录等 std::visit([image](const auto encoder) { encoder.encode(image); }, m_strategy); // 公共后置逻辑统计耗时、上传结果等 } private: EncoderStrategy m_strategy; }; void foo() { ImageProcessor processor(PngEncoder{}); processor.process(photo.png); processor.setStrategy(WebpEncoder{}); processor.process(photo.png); }如果你不喜欢std::visit的lambda里只能做相同的事情可以用重载技巧分派不同逻辑templatetypename... Ts struct Overload : Ts... { using Ts::operator()...; }; templatetypename... Ts Overload(Ts...) - OverloadTs...; // 基于当前策略执行不同的分支 void processByStrategy(const EncoderStrategy s) { std::visit(Overload{ [](const PngEncoder e) { std::cout PNG path: compress level 9\n; }, [](const JpgEncoder e) { std::cout JPG path: quality 85\n; }, [](const WebpEncoder e) { std::cout WEBP path: lossless mode\n; }, }, s); }如果没有std::visit你可能要自己写switch或者if-else来检查index那就会退化成手动的策略分发代码会又长又容易漏。4.3 与传统方案对比传统方案中每个策略是一个类策略集合开放扩展需要新写一个派生类。Variant方案的策略集合封闭所有候选类型在编译期列清楚。这个差别带来一个核心取舍传统方案遵循开闭原则新增策略不需要改已有代码variant方案相反增加新策略一般需要改动所有std::visit的地方但封闭集合的好处是编译器可以帮你检查所有分支是否都已处理。从内存语义看variant方案是值语义。它存储在栈上不需要堆分配对象的拷贝、移动都很自然不需要shared_ptr那套复杂的生命周期管理。从性能看std::visit通常有两种实现方式一种是基于索引的虚调用或跳转表开销和虚函数接近另一种是编译器内联生成if-else链对于类型数量不多的情况可以被优化成近乎直接调用。在我的测试里2~4个候选类型的strategy变体性能基本不输于虚函数方案有时还更好因为省掉了指针解引用。4.4 适用场景与坑适用场景非常清晰当你的策略集合在需求分析阶段就能明确列举、且不太可能频繁增加时variant方案是最优雅的。例如文件格式支持列表、用户角色权限集合、数据类型转换器、网络协议版本处理等。有几个实际坑需要特别说明。第一std::variant不允许存储引用类型。如果你想把策略对象以引用方式放进variant会编译失败必须改用std::reference_wrapper或指针。所以具体策略对象需要是可拷贝构造的拷贝成本要心里有数。第二std::visit的lambda里如果只是“对任何类型做相同处理”用一个泛型lambdaauto即可但如果你想对不同类型的策略做不同处理就得用Overload把那几个lambda并列起来。注意每个lambda参数类型必须严格匹配策略类型不能写类似int的参数去接PngEncoder否则编译器会报告没有匹配的重载函数。第三新增策略类型时要审查所有std::visit调用点漏掉一个就会编译失败。表面看是麻烦实际这是编译期强制你把所有分支处理逻辑都过一遍比运行时漏掉一个分支造成的诡异bug安全得多。我对这套方案的理解是用扩展性换安全性在封闭策略集合中这是非常合算的买卖。5. 边界辨析策略模式与状态模式、模板方法的差别5.1 模式混淆的常见翻车现场写了几年代码的人一定会遇到过把策略模式写成状态模式或者把模板方法当成策略模式来用的情况。这三个模式在结构上有相似之处但意图完全不同一旦混淆后续扩展就会走向深渊。这里拿一个实际翻车案例有个同事实现了一个网络重连器把“连接失败后执行重连策略”写成了策略模式一个ReconnectStrategy基类下有ImmediateReconnect、BackoffReconnect、ManualReconnect。看起来很标准但后来需求变成“重连三次失败后切换到ManualReconnect”他就愣住了——策略需要知道“自己试了几次”还需要“触发另一个策略”这一下就把他的那套策略接口全给炸了。5.2 三者核心意图的辨析策略模式关心的是“怎么算”。同样的输入通过不同算法得到结果算法之间互相替换。上下文只关心结果不关心过程细节。选择哪个策略由使用方决定或由上下文持有者决定。状态模式关心的是“现在处于什么状态这个状态下可以做什么”。状态本身就是上下文的一部分状态的切换通常由状态内部逻辑驱动例如订单从“待付款”到“已支付”是支付动作触发的状态迁移。状态模式中状态对象经常需要持有对上下文的引用以便切换状态。模板方法关心的是“整体骨架固定个别步骤可变”。它通过继承把公共流程放在基类把可变步骤留在基类中的虚函数由子类重写。策略模式是“组合优先”——把策略对象注入上下文模板方法是“继承机制”——子类重写基类步骤。这俩在可扩展性上走向完全不同的方向。拿一个易懂的生活化类比你是店长。策略模式是“结算时选优惠方案”——可以换优惠券、换会员折扣、换满减换哪个不影响收银流程状态模式是“电源状态”——关机、待机、开机三种状态每种状态能做的操作不一样状态之间靠事件迁移模板方法是“泡茶的流程”——烧水、放茶、等待、倒茶是固定骨架但放什么茶由子类决定。5.3 避免误入歧途的检查清单我给自己定了三条检查标准写代码前先过一遍第一看这个“变化点”是否需要运行期切换如果不需要优先考虑模板静态策略如果需要再看切换频率——频繁切换用虚函数或std::function低频切换可以用variant不切换但希望代码结构清晰可以模板策略直接绑定。第二看策略是否需要了解“其他策略”或“调用者的状态”如果策略需要触发上下文切换到另一个状态那大概率不是策略模式而是状态模式。策略算法相对独立状态对象之间则存在转移关系。第三看公共流程是否被“复用”了多次如果公共流程在整个生命周期里几乎不变你只是想让子类某个小步骤变化那可能是模板方法更合适。如果公共流程可以被抽成一个独立的上下文类用注入的方式接收不同行为那就走策略模式的路线。判断错没关系判断对了省下的返工量是真金白银。6. 工程选型这些变体到底怎么选6.1 先回答五个问题再选型说实话每次看到新人为了“策略模式写成虚函数还是std::function”纠结半天我都觉得方向搞反了。选型不该从“哪个模式像教科书”出发而该从需求维度出发。我习惯先问五个问题第一策略在运行期是否需要切换如果能确定编译期锁死选模板静态策略零开销且代码清晰。如果运行期必须切换就直接排除模板方案。第二策略集合是开放还是封闭开放经常加新算法优先经典虚函数或std::function因为增加新策略不需要改动调用方封闭就这么几种且短期内不会变直接影响variant方案让编译器帮你穷尽分支。第三策略的实现形态是什么是一个类还是一组自由函数或lambda是类虚函数和CRTP都行是函数std::function是最顺手的容器。第四性能敏感度有多高如果策略调用在非常热的路径上每多一次间接可能都很痛这时候静态模板方案是唯一答案。热路径且必须运行时切换可以考虑函数指针或带有限候选的variant避免std::function的擦除开销。第五代码可调试性和团队可维护性如何取舍虚函数方案调试直观但类多variant方案编译期检查全面但新增类型要改所有visit点std::function方案灵活但类型被擦除后错误信息往往不直观。6.2 变体对比速查表维度经典虚函数std::function模板/CRTPstd::variant策略形态继承自接口的类任意可调用对象满足接口的任意类型封闭类型集合运行时切换支持支持不支持支持切换variant内容扩展性好开闭原则友好好非侵入编译期扩展好但类型封闭封闭新增要改所有visit性能虚函数调用类型擦除一次间接最优可内联接近虚函数视实现而定调试难度直观类型被擦除略有晦涩报错信息可能难读状态明确但visit调用点多典型场景策略数量多、频繁增删回调注册、命令注册泛型库、性能敏感固定格式集合、协议处理6.3 真实项目里的选型经验我这个比较直接的体会是从一个文件解析中间件项目里悟出来的。最初用经典虚函数方案十几个格式解析器全是继承一个Parser接口。后来产品定了方向支持格式只保留四种而且未来两三年都看不到新增的需求。我第一反应是改成std::function把每个解析器从类改成函数。但改到一半发现误打误撞接触到了variant方案最后把ParserStrategy定义成std::variantRfcParser, JsonParser, XmlParser, CsvParser。改动量其实不大但之后所有解析入口的编译期检查都变得特别舒服——每加一种格式编译会追着让你把所有switch/visit点处理一遍再不用担心哪条分支漏掉。这个案例基本奠定了我对value-semantic和编译期穷尽检查的好感。另一个反面案例是一个交易系统里的风控模块。当时图省事把“是否允许交易”“调整交易额度”这组动作全部塞进了std::function。运行期发现某些策略之间需要互相调用比如“订单大小策略”要读取“用户等级策略”的结果。std::function可以解决问题但你要额外维护一个依赖上下文把用户等级策略作为函数参数传进另一个lambda里很快这个模块就变成了一团浆糊。最后我把这些互相依赖的策略重新梳理成状态模式加策略模式混合——用户等级是一个状态根据状态选择的风控计算是一个策略。模式的边界一旦清晰代码量虽然没少但维护的人终于找得到脑子在哪了。6.4 常见问题与排查技巧实录写策略模式变体这几年整理了出现频率最高的一批坑第一类继承方案里忘了虚析构函数。这是C老生常谈但永远在犯。策略基类如果没有虚析构delete派生类指针时行为未定义进程崩溃或内存泄漏都来了。我现在的习惯是策略接口如果是为了多态而存在析构函数必须写成virtual或者protected新代码我更喜欢用std::unique_ptr存策略配合纯虚接口时把析构设为default。第二类std::function捕获悬垂。前面提过lambda捕获this或引用时生命周期由外部管理。特别是把std::function放进容器作为“注册表”使用的模块例如事件分发器、插件系统特别容易踩。我在项目里见过一个极隐蔽的bug一个策略std::function捕获了一个临时字符串lambda在临时对象析构后才被调用结果字符串内容变成乱码排查了两天。第三类variant方案漏掉新增类型导致编译失败好多新人不理解为什么“加一个类型就编译不过”觉得是模板系统在针对自己。实际上这是好事所有std::visit调用点必须匹配候选类型漏了编译器能查出来。这时候耐心一点逐个visit点把它处理掉不要用auto泛型分支把所有类型都接到同一个分支里——除非你确定要对所有类型一视同仁。第四类模板策略方案里策略对象有状态时需要小心拷贝。静态多态没有虚表但有成员变量。比如一个带计数器的策略它被拷贝进上下文后后续对策略的修改不会反映到外部原对象上。用CRTP时尤其注意Derived对象和base里持有的策略状态容易被混淆。你要明确策略状态属于“谁”存放位置必须统一。第五类把std::visit当万能钥匙滥用导致代码复杂度爆炸。如果你的std::visit分支里每种类型就一个分支且代码量很大那多个visit点会迅速膨胀。此时大概率是你把一个动态分派场景硬改成了静态分派。遇到这种情况我更推荐回到模板策略或者经典虚函数至少不用维护N个visit点。最后的实践建议这些变体我都真刀真枪用过。最初几年我死磕经典虚函数方案后来遇到性能瓶颈才去研究模板和CRTP再后来C17普及std::variant方案在条件合适时把代码简洁度拉高了不止一个档次。我最深的感受是设计模式的“正统”在C里从来不是一道单选。策略模式不是一套固定的代码模板而是一种思维框架——你要抽离的是“变化点”至于用什么机制把这个变化点缝起来取决于你的真实需求。如果你现在正面对一个可以用策略模式的场景我建议按这个顺序走一遍先想清楚运行期切换需求再定义清楚策略的开放封闭边界然后评估性能路径最后才动手写代码。我在实际工作中最喜欢的一个组合是把策略集合定义成std::variant封闭起来如果未来真有新增需求再考虑把variant替换成接口加虚函数——这个改造通常不会伤筋动骨但前期省下的维护成本是真真切切的。最后再分享一个小技巧无论选哪个变体尽量把策略的构造、注入与调用现场分开策略的入口集中在初始化阶段调用阶段只通过统一的上下文接口触发这样将来换变体时改动面永远只有中间层那一小块核心业务代码一行都不用动。
返回列表