接手一个老项目时我在一个导出模块里看到了让我后背发凉的代码一个函数两百多行里面挂着超过十二个独立分支分别处理不同格式、不同压缩方式、不同落盘路径所有分支用if (format ...)串联在一起。新需求来了大家的第一反应都是再补一个 if没人敢动旧分支因为动一个旧分支就意味着要把所有格式回归一遍。后来我花了一个周末把整个导出链路用策略模式重排了一遍导出扩展成本从改一个函数变成新增一个文件、注册一条规则。这篇博文就聊聊我当时做重构的思路、踩过的坑以及几种 C 策略模式写法的适用边界适合想系统性改进业务代码结构的读者尤其是项目里已经出现大量switch/if判断、但还没想清楚怎么下手的人。1. 一个导出模块的失控现场为什么你的代码会一步步长成巨型条件判断1.1 失控现场十二个 if 支撑的导出逻辑先还原一下我遇到的真实情况。模块的核心是一个OrderExporter早期需求很简单把订单数据写成 CSV 文件。接口长这样class OrderExporter { public: bool Export(const std::vectorOrderRecord orders, const std::string path); };需求一多接口开始膨胀先加了 JSON 格式因为前端需要然后加了 XML因为某合作方对接要用再后来加了压缩选项有人要 gzip有人要原始文件最后加了不同上报通道落本地磁盘、传对象存储、发到消息队列……每次新增都往同一个函数里塞入参数和分支bool OrderExporter::Export(const std::vectorOrderRecord orders, const std::string path, const std::string format, const std::string compression, const std::string sink) { std::string body; if (format json) { body ToJson(orders); } else if (format xml) { body ToXml(orders); } else if (format csv) { body ToCsv(orders); } else if (...) { /* more formats */ } if (compression gzip) { body CompressGzip(body); } else if (compression zstd) { body CompressZstd(body); } else if (compression none) { // no-op } if (sink file) { WriteToFile(path, body); } else if (sink oss) { UploadToOss(path, body); } else if (sink kafka) { PublishToKafka(path, body); } return true; }这种代码在业务系统里太常见了。每次加需求你确实只需要加一个分支看起来很划算。但隐患藏在三个地方第一所有策略代码共享同一个函数作用域局部变量互相干扰。我见过某个分支为了处理某种特殊编码直接修改了上层传入的body导致后续压缩逻辑在其它格式下表现异常。第二分支判断一旦超过十个合并冲突概率骤增。多人并行开发时每个人都在同一个if / else if链上追加新分支git merge时动不动就冲突。第三路径覆盖永远测不齐。格式 x 压缩 y 目标 z 的组合是指数级的靠测试用例把所有组合覆盖一遍几乎不可能。1.2 失控带来的三个核心痛点开闭、可组合性、可测性我把失控的根因归纳成三个问题这三个问题的答案直接指向策略模式。开闭原则问题。新格式、新压缩算法、新目标通道本质上都是新的变化维度。如果每个变化维度都落在同一个函数内部函数就没有闭合的时候——它必须持续修改才能支持新需求。而策略模式把每个变化维度封装成一个独立对象新需求加对象不改旧对象这就是对扩展开放、对修改关闭。可组合性问题。导出任务其实是格式化 - 压缩 - 输出三步流水线每个环节是一族算法。如果不做策略化组合关系只能通过嵌套 if 表达。策略化之后我可以分别持有三种策略对象——格式策略、压缩策略、输出策略——在运行时自由组合甚至可以按订单内容动态决定压缩策略。组合能力一旦打开系统就不再是固定的三段式而是可以编排的组装件。可测性问题。策略化之前的测试非常笨拙要测 JSON 格式逻辑必须把整个Export函数跑一遍还得给它一个真实的文件路径。策略化之后FormatStrategy是独立对象输入一批订单、输出一个字符串纯函数式的测试连文件系统都不需要。后面我会专门讲测试这块。2. 三种落地写法辨析虚函数接口、std::function 与模板策略确定要上策略模式之后第一个要回答的问题是在 C 里用什么机制实现多态分派。我实际项目里见到过三种主流写法它们的适用场景差异很大。2.1 写法 A经典接口 虚函数大多数场景的首选这是最教科书、也最稳妥的写法定义抽象基类纯虚函数暴露策略行为派生类各实现一份上下文对象持有一个基类指针或引用。class FormatStrategy { public: virtual ~FormatStrategy() default; virtual std::string Format(const std::vectorOrderRecord orders) const 0; virtual std::string Extension() const 0; }; class JsonFormatStrategy final : public FormatStrategy { public: std::string Format(const std::vectorOrderRecord orders) const override { // 序列化为 JSON } std::string Extension() const override { return .json; } };选择它的理由有三点。一是语义清晰接口契约白纸黑字写在虚函数签名里IDE 补全、代码阅读都很友好。二是生命周期可控基类指针可以是原生指针、unique_ptr或shared_ptr所有权关系一目了然。三是便于扩展任何派生类只需要实现接口不需要感知其它策略的存在。实际项目中绝大多数业务策略场景格式、编解码、排序规则、支付渠道等都适合这个写法。它的缺点是固定了运行时多态每个虚调用有一次跳转开销而且类型信息在编译期丢失。2.2 写法 Bstd::function 轻量策略适合分支很小、状态很轻的场景有些策略非常薄——不需要成员变量不需要复杂生命周期只需要一个行为。这种场景下为每个策略写一个类显得杀鸡用牛刀。我用std::function直接把策略表做出来using FormatHandler std::functionstd::string(const std::vectorOrderRecord); class OrderExporter { std::unordered_mapstd::string, FormatHandler formatters_; public: void RegisterFormatter(std::string name, FormatHandler handler) { formatters_.emplace(std::move(name), std::move(handler)); } std::string Export(const std::vectorOrderRecord orders, const std::string format) { auto it formatters_.find(format); if (it formatters_.end()) { throw std::runtime_error(unsupported format); } return it-second(orders); } };这个写法的最大好处是注册行为非常灵活可以注册一个普通函数、一个 lambda、一个绑定表达式甚至是一个捕获了外部状态的闭包。对于那种每种格式就是一行ToXxx(orders)的场景代码量比写五个派生类少得多。但它也有代价。std::function本身有类型擦除开销每次调用多一次间接跳转更重要的是一旦 lambda 捕获了对象引用策略的生命周期就跟捕获对象绑在一起容易出现悬空引用。我的经验是策略行为只在少量几行内、逻辑简单到不需要单独测试时才用这个写法一旦策略开始有内部状态、需要复杂初始化就老老实实回到写法 A。2.3 写法 C模板策略编译期绑定运行时不可变第三种写法用于程序启动时策略就已经确定、运行期间绝不变更的场景。模板参数直接携带策略类型完全去掉虚函数开销template typename Formatter, typename Compressor class Exporter { public: std::string Run(const std::vectorOrderRecord orders) { std::string body Formatter::Format(orders); return Compressor::Compress(std::move(body)); } }; struct JsonFormatter { static std::string Format(const std::vectorOrderRecord orders); }; struct GzipCompressor { static std::string Compress(std::string body); }; using JsonGzipExporter ExporterJsonFormatter, GzipCompressor;这是典型的零开销抽象所有分派在编译期完成没有虚表、没有跳转甚至可以在Formatter::Format上做内联。代价是类型被编译期固定不能在运行时根据配置切换组合。如果你的系统里策略组合是写在配置文件里的、启动后不变那么模板策略会让性能最好——这个我们在第 5 节再展开。2.4 选型对照什么时候用哪种观察维度虚函数接口std::function模板策略运行时可切换支持支持不支持状态管理各派生类自带靠捕获或外部对象一般静态无状态编译期内联优化不可用不可用可用代码组织复杂度每策略一个类轻量注册类型嵌套可读性差适用规模复杂业务策略简单分支规则高性能、场景固定我在业务代码里大约七成用虚函数接口两成用std::function最后一成给真正的热路径用模板策略。后面讲的实战重构案例混合使用了第一种和第二种。3. 实战重构一个可扩展的订单导出服务带压缩和审计3.1 业务需求拆解不只换格式还有组装链路重构第一步不是写代码而是把需求拆成可以独立变化的维度。当时导出服务面临三类变化格式维度JSON、XML、CSV、Parquet后加的压缩维度不压缩、gzip、zstd输出维度本地文件、对象存储、消息队列另外还有两个非功能性要求所有导出行为必须记录审计日志方便追溯谁在什么时间导出了哪批数据压缩失败时外层要能稳定拿到错误信息不能崩进程。基于这些需求我的设计思路是把格式化 - 压缩 - 输出构造成一条策略链链上的每个节点都是独立策略对象上下文对象只负责把订单数据传进去、把最终结果接出来工厂函数负责根据配置把链组装好。审计日志不单独做策略而是作为策略链的装饰器存在因为它是横切关注点几乎每个策略执行都要记日志。3.2 定义策略接口与上下文对象我没有急着写FormatStrategy一个接口到底而是把三步拆成三个独立的策略接口。为什么拆因为这些策略的扩展频率完全不同而且后续还要独立测试。struct OrderRecord { std::string order_id; double amount; std::string currency; int64_t timestamp; }; // 侧重纯字符串转换便于单元测试 class IFormatter { public: virtual ~IFormatter() default; virtual std::string Format(const std::vectorOrderRecord records) const 0; virtual std::string Extension() const 0; }; // 输入输出都是字节串独立于具体格式 class ICompressor { public: virtual ~ICompressor() default; virtual std::string Compress(const std::string raw) const 0; virtual std::string ContentEncoding() const 0; }; // 输出目标只关心把字符串放到哪里 class ISink { public: virtual ~ISink() default; virtual void Write(const std::string payload, const std::string target) const 0; };三个接口的抽象粒度刚好对齐三个变化维度。我故意让每个接口的参数都简单直接IFormatter进来订单、出去字符串ICompressor进来字符串、出去字符串ISink进来字符串和路径。这样每个策略都可以独立测试不需要互相 mock。3.3 组合策略格式化 压缩 传输的管线接口定完后组合逻辑写在一个ExportPipeline类里。它持有三种策略的unique_ptr职责只有一个按顺序执行。class ExportPipeline { public: ExportPipeline(std::unique_ptrIFormatter formatter, std::unique_ptrICompressor compressor, std::unique_ptrISink sink, std::shared_ptrAuditLogger audit) : formatter_(std::move(formatter)), compressor_(std::move(compressor)), sink_(std::move(sink)), audit_(std::move(audit)) {} bool Execute(const std::vectorOrderRecord records, const std::string target) { try { std::string body formatter_-Format(records); body compressor_-Compress(body); sink_-Write(body, target); audit_-Log(export_ok, formatter_-Extension(), compressor_-ContentEncoding(), target, records.size()); return true; } catch (const std::exception e) { audit_-Log(export_failed, formatter_-Extension(), compressor_-ContentEncoding(), target, e.what()); return false; } } private: std::unique_ptrIFormatter formatter_; std::unique_ptrICompressor compressor_; std::unique_ptrISink sink_; std::shared_ptrAuditLogger audit_; };这个类非常薄真正的行为都在各个策略里。它的存在价值是保证管线顺序和错误处理只有一份实现。后续如果要在中间插入一个新节点比如加密、签名只需要在Execute中增加一步然后在工厂组装处传入对应的策略对象即可。3.4 通过工厂把策略组装起来有了策略接口和管线接下来需要一个地方把这些东西按配置拼起来。我用一个静态工厂方法完成组装class ExportPipelineFactory { public: static std::unique_ptrExportPipeline Create(const ExportConfig cfg, std::shared_ptrAuditLogger audit) { std::unique_ptrIFormatter formatter MakeFormatter(cfg.format); std::unique_ptrICompressor compressor MakeCompressor(cfg.compression); std::unique_ptrISink sink MakeSink(cfg.sink_type); if (!formatter || !compressor || !sink) { throw std::invalid_argument(invalid export config); } return std::make_uniqueExportPipeline( std::move(formatter), std::move(compressor), std::move(sink), std::move(audit)); } private: static std::unique_ptrIFormatter MakeFormatter(const std::string fmt) { if (fmt json) return std::make_uniqueJsonFormatter(); if (fmt xml) return std::make_uniqueXmlFormatter(); if (fmt csv) return std::make_uniqueCsvFormatter(); return nullptr; } // MakeCompressor / MakeSink 类似 };有人会问工厂里不还是有if分支吗这不是白重构了吗不是。工厂里的if是唯一允许出现分派的地方。它的职责是把配置字符串映射到策略对象而不是承载业务逻辑。业务逻辑被拆分进各自策略之后新加一种压缩算法只需要改MakeCompressor一个函数而之前呢你得在最核心的业务函数里找到压缩分支小心地插一个else if还要祈祷它别影响其它格式。3.5 新增策略时到底要改几个文件这是我重构后给自己定的验收标准也是给团队的双录规则。假如现在要新增Msgpack 格式 lz4 压缩 写入本地临时文件新建MsgpackFormatter类实现IFormatter新建Lz4Compressor类实现ICompressor如果有对应的通道需求再新建一个ISink实现类在工厂的MakeFormatter、MakeCompressor中注册两个字符串映射新增对应的单元测试文件。改动范围被限制在新增文件 工厂注册行原有所有旧策略类、旧管线逻辑、旧测试全部不用碰。这就是开闭原则带来的直接好处。实际重构完成后整个导出模块的代码量反而没有增加多少但每个文件的职责变得极其清楚新人接手时不再需要从两百行的 if 链里猜业务含义。4. 上了生产之后才发现的坑生命周期、拷贝与异常安全策略模式听起来很美但真正跑起来之后我踩了几个非常实际的坑这里一个个说。4.1 策略对象被 copy 的代价多态克隆不是默认行为第一个坑出在策略对象的拷贝语义上。业务方有个需求同一套导出管线要根据不同订单 id 分段执行。有人图省事直接写了ExportPipeline backup_pipeline original_pipeline;ExportPipeline里持有的是unique_ptr这种拷贝在 C 里直接编译失败。有人会说那改成shared_ptr不就完了于是把三个策略全部改成shared_ptr。这样改完backup_pipeline和original_pipeline里面的策略对象是同一份。看起来没问题因为策略本身是无状态的但坏味道在于多个上下文共享一个策略对象后一旦某个策略后面被加了计数器、内部状态比如统计调用次数数据就串了。我的建议是默认把策略设计成无状态的、可共享的如果策略内部确实需要维护状态那么在拷贝上下文时必须显式拷贝策略而拷贝多态对象要自定义克隆接口class IFormatter { public: virtual ~IFormatter() default; virtual std::string Format(const std::vectorOrderRecord records) const 0; virtual std::string Extension() const 0; virtual std::unique_ptrIFormatter Clone() const 0; };Clone()的实现一般是return std::make_uniqueJsonFormatter(*this);。这样ExportPipeline可以定义拷贝构造和拷贝赋值把三个策略各克隆一份避免共享内部状态。4.2 用 unique_ptr 还是 shared_ptr上下文与策略的职责边界我在重构时一开始给ExportPipeline用的也是shared_ptr理由是策略对象可能在多处使用。后来发现这是个伪需求策略对象实际上只应该从工厂创建、再被管线持有管线的销毁就意味着策略生命周期结束。用unique_ptr是最清晰的语义管线和策略是独占组合关系。如果确实有两个管线需要共享同一个策略对象那说明策略对象是真正的全局无状态单例此时更合适的做法是让它成为静态成员或者由工厂持有并返回引用而不是让每个管线shared_ptr强引用它。还有一种情况是用std::function捕获shared_ptr导致循环引用这个在写法 B 里尤其容易踩。捕获方式要尽量选[weak_ptr]或[raw_ptr]并保证策略对象生命周期比捕获者更长。4.3 异常弹栈时管线如何安全回滚生产环境里遇到过一件非常尴尬的事格式化阶段成功压缩阶段中途失败最外层拿到了失败返回值但是对象存储那边已经收到了之前同步写入的旧文件。顺着日志一查发现是这行代码出错了sink_-Write(body, target);Write内部先创建远程临时文件然后再Rename到目标位置。压缩失败根本不会走到这一步但我在某次手动测试时用了压缩前、后两份数据分别调用Sink把远程测试桶里的同名文件覆盖了。这个问题的本质是策略链没有实现事务性回滚中间节点失败后已执行的节点不会撤销副作用。解决方案不是让策略链自己实现事务那会把ExportPipeline复杂化而是约定一个规则ISink的实现必须做写临时文件 原子改名/上传ICompressor的失败抛异常之前绝不改写输入异常一旦抛出Execute的catch负责记审计并返回 false。这样真实业务中最多留下一个临时文件垃圾而不会损坏线上数据。4.4 虚析构缺失一个隐蔽的内存泄漏这是老生常谈但我确实在代码评审里见过不止一次class IFormatter { public: virtual std::string Format(...) const 0; // 没有 virtual ~IFormatter() };当代码里出现std::unique_ptrIFormatter formatter std::make_uniqueJsonFormatter();时析构的时候只会调用~IFormatter()而不会调用~JsonFormatter()。如果JsonFormatter里有std::string、std::vector这类成员它们不会被释放直接内存泄漏。现在 C 的设计公约里抽象基类必须给virtual ~IFormatter() default;或者protected的非虚析构禁止通过基类指针删除派生类。这一点我建议列入评审清单每写一个策略接口就检查一次。5. 性能观察虚函数调用到底贵不贵以及什么时候需要放弃运行时多态很多团队拒绝策略模式理由之一是虚函数慢。这个判断太笼统了。我针对订单导出这个场景做了一点压测记录一下量级供大家参考。5.1 一次虚函数调用到底花多少钱虚函数调用的额外开销有两部分一是间接跳转导致的分支预测失败概率上升二是虚表查找本身。实测下来在现代 CPU 上一次不在缓存中的虚调用比普通函数调用慢大约 2-5ns如果虚表刚好在缓存里差距可以缩小到 1ns 以内。对订单导出这种序列化压缩网络传输的场景一次导出处理至少是微秒甚至毫秒级虚函数开销几乎可以忽略。真正该关注的不是单个虚调用而是循环内的虚调用。比如要格式化一万条订单如果每条订单在循环内部都调用formatter.FormatOneRecord(record)一万条就是一万次虚调用这时候开销就会放大到肉眼可见的程度。我的优化策略是保持策略接口是批处理而非单条处理Format(const std::vectorOrderRecord)一次收一整批虚调用只发生一次剩下的都是在批处理内部做普通调用。5.2 分支预测与策略切换的局部性策略模式的切换本质上是一种运行期跳转。如果业务逻辑需要频繁在多个策略之间切换比如每隔几条订单就换一种格式CPU 分支预测器会无所适从性能下降就会明显。我给这个现象起了个名字策略抖动。出现策略抖动时优先考虑的不是优化虚函数而是调整调用方式让相同策略的订单尽可能批量连续处理把策略切换频率降下来。5.3 什么时候用模板策略或 CRTP 顶掉运行时多态订单导出的热路径其实是大批量数据格式化。如果用模板策略把格式化函数内联进去编译器可以将整段序列化逻辑直接优化成一个紧凑的循环省掉所有间接跳转。我当时对 CSV 格式化做了模板化改写template typename FormatterImpl class ExporterEngine { public: void Run(const std::vectorOrderRecord records, const std::string target) { auto raw FormatterImpl::Format(records); WriteFile(target, raw); } }; struct CsvEngine { static std::string Format(const std::vectorOrderRecord records) { // 紧凑的 CSV 拼接 } };这里FormatterImpl::Format是静态函数调用点在ExporterEngineCsvEngine::Run内部编译期就能解析不需要虚表。代价是ExporterEngineCsvEngine和ExporterEngineJsonEngine是不同类型不能在同一个容器里存放。如果你确实需要运行时配置驱动不同类型可以折中工厂返回一个基类引擎基类内持有运行时配置热路径使用模板化内部函数冷路径使用虚函数。5.4 压测结果与经验结论我拿一百万条订单记录做了本地压测粗略量级不同机器差异很大方案单次导出耗时相对基线原始 if 分支约 620 ms1.0x虚函数策略约 628 ms1.01xstd::function 策略约 645 ms1.04x模板策略静态分派约 598 ms0.96x结论很清晰在批量操作的场景里策略模式带来的性能损失几乎可以忽略std::function的额外开销最高但也只有 4% 左右模板策略反而因为内联获得了一点点提升。真正影响性能的是算法本身的复杂度而不是分派方式。所以我的选型原则是先正确再考虑热路径优化只有压测证明分派开销是瓶颈时才用模板策略替换。6. 策略模式的测试心得与滥用代价6.1 用 Fake 策略做单元测试把外部世界彻底隔离策略化之后测试变得非常好写。原来测Export函数需要准备文件路径、网络连接、消息队列环境现在只需要构造一个 Fake 策略把接收到的数据记录下来class FakeSink final : public ISink { public: void Write(const std::string payload, const std::string target) const override { last_payload_ payload; last_target_ target; } std::string last_payload_; std::string last_target_; };测试ExportPipeline的时候给它塞进FakeSink跑完直接断言sink-last_payload_的内容。对于IFormatter测试更是变成了纯字符串处理给订单输入断言输出里的字段和分隔符。一次测试只需要关注一个策略的行为不需要搭起整个导出服务。6.2 与工厂模式、注册器的边界划分策略模式经常和工厂模式搭配出现但很多人把它们的职责搞混。策略模式解决的是行为如何替换工厂模式解决的是对象如何创建。我的经验是策略接口负责定义行为契约工厂负责策略的组装注册器Registry负责配置字符串到策略对象的映射。在实际项目里当策略数量超过十个时我会把工厂里的MakeFormatter改成注册器模式让每个策略类在启动时主动把自己注册进一个unordered_mapclass FormatterRegistry { using Creator std::functionstd::unique_ptrIFormatter(); std::unordered_mapstd::string, Creator creators_; public: static FormatterRegistry Instance(); void Register(const std::string name, Creator creator); std::unique_ptrIFormatter Create(const std::string name) const; }; // 各策略在注册函数中调用 // FormatterRegistry::Instance().Register(json, [] { return std::make_uniqueJsonFormatter(); });这样新增策略时连工厂都不用改只需要在实现里多写一行注册函数。不过要注意注册函数必须在主流程之前被调用一般放在一个统一的内部初始化函数里否则可能出现配置里写了这个格式但运行时找不到策略的问题。6.3 滥用策略模式的两个反例过度设计与策略爆炸策略模式也不错但把它当锤子一样到处敲会带来两个反例。第一个反例是只有一个实现也要建接口。有的人为了显示自己有设计模式素养连一个加法策略都要建接口和实现类。当策略只会有一个实现时引入虚函数就是纯粹的浪费代码变多、阅读变慢、调试变难。我的经验法则是至少要有两个已知的、未来大概率会继续演化的实现才值得抽出策略接口。第二个反例是策略爆炸。当变化的维度有多个且每次变化都是多维度的交叉组合策略类数量会迅速膨胀。格式 x 压缩 x 输出如果每个组合都建一个策略类很快就会变成几十个类。正确做法是拆维度把格式、压缩、输出拆成三个策略族用管线装配而不是为每个组合创建策略类。组合维度的扩张靠装配解决不靠类数量解决。什么时候真正要让策略让位最后说一个我自己的体会策略模式解决的是同一接口下行为可替换但不是所有行为变化都该用策略。如果你面临的变化根本不需要在运行时切换只是想在编译期选择不同实现模板策略更合适如果你面对的是算法内部局部规则的调整也许只需要一个带参数的函数就好不必抽象成对象如果策略之间还会共享大量公共逻辑更合适的可能是模板方法模式——把公共骨架留在基类里只留一小部分可变的钩子。我在这个导出项目里最后保留下来的设计是三个策略接口、一个管线类、一个工厂、几个策略实现类。这个结构已经稳定运行了大半年后续加过两次新格式和一种压缩算法每次改动成本都控制在半小时内。这就是策略模式在 C 项目里最实际的回报把一个容易腐烂的巨型分支变成一台可以随时装卸零件的装配机器。