C++设计模式实战:从策略到状态,把握重构时机避免过度设计
1. 先泼盆冷水设计模式不是用来秀的1.1 我见过的模式灾难几年前我带过一个 C 服务端项目新人同学刚啃完《设计模式》热情极高。接手一个本来只有几十行逻辑的消息处理模块后他花了两周时间把里面塞进了抽象工厂、建造者、观察者、中介者还画了一张特别漂亮的 UML 图。代码量从 200 行涨到 1800 行编译时间翻了一倍最要命的是原来一个晚上能改完的需求现在要摸清楚六个类之间的调用关系才能动手。这不是段子是真实发生在很多 C 项目里的事。设计模式本身没问题出问题的是先抡锤子再找钉子的思维方式。很多人学完 23 种模式后最大的变化不是代码写得更好了而是开始用模式名词写注释——仿佛写了 pattern 名字代码就高级了。1.2 模式真正的价值在于沟通成本我从那以后反思了很久最后得出的结论是设计模式在 C 项目里最大的价值不是复用也不是灵活而是降低团队之间的沟通成本。当你跟同事说这里用观察者模式管理配置变更对方脑海里立刻浮现出一套约定俗成的结构subject 维护订阅者列表notify 时逐个回调。他不需要再从头阅读你的代码就知道数据流向大致是怎样的。这种语义压缩能力才是模式值得存在的核心理由。如果一段代码引入模式后团队里没人能一眼看懂或者需要翻文档才能理解你为什么要这样设计那这个模式引入得大概率是失败的。设计模式不是装饰品它是给活人看的代码组织方式。1.3 一个模式出现前的三个信号现在我写 C 代码在考虑某个设计模式之前会先问自己三个问题这段代码里是否有一个变化点并且这个变化点出现的频率足够高如果需求半年都不变一次提前抽象就是浪费。直接把变化的部分抽成参数、回调或者虚函数是否已经能解决问题很多情况下C 本身的机制就够用了并不需要上升到设计模式。引入模式后新同事或者未来的我是否能比现在更快地理解这段代码这三个问题只要有一个答不上来我就不会动手。设计模式是被变化逼出来的不是被设计造出来的。2. 策略模式把变化的算法从业务代码里拆出去2.1 实际场景计价系统的折扣规则策略模式是入门最容易、实战价值也最高的模式之一。它的核心其实就一句话定义一族可替换的算法让它们可以独立于使用它的客户端变化。我印象最深的应用场景是电商计价系统。订单价格计算表面上很简单商品单价乘以数量。但真实业务里折扣规则千奇百怪——满减、会员折扣、限时特价、首单优惠、组合优惠而且运营还在不停加新规则。如果你把所有这些规则全部写进一个calculatePrice()函数里用if (type MEMBER) ... else if (type FULL_REDUCTION)去判断前期确实爽但随着规则增多这个函数会变成几百行的一大坨每次新增规则都要动老代码测试范围也跟着扩大。2.2 用接口实现策略的 C 写法策略模式在这里的做法是定义一个计价策略接口每种规则实现一个策略类然后让订单类持有策略对象的指针。class IDiscountStrategy { public: virtual ~IDiscountStrategy() default; virtual double apply(const Order order) const 0; }; class MemberDiscountStrategy : public IDiscountStrategy { public: double apply(const Order order) const override { // 会员价逻辑 } }; class FullReductionStrategy : public IDiscountStrategy { public: double apply(const Order order) const override { // 满减逻辑 } }; class Order { std::unique_ptrIDiscountStrategy discount_; public: void setDiscountStrategy(std::unique_ptrIDiscountStrategy strategy) { discount_ std::move(strategy); } double total() const { double base /* 商品原价合计 */; return discount_ ? discount_-apply(*this) : base; } };这样改完新增规则的时候只需要加一个策略类Order不用动total()不用动调用方只要在合适的时机注入策略对象就行。这就是策略模式最直接的收益把变化隔离在新增代码里而不是修改老代码里。2.3 现代 C 里更轻量的替代方案不过说实话如果策略只有一两个函数我现在的习惯已经不是非得上虚函数接口了。C11 之后std::function是个极好的轻量替代品。class Order { std::functiondouble(const Order) discount_; public: void setDiscount(std::functiondouble(const Order) callback) { discount_ std::move(callback); } double total() const { double base /* 商品原价合计 */; return discount_ ? discount_(*this) : base; } }; order.setDiscount([](const Order o) { return o.basePrice() * 0.8; // 八折 });第 7 节我还会单独讲现代 C 对设计模式的改造。这里想强调的是策略模式的精神比它的形式更重要。你要抓住的是把算法族从上下文里剥离出来至于是用虚函数、std::function还是函数指针看项目实际情况。3. 观察者模式解耦消息通知但生命周期是老大难3.1 实际场景配置中心与界面刷新观察者模式解决的是一对多依赖的问题一个对象状态变了所有依赖它的对象都能收到通知并自动更新。C 项目里最常见的观察者场景不是 GUI 按钮点击而是配置中心与业务模块的联动。比如服务端有一个动态配置中心某个开关从true变成false之后限流模块、日志模块、监控上报模块全都要跟着作出反应。如果每个模块都用轮询去查配置浪费资源不说响应还不及时。观察者模式的做法是做一个ConfigSubject维护订阅者列表配置变更时逐个调用订阅者的onConfigChanged回调。3.2 原始指针的坑与 weak_ptr 解法观察者模式在 C 里最大的坑是订阅者的生命周期。我们经常遇到这种情况模块 A 订阅了配置中心的变更通知但模块 A 先于配置中心析构了。如果配置中心还保存着 A 的裸指针下一次推送通知时就会访问野指针直接崩溃。这个问题有几种解法我的经验是按照项目阶段来选择确保订阅者的析构发生在 subject 之前。这在很多服务端主从结构里是可以保证的但一旦有人不小心改了初始化顺序线上就会出现随机崩溃排查起来非常痛苦。在订阅者析构函数里主动 unsubscriple。这是最直接的做法要求程序员自觉容易漏。用weak_ptr保存订阅者。推送通知时先lock()如果对象已经析构就自动跳过并清理订阅列表。第三种方案我强烈推荐。它的代码如下class ConfigSubject { std::vectorstd::weak_ptrIConfigObserver observers_; public: void attach(const std::shared_ptrIConfigObserver obs) { observers_.emplace_back(obs); } void notify(ConfigChangeEvent evt) { for (auto it observers_.begin(); it ! observers_.end();) { if (auto obs it-lock()) { obs-onConfigChanged(evt); it; } else { it observers_.erase(it); // 清理析构掉的订阅者 } } } };注意weak_ptr方案有一个前提订阅者必须以shared_ptr方式被持有否则weak_ptr无从锁定。这也是很多老项目改造时最头疼的地方。3.3 单线程 vs 多线程下的观察者观察者模式还有一个绕不开的话题线程安全。如果配置中心在业务线程里被修改而订阅者的onConfigChanged回调会去操作 UI 或别的工作线程数据那问题就来了。我的建议是同步通知异步处理。也就是 subject 只管把事件同步地派发出去回调里不要做耗时操作收到通知后把任务丢给合适的执行器。如果确实需要异步派发可以给 subject 内部加一个互斥锁保护订阅者列表但锁的使用要极为克制否则推送频率一高性能就崩了。我在实际项目中观察者回调里就出现过死锁——一个订阅者在处理回调时反过来调用了导致配置变更的接口直接把自己锁死了。后来我在文档里强制约定回调函数内不得再次修改订阅源这才消停。4. 工厂模式家族谁该负责创建对象4.1 简单工厂不是模式但天天在用简单工厂严格来说不在 GoF 23 种模式里但它却是 C 代码里出现频率最高的工厂。它的核心做法是用函数根据参数返回不同类型的对象实例但对象的公共接口保持一致。举一个最常见的例子——解析器。假设你根据文件后缀决定创建哪种解析器std::unique_ptrIParser createParser(const std::string filename) { auto ext getExtension(filename); if (ext json) return std::make_uniqueJsonParser(); if (ext yaml) return std::make_uniqueYamlParser(); if (ext toml) return std::make_uniqueTomlParser(); return nullptr; }简单工厂的缺点是显而易见的每新增一种解析器都要改动这个createParser函数。但它的优点也很明显——调用方只需要依赖IParser至于创建细节全被隔离在内。如果你的项目里这个工厂函数一年改动不超过两三次简单工厂就是最优解不需要上更复杂的模式。4.2 工厂方法与抽象工厂跨平台代码的骨架工厂方法模式比简单工厂多了一层虚函数化基类定义创建对象的接口子类决定创建哪种具体对象。抽象工厂模式则更进一步提供一个创建一族相关对象的接口调用方不关心具体是哪一族产品。这两个模式在 C 里最常见的应用场景是跨平台 UI 或跨平台文件系统封装。你在 Windows 上创建一个按钮是WinButton在 Linux 上是X11Button在 macOS 上是CocoaButton。客户端代码只需要跟ButtonFactory打交道具体创建哪个平台按钮交给不同平台的 factory 子类去完成。class IButtonFactory { public: virtual ~IButtonFactory() default; virtual std::unique_ptrIButton createButton() const 0; }; class WinButtonFactory : public IButtonFactory { public: std::unique_ptrIButton createButton() const override { return std::make_uniqueWinButton(); } }; class X11ButtonFactory : public IButtonFactory { public: std::unique_ptrIButton createButton() const override { return std::make_uniqueX11Button(); } };这种模式的价值在于跨平台代码最怕到处都是#ifdef _WIN32工厂方法把平台差异收敛到每一个具体工厂类里业务代码干干净净。4.3 create 函数与 unique_ptr 的组合我还注意到一个细节老一辈 C 教材里的工厂返回的是裸指针调用方一旦忘记delete就内存泄漏。现在写新代码我的习惯是工厂函数一律返回std::unique_ptr。std::unique_ptrIParser makeJSONParser(); // 返回 unique_ptr这样有两个好处一是调用方明确知道自己拥有这个对象不需要再考虑释放二是如果发生了异常unique_ptr局部变量会保证资源被释放不会被泄漏到外面。工厂 智能指针的组合可以说是现代 C 项目里最舒服的编程方式之一。5. 模板方法模式用基类定死流程用虚函数留出扩展点5.1 场景多格式数据导入流程模板方法模式和策略模式容易混但它俩解决问题的层面完全不同。模板方法关注的是流程骨架共享把一段算法的整体步骤在基类里固定下来但把其中某些步骤的具体实现延迟到子类。C 项目里特别典型的案例是数据导入。无论你导入 CSV、Excel 还是 JSON流程几乎都是一样的打开文件解析头部/结构逐行或逐条读取数据数据清洗写库记录导入日志如果每个格式都各自写一遍主流程代码会大量重复如果全部写在一个类里又会被各种格式的特殊逻辑塞满。模板方法模式正好对症class DataImporter { public: bool import(const std::string path) { if (!openFile(path)) return false; if (!parseHeader()) return false; while (auto record readNext()) { clean(record); if (!insert(record)) { logError(record); return false; } } finish(); return true; } protected: virtual bool openFile(const std::string path) 0; virtual bool parseHeader() 0; virtual std::optionalRecord readNext() 0; virtual void clean(Record record) { /* 默认空实现 */ } virtual bool insert(const Record record) 0; virtual void finish() {} };子类只需要实现那几个虚函数import()的完整流程由基类统一控制。这样既保证了所有导入器的行为一致又给每个格式留了足够的扩展空间。5.2 模板方法和策略模式怎么选很多人在这里纠结模板方法明确第一步做什么、第二步做什么而策略模式把一个步骤里面的算法实现开放出去。区分它们的核心是变化的粒度。如果变化的是整个流程——那可能要考虑策略模式把流程封装成策略对象。如果变化的是流程中某一步的具体做法——那就是模板方法的典型适用范围。业务领域有一个很直白的判断方法当你发现流程不变步骤变的时候模板方法当你发现整个算法都可以替换的时候策略模式。5.3 钩子方法模板方法里最容易忽略的设计点模板方法模式里有一个细节很多人忽略但实战中特别重要——钩子方法。钩子方法是基类里的非纯虚函数默认实现是空操作或返回固定值子类可以按需覆写。它的作用是让子类有能力插入额外的逻辑而不必修改主流程。以数据导入为例我在基类里加了一个virtual bool allowDryRun() const { return false; }的钩子。测试环境需要试导入功能子类覆写为return true主流程就会在写库前先打印要执行的 SQL。没有钩子的话子类想让流程在某个节点停下来或加个判断就只能拥有自己的import()副本那就完全失去了模板方法的意义。写模板方法的时候不要把所有方法都设成纯虚函数。多想想哪些步骤大部分子类会用默认行为哪些步骤需要被插入然后用钩子去表达这个模式用起来会顺手很多。6. 状态模式当项目里到处都是状态判断的时候6.1 场景网络连接的状态流转状态模式处理的对象状态变化导致行为变化的问题。C 网络库是它的天然试验场。一个 TCP 连接的经历可以非常简单连接中 → 已连接 → 断开。但如果加上重连、鉴权、心跳超时、服务端主动关闭等逻辑状态数量瞬间膨胀各种if (state_ ConnectionState::CONNECTING)的判断会散落在send()、recv()、onTimer()等各个方法里。更麻烦的是同一状态在不同事件下的迁移规则还不一样。这种逻辑如果用 switch 硬写每加一个状态或者每改一条迁移规则都要把所有相关函数翻一遍。6.2 状态模式的基本骨架状态模式把这些判断变成多态每个状态一个类类内定义该状态下对各种事件的处理状态迁移通过替换当前状态对象来完成。class TcpConnection; class ConnectionState { public: virtual ~ConnectionState() default; virtual bool send(TcpConnection conn, const char* data, size_t len) 0; virtual void onConnect(TcpConnection conn) 0; virtual void onTimeout(TcpConnection conn) 0; }; class ConnectingState : public ConnectionState { public: bool send(TcpConnection, const char*, size_t) override { // 连接中不能发送缓存或报错 return false; } void onConnect(TcpConnection conn) override; void onTimeout(TcpConnection conn) override; }; class ConnectedState : public ConnectionState { public: bool send(TcpConnection conn, const char* data, size_t len) override { // 真正发送数据 return true; } // ... }; class TcpConnection { std::unique_ptrConnectionState state_; public: void setState(std::unique_ptrConnectionState s) { state_ std::move(s); } bool send(const char* data, size_t len) { return state_-send(*this, data, len); } };这样改完不同状态的差异被封装在各自的类里新增状态不需要改动原状态类的代码符合开闭原则。6.3 状态表方案与 std::variant 的比较不过状态模式也并不是唯一解。如果状态机比较规则没有复杂的进入动作、退出动作我更喜欢用状态转移表来写。enum class Event { CONNECT, TIMEOUT, DISCONNECT, SEND }; enum class State { IDLE, CONNECTING, CONNECTED, CLOSED }; struct Transition { State from; Event event; State to; }; const std::vectorTransition kTransitions { {State::IDLE, Event::CONNECT, State::CONNECTING}, {State::CONNECTING, Event::CONNECT, State::CONNECTED}, // ... }; State nextState(State current, Event evt) { for (const auto t : kTransitions) { if (t.from current t.event evt) return t.to; } return current; // 忽略非法转移 }状态转移表最大的优点是一眼看全所有规则评审的时候特别好用。现代 C 的std::map甚至可以存std::function作为转移动作非常灵活。如果你的状态数量很少、规则稳定其实用std::variant也能实现类似效果using ConnectionStateVariant std::variantConnectingState, ConnectedState, ClosedState;std::variant会管理状态对象的内存生命周期不需要手写指针安全性更高。这一点在第 7 节我再展开。7. 现代 C 给设计模式带来的变化7.1 std::function 让策略和观察者不需要继承传统设计模式教程写观察者几乎是清一色的接口继承class IObserver { public: virtual void update() 0; };但现代 C 项目里我越来越常用std::function替代这种抽象接口。订阅者不再需要继承一个只有单个方法的类而是直接传一个 lambda 进来。class Subject { std::vectorstd::functionvoid(const Event) handlers_; public: void connect(std::functionvoid(const Event) handler) { handlers_.push_back(std::move(handler)); } void notify(const Event evt) { for (auto fn : handlers_) { fn(evt); } } };你用std::function的时候要付出一点代价它通常比直接调虚函数慢也可能产生堆分配。但对绝大多数业务场景来说这个开销可以忽略不计。换来的是代码可读性和表达力的提升订阅方不再需要为一个方法写一个类这非常契合 C 函数式风格。策略模式同样适用把std::function作为策略的承载对象状态模式里的转移动作也可以用std::function存到 map 里。设计模式的思想不变但承载形式更轻了。7.2 CRTP编译期多态版本的模板方法CRTPCuriously Recurring Template Pattern是一种 C 特有的静态多态技巧。它的写法是基类模板把派生类作为模板参数template typename Derived class DataImporterBase { public: bool import(const std::string path) { if (!derived().openFile(path)) return false; // ... } private: Derived derived() { return *static_castDerived*(this); } }; class CsvImporter : public DataImporterBaseCsvImporter { // 实现 openFile 等 };它和模板方法模式的思路几乎一模一样基类定流程子类填细节。但 CRTP 是在编译期完成绑定没有虚函数调用开销。对于游戏引擎、高频交易这类要求极致性能的 C 项目这种零成本抽象非常诱人。需要说明的是CRTP 并不是要替换模板方法模式。模板方法的虚函数多态支持运行时动态改变子类CRTP 要求类型在编译期就确定。如果你的业务确实需要运行时多态比如根据配置动态选择导入器还是老老实实用虚函数如果性能敏感且类型确定CRTP 是更优解。7.3 std::variant 让访问者模式不再需要 accept 函数访问者模式被称为 GoF 里最难理解、最少使用的模式之一因为它引入的accept双调度机制让代码结构极其绕。传统 C 要访问一个异构对象集合的每个类型写起来很痛苦。C17 的std::variant和std::visit把这件事变得简单直白using DataValue std::variantint, double, std::string, std::vectorbyte; std::string describe(const DataValue v) { return std::visit([](const auto x) - std::string { using T std::decay_tdecltype(x); if constexpr (std::is_same_vT, int) { return int: std::to_string(x); } else if constexpr (std::is_same_vT, double) { return double: std::to_string(x); } else if constexpr (std::is_same_vT, std::string) { return string: x; } else { return bytes; } }, v); }std::visit本质上就是一个编译器为你生成的访问者不需要accept函数也不需要每个类型写一个访问者类。在设计模式的教学里很久没有出现过这种用语言特性消灭模式的案例。这也侧面说明一件事设计模式的本质是弥补语言的不足当语言进化后相应的模式自然会瘦身。8. 最重要的事识别模式出现的时机比记住模式更重要8.1 从坏味道反向推导模式说了这么多模式最后一个问题可能是最有价值的什么时候该用什么时候不该用我的经验是不要从一个空类开始就设计模式而是先写出直观的代码在遇到坏味道时再考虑重构为模式。常见的坏味道包括一个函数几百行里面全是if (type ...)的分支——这是策略模式或状态模式的召唤。新增一个功能要同时改四五个类——可以考虑提取接口或工厂。对象的状态变化导致同一操作有完全不同的行为代码里到处是状态判断——状态模式在向你招手。两段代码流程完全一样只是中间某个步骤不同——模板方法模式正好解决。模式是被重复和变化逼出来的而不是被设计出来的。面向模式的编程远远优于面向模式的设计。8.2 过度设计的自查清单我给自己定了一个排查过度设计的清单今天也分享出来这个模式引入后新增需求是更简单了还是更复杂了如果引入模式后常见需求反而要多写很多样板代码那不如不要。代码里有多少人真能看懂这个模式如果团队平均 C 水平止步于智能指针就不要顺手写 CRTP。这个模式是否增加了运行时开销服务端一次请求几微秒的虚函数调用一般不用在乎但如果是在被调用千万次的热路径上模式的选择要慎重。是否可以用更简单的替代方案能用std::function解决的不建类能用标准库容器解决的不建对象能用枚举解决的不建状态机。8.3 我的个人经验重构时引入模式而不是一开始就设计最后再说一个比较反常规但很重要的心得绝大多数设计模式的最佳引入时机是重构阶段而不是架构阶段。人没法凭空预判所有的变化点一开始就把架构铺得很大最后往往发现变化的方向完全不是设想那样。写得简单点等需求真的来了你会清楚地看到哦这个 switch 已经出现第四次了该抽策略了。这时候再引入模式每一步都是基于真实需求的心里非常有底。重构时引入模式还有一个额外的好处你会留下前后对比。当团队成员看到 为什么这里要用状态模式的答案不是因为它是经典模式而是因为它消除了这三个函数里重复的状态判断理解成本会低得多。我在 C 项目里见过的最高质量的代码从来不是那种把 23 种模式炫技般全部用上的代码而是把简单的逻辑写得清晰、让变化点一目了然的代码。设计模式应该像工具箱里的好工具——挂在墙上需要的时候取下来用而不是把所有零件都永远装在身上。C 是一门允许你写出既快又复杂的语言也可以让你把代码写得像装配流水线一样模块分明。设计模式的实战说到底比的是判断力而不是背了多少个类图。每次想用模式的时候多问一句这个模式的引入是为了让下一个读代码的人更省力还是为了让自己的简历更好看答案自然会告诉你该怎么做。