ARTICLE DETAIL

资讯详情

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

现代C++设计模式实战:语言特性、高频模式与重构案例

现代C++设计模式实战:语言特性、高频模式与重构案例 开始之前我得先说明一句这篇文章的定位不是把《设计模式》那本书里的 UML 图给你搬出来重画一遍。那种事网上随便一搜就是几十篇。我想写的是把我这些年用 C 实际写项目时对“设计模式”这四个字的真实理解——哪些模式用得最多、哪些模式在 C 里根本是坑、哪些实现方式要随着 C 标准变化而调整以及最关键的怎么从“背得出 23 个模式名字”变成“写代码时真的能自然用出来”。这篇文章适合三类人看一是学完 C 语法但写项目时总觉得代码“一股脑堆在一个文件里”的同学二是准备面试问到设计模式时不想只背定义想说出点 C 特有门道的人三是工作两三年想重构手里那坨“加需求就崩”的旧代码的开发者。我会把每个模式都落到真实场景里讲配套的代码、切换思路、踩坑记录都会展开。保证你看完不是记住概念而是知道下次遇到什么代码味道该掏哪个模式出来。1. 设计思路拆解为什么C里谈设计模式总是绕不开“语言特性”这个坎1.1 设计模式的本质到底是什么先把地基打牢。设计模式不是某种可以 install 的库也不是一套必须遵守的语法规则它本质上是一批前辈在大量工程实践里总结出来的“常见问题的常用解法”。更准确地说它是在接口设计这个层面上对“如何组织类与类之间的关系”“如何安排对象之间的通信”“如何控制对象的创建与销毁”这三类问题的经典答案。你可以把它类比成做菜时候的“火候口诀”肉丝要滑嫩大概就是温油下锅、快速划散鱼要入味就是先腌后蒸。这些口诀不是一个配方对应一道菜而是一类操作对应一类需求。设计模式也一样单例模式解决“全局只需要一个实例”的需求观察者模式解决“一改多处联动”的需求策略模式解决“同一动作有不同做法且运行时可以切换”的需求。但这里有一个 C 比其他语言更尴尬的地方设计模式最初那本畅销书里的示例代码用的是 Smalltalk 和 C 老标准。回到那个年代没有std::function、没有shared_ptr、没有 lambda、没有模板的变长参数包连auto都还没有。那时候要实现“把一段行为传进函数”你只能定义接口类、派生类、再传指针。于是很多模式就长成了“满屏类继承”的样子。到了现代 C语言本身已经吸收了很多模式的“精神”直接提供语言级支持。这时候你再硬套老写法不是不行但属于脱裤子放屁。所以在聊 C 里的设计模式前必须先建立一种观念模式是思想不是模具。用的时候要看语言给了你什么新工具把模式用更简洁的方式表达出来那才叫真正理解设计模式。1.2 C 与 Java 在设计模式上的根本差异网上搜“设计模式实现”十有八九出来的都是 Java 版本。Java 是纯面向对象语言万物皆对象、垃圾回收替你管内存、接口是语言一等公民。拿 Java 的套路直接往 C 上搬经常会碰到三堵墙第一堵墙是值语义 vs 引用语义。Java 里你new一个对象赋值给变量赋的是引用两个变量可以指向同一个堆对象copy 不存在的。C 则天然是值语义std::string a x; std::string b a;这是真复制出两份数据。想让两个对象共享底层数据得明确用指针、引用或者shared_ptr。很多设计模式尤其是结构性模式默认你操作的是“引用”在 C 里如果不注意值语义一个不小心就把不该复制的对象复制了性能崩了还是小事对象身份乱了才是最隐蔽的。第二堵墙是资源管理方式。Java 不用管 deleteC 则必须面对构造、析构、拷贝、移动这一整套“生命周期管理”问题。也正因为如此C 演化出了 RAII资源获取即初始化这个杀手锏——用对象的构造函数和析构函数来绑定资源的获取与释放。不少模式在 C 里可以直接通过 RAII 获得更好的实现典型就是锁的自动释放你根本不用手写unlock。第三堵墙是泛型能力。Java 的泛型是擦除式的相比之下很弱C 的模板是图灵完备的。这意味着很多需要“运行时多态”的模式在 C 里可以用“编译期多态”来实现比如 CRTP奇异递归模板模式、策略模式模板化。代价是代码读起来更晦涩但换来的是零运行时开销。后面所有模式的 C 实现讨论我都会基于这三点差异展开。你只有先理解了这三堵墙才能在具体代码里做出明智取舍该用虚函数就用虚函数该上模板就上模板能用std::function就不必再造一整套继承体系。1.3 现代 C11/14/17对传统模式实现的改写既然说到了语言特性迭代索性把“现代 C 对传统模式的影响”这个表列一下哪个模式被语言特性“削”掉了复杂度一眼就看明白。传统模式老式 C 的实现痛苦现代 C 的新工具观察者模式需要定义观察者接口、维护观察者基类指针列表std::function lambda直接存可调用对象无需强制继承策略模式每种算法一个类继承同一策略接口工厂创建lambda、std::function甚至直接用模板参数做编译期策略单例模式需要手写双重检查锁、处理内存屏障函数内静态局部变量天然线程安全的初始化工厂模式工厂返回裸指针调用者必须记得 delete返回shared_ptr或unique_ptr靠 RAII 自动管理模板方法模式经典继承体系基类实现骨架派生类重写也可以用 CRTP 做编译期多态版本命令模式一个命令类一个 execute 方法派生类若干std::function直接封装 lambda把命令变成可赋值、可存储的对象这张表不是否定传统模式而是说同一个“可行的解法”在现代语言里有了更轻、更快的表达路径。实际项目里我不会为了体现“我是设计模式高手”而拒绝这些新工具反而会优先用语言特性解决问题只有语言没有原生支持时才上完整的类体系。2. 核心高频模式的 C 实现从代码层面拆给你看2.1 单例模式别再写双重检查锁了单例模式可能是 C 初学者接触的第一个设计模式但同时也是被用烂、被错用最多的模式。很多人一上来就写这样的代码class Singleton { public: static Singleton* getInstance() { if (instance_ nullptr) { instance_ new Singleton(); } return instance_; } private: static Singleton* instance_; };这段代码放在单线程里没问题多线程下就是竞态条件两个线程同时看到instance_ nullptr然后各自new一次内存泄漏加逻辑错乱。于是有人开始给它加锁写出经典的“双重检查锁”static Singleton* getInstance() { if (instance_ nullptr) { lock_guardmutex lock(mtx_); if (instance_ nullptr) { instance_ new Singleton(); } } return instance_; }这个版本比裸指针好一些但 C 老标准下有个魔鬼细节instance_ new Singleton()这条语句不是原子的编译器和 CPU 可能先分配内存、再赋指针、最后才调用构造。另一个线程在赋值完成但构造未完成时读到非空指针拿到一个半成品对象用起来直接崩。这个问题要彻底解决在 C11 之前你得用各种平台特定的内存屏障指令。但现在不需要了语言自己给了你一个完美答案class Logger { public: static Logger instance() { static Logger log; return log; } void write(const std::string msg) { // 写日志逻辑 } Logger(const Logger) delete; Logger operator(const Logger) delete; private: Logger() default; ~Logger() default; };关键点在static Logger log;这一句。C11 标准明确规定函数内静态局部变量的初始化是线程安全的编译器会在第一次执行到这里时插入必要的同步机制保证只有一个线程会构造log其他线程只会看到初始化完成后的对象。这就是大名鼎鼎的Meyers Singleton。这个版本简单、线程安全、零手工锁、零内存泄漏静态变量程序结束自动析构。我个人的建议是只要你需要单例C 里优先写这种不要再折腾指针和锁。不过单例模式本身要慎用。全局状态是万恶之源它让你在测试时没法轻松替换依赖、在并发时成为锁竞争的焦点。我更倾向把它用在真正“全局唯一且有共享状态”的场景——比如日志系统、配置管理器、全局事件总线。如果一个类只是“目前只有一个对象”那你完全可以在 main 函数里构造一个对象手动传递引用而不是非把它变成单例不可。2.2 观察者模式从继承体系到 std::function 的转变观察者模式解决的场景几乎所有带界面或者带异步通知的系统都会碰到一个对象状态变了其他多个对象需要同步知道并做出反应。最经典的实现是主题Subject维护一个观察者Observer列表观察者继承自同一个抽象接口主题变更时逐个调用update()。传统 C 长这样class IObserver { public: virtual ~IObserver() default; virtual void update(int value) 0; }; class Subject { public: void attach(std::shared_ptrIObserver obs) { observers_.push_back(obs); } void setValue(int v) { value_ v; notify(); } private: void notify() { for (auto obs : observers_) { if (auto sp obs.lock()) { sp-update(value_); } } } std::vectorstd::weak_ptrIObserver observers_; int value_{0}; };这个实现有个强制约束所有观察者必须继承IObserver。如果观察者本身已经有基类了那它在 C 里就面临“多继承”的选择一多继承菱形问题、虚继承的复杂度就全上来了。换个思路。观察者模式真正要求的核心其实就一点主题手里要能存一段“将来可以用来通知你的逻辑”。C11 之后std::function就是干这个的。它像是一个“行为容器”不管你塞进函数指针、函数对象、lambda 还是std::bind的结果它都能接住。于是主题可以变成这样class EventSource { public: using Handler std::functionvoid(int); void connect(Handler h) { handlers_.push_back(std::move(h)); } void fire(int value) { for (auto h : handlers_) { h(value); } } private: std::vectorHandler handlers_; };调用方的代码也变得异常简单EventSource source; source.connect([](int v) { std::cout Observer A received: v \n; }); source.connect([someState](int v) { // 这个 lambda 可以捕获自己的状态 }); source.fire(42);每个观察者不用再为“实现接口”创建一个类文件lambda 直接捕获上下文完全所见即所得。这是现代 C 对设计模式最大的解放。但也要注意新写法带来的新坑。std::function版本对观察者的生命周期几乎无感知如果fire()的时候某个 handler 捕获了一个已经析构的对象那就是经典的悬垂引用崩溃。解决思路有两种一是约定 handler 必须是“无主状态”的纯逻辑外部对象的生命周期自己负责二是在connect之前明确用weak_ptr包裹捕获对象在 handler 内部锁一下lock()再调用。我实际写代码时会评估系统规模小系统用std::function 约定大系统需要自动解除注册的还是老老实实维护std::weak_ptrIObserver列表更稳。2.3 工厂模式从简单工厂到抽象工厂的路线图工厂模式是 Java 里被吹到天上去的东西。毕竟 Java 的new必须出现在具体类名旁边想要绕开依赖就得搞工厂。但 C 里我们要分析一下到底哪种“工厂”才是真实项目需要的。先说简单工厂。它不是一个 gof 正统模式但好学好用。用一个静态函数根据参数返回不同类型的对象class Message { public: virtual ~Message() default; virtual void print() const 0; }; class TextMessage : public Message { ... }; class ImageMessage : public Message { ... }; class MessageFactory { public: static std::unique_ptrMessage create(const std::string type) { if (type text) return std::make_uniqueTextMessage(); if (type image) return std::make_uniqueImageMessage(); throw std::runtime_error(unknown message type); } };核心优势暴露无遗客户端代码只依赖Message这个抽象外加工厂类。以后加了VideoMessage只需要改工厂内部所有调用方的代码一行不动。这就是对“开闭原则”的实践——对扩展开放对修改封闭。但很多教程到这里就把工厂讲完了导致初学者以为工厂模式就是“写个 if 判断 new 谁”。远没有这么简单。如果工厂里堆了几十个 if 分支每加新产品都要改工厂这个工厂会变得越来越重。所以衍生出工厂方法模式工厂本身做成抽象类具体产品由具体工厂创建。客户端要加新产品就新写一个工厂子类原文不动彻底闭源修改。class MessageCreator { public: virtual ~MessageCreator() default; std::unique_ptrMessage create() const { return makeMessage(); } private: virtual std::unique_ptrMessage makeMessage() const 0; }; class TextMessageCreator : public MessageCreator { private: std::unique_ptrMessage makeMessage() const override { return std::make_uniqueTextMessage(); } };再往上是抽象工厂模式一个工厂能创建一族相关的产品。比如一个 UI 主题工厂既能创建按钮又能创建输入框Windows 主题和 Mac 主题各一套。抽象工厂的难点不在代码而在产品族的扩展——当你想增加一个“下拉框”产品所有工厂子类都要跟着改。这一刀切下去开闭原则就被破坏了。所以抽象工厂适合产品族稳定、不经常加新产品的场景。我给个实在建议大多数中小型项目简单工厂就够了。那些为了“开闭”而强行给工厂套接口的代码最后往往只是徒增类数量。等你真的遇到“新增产品都要求改工厂代码导致不断回归测试”的痛苦时再升级到工厂方法不迟。模式是拿来解痛的不是拿来炫技的。2.4 策略模式、状态模式与命令模式的现代等价物把这三个放一起讲是因为它们的 C 实现方式几乎是同构的。策略模式的核心是“把算法提取出来使其可替换”状态模式的核心是“把状态变化时的行为差异提取出来”命令模式的核心是“把一次请求封装成对象支持记录、排队、撤销”。传统实现里这三个模式分别长成一棵继承树策略基类、状态基类、命令基类派生类各自实现再用一个 Context 去持有当前实例。老代码确实只能这么写。但现代 C 里这三棵树的“叶子”很多情况下一个 lambda 就可以替代策略模式using CompressStrategy std::functionvoid(const std::string path); void compress(const std::string path, CompressStrategy s) { s(path); } // 在调用处 compress(data.txt, [](const std::string p) { // zip 实现 }); compress(data.txt, [](const std::string p) { // rar 实现 });命令模式struct Command { std::functionvoid() execute; std::functionvoid() undo; }; std::vectorCommand history; history.push_back({ []{ std::cout do A\n; }, []{ std::cout undo A\n; } });这种写法把模式的形式复杂度降到了最低但本质完全保留行为被封装成第一等对象可以被传递、存储、替换。核心转变是面向接口编程不一定非得是“类层次结构”意义上的接口。C 的接口概念已经被std::function、概念Concept和模板鸭式类型扩展了。你面向的可以是一个签名而不是一个虚函数表。3. 实操过程与真实案例用设计模式改造一个不断膨胀的日志模块3.1 场景设定什么代码味道提醒你“必须用模式了”讲那么多原理还是不过瘾。我给你一个我自己重构过的真实案例完整走一遍“发现问题 → 抽象识别 → 落地模式”的全过程。假设一个服务器项目里有一个日志模块一开始就一个类写日志到文件class Logger { public: void log(const std::string level, const std::string msg); };后来需求来了日志不仅要写文件还要同时上报到远程监控服务再后来又要统计错误次数还要在出错时通过 WebSocket 推送给在线运维界面。于是这个 Logger 开始膨胀每加一个需求就多加一段“写文件之后再做另一件事”的逻辑。三个月后Logger 内部全是if判断和硬编码的远程地址改一个渠道要动 Logger 主类测试全得回归。这个代码味道叫什么叫**“一个类承担了多个变化点”**。写文件的逻辑是稳定的上报远程的逻辑是相对稳定的推送界面的逻辑是独立演进的。三个变化轴捆在了同一个类里任何一根轴动了整体都震动。3.2 第一步给日志渠道抽象出接口引入工厂首先把“日志写到哪”这个行为抽象成一个接口class LogSink { public: virtual ~LogSink() default; virtual void write(const std::string level, const std::string msg) 0; }; class FileSink : public LogSink { // 写入本地文件 }; class RemoteSink : public LogSink { // 通过 HTTP 上报 }; class UISink : public LogSink { // 推送到运维界面 };然后做一个简单工厂根据配置文件创建 sink。这一步的效果立竿见影Logger主类不再知道FileSink、RemoteSink的存在它只面对一个LogSink接口。以后要加一个“写入消息队列”的 sink只需要新增类修改工厂内部配置映射Logger完全不动。3.3 第二步观察者模式让多个 sink 不再耦合但有个问题一个日志事件可能需要同时写到三个 sink 去。如果 Logger 里维护一个std::vectorstd::unique_ptrLogSink依次调用write这行为本身就是最简单的观察者模式class Logger { public: void attach(std::unique_ptrLogSink sink) { sinks_.push_back(std::move(sink)); } void log(const std::string level, const std::string msg) { std::lock_guardstd::mutex lock(mtx_); for (auto sink : sinks_) { sink-write(level, msg); } } private: std::vectorstd::unique_ptrLogSink sinks_; std::mutex mtx_; };你发现没有这一步做完我们没有照着书籍目录去查“观察者模式怎么画 UML”而是顺着“有多个对象需要响应同一个事件”这个需求自然就走到了观察者结构上。模式应当从需求中长出来而不是从目录里套下来。那单例模式用在哪日志模块本身天然适合全局单例——任何地方都能调用不需要到处传递实例。我把Logger::instance()做成 Meyers Singleton外部直接Logger::instance().log(error, something bad)清爽。3.4 第三步策略模式应对日志格式变化日志模块还有第二个变化轴格式。今天要 JSON 格式明天要纯文本后天可能还要加密编码。这些格式算法和 sink 是正交的。如果你把格式写在FileSink::write()里面那所有 sink 都得各自实现一遍格式化代码重复到爆炸。抽取一个Formatter策略using Formatter std::functionstd::string(const std::string level, const std::string msg); Formatter jsonFormatter [](const std::string level, const std::string msg) { return {\level\:\ level \,\msg\:\ msg \}; }; Formatter plainFormatter [](const std::string level, const std::string msg) { return [ level ] msg; };sink 接口接收的write参数从(level, msg)变成已经格式化好的字符串。Logger 在log时先把原始消息交给 formatter再把结果发给所有 sink。这样日志格式这个变化轴和输出渠道这个变化轴彻底解耦了。任何一边的变动都不会影响另一边。最终这个日志模块的类结构是单例 Logger 持有一组 LogSink 对象观察者集合一个 Formatter 策略对象std::functionsink 对象由工厂创建。整个模块的可扩展性远超一开始那个堆满 if 的原始版本。而我用的模式翻来覆去就三个单例、观察者、策略。23 个模式里真正高频的日常开发可能也就六七个。把有限几个用熟比把 23 个都背下来实用得多。4. 常见问题与排查技巧实录设计模式在C里翻过的车4.1 单例模式的线程安全与生命周期顺序问题场景程序退出阶段崩溃崩溃栈指向单例类的析构函数并且是在访问另一个全局对象时崩的。原理分析C 里静态对象的析构顺序与构造顺序相反但不同编译单元之间的静态对象初始化顺序是未定义的。如果单例 A 的析构函数需要访问单例 B而 B 已经先被析构了那 A 就踩在悬垂对象上。解决方案单例的析构函数里不要做太复杂的操作尤其不要访问其他单例。日志系统尤其要注意因为打日志的时机几乎贯穿整个程序生命周期。我实际项目中有一个很实用的手法在单例内部维护一个shared_ptr程序结束时通过一个独立的std::atexit钩子去销毁或者干脆让日志模块在单例内部使用in_place且析构时不 flush 外部资源的模式。最简单粗暴的应对是日志单例持有 sink 的shared_ptr而 sink 自身不依赖其他全局单例。把依赖方向理清生命周期问题就能避开大半。4.2 观察者模式里的悬垂引用与回调地狱问题场景一个 UI 组件订阅了数据模型的变化事件但组件已经销毁数据模型还在于是下次通知来临时调用了一个已经释放的对象的成员函数——C 里这是未定义行为表现为偶发的崩溃、随机数据错乱。根源观察者模式天生存在“发布者持有观察者的引用”的强耦合而 C 不自动管理生命周期谁释放、谁解绑必须明确。实战建议我的经验是别让发布者直接持有观察者的裸指针或shared_ptr。两种可靠做法发布者持有weak_ptrIObserver通知前lock()一下为空就跳过并顺便清理列表。观察者在析构函数里主动调用发布者的detach(this)。第二种做法需要观察者保存自己订阅过的发布者列表稍麻烦但完全掌控。如果用的是std::function版本强烈建议捕获的上下文都是可复制的值类型或者持有托管对象的shared_ptr副本。记住一条铁律在 C 观察者模式里比“通知不到”更可怕的是“通知到一个已死对象”。4.3 “为了模式而模式”造成的抽象爆炸问题场景一个只有两种产品的小模块硬是照着抽象工厂搭了 4 个类层级再加 2 个工厂类。功能没变复杂代码量翻倍新同事接手看了半天只记住了一堆接口。反思这是我踩过最大的坑。设计模式的初衷是简化问题但如果问题本身只有一棵树你非得给它配一片森林那模式就成了复杂度之源。Bjarne Stroustrup 有句话“没有设计模式的编程是一种野蛮行为但盲目的模式崇拜则是另一种野蛮行为。”我现在的准则是抽象必须由两三个以上的具体实现来支撑否则不抽象变化点至少要出现两次否则缓引入模式。用生活化的说法一条路上只有一个十字路口你不会为它修一座高架桥。等车真的多起来再架桥不迟。4.4 现代 C 工具与模式结合时的性能误区std::function很方便但方便不等于免费。一个std::function可能涉及堆分配当捕获的 lambda 体积超过对象本身容量时、类型擦除、虚函数调用内部实现需要三层开销。对性能敏感的代码比如每帧都要执行几千次的回调用std::function可能就不是明智的选择。传统虚函数继承体系也不是零成本——虚函数调用是间接跳转现代 CPU 分支预测可能要吃亏。想极致性能可以用 CRTP 或模板参数把多态压制到编译期template typename SinkImpl class SinkBase { public: void write(const std::string s) { static_castSinkImpl*(this)-writeImpl(s); } };这样既实现了“一个接口多个实现”的思想又没有任何运行时开销。代价是代码可读性和类型灵活性下降。所以我的选择逻辑是默认用虚函数或std::function性能分析确认热点之后再优化成模板版本。别一开始就把代码写成了别人看不懂的模板迷宫。个人体会与一个小技巧设计模式这东西看书是学不会的开会讨论 UML 也是学不会的只有拿着一个坏味道十足的代码块一行行重构成模式结构时脑子里那根弦才真正接通。我给自己定的训练方法是每次接到新需求先问自己三个问题——“这个模块以后有几种变化方向”“这些变化方向现在有没有捆在一起”“能不能用最简单的方式拆开它们”想清楚这三点设计模式自然而然地就在代码里生根了。最后分享一个实用小技巧用设计模式看书时旁边的流程图画得再漂亮也不要抄进代码里。画在纸上想清楚合上书让你的代码在保持简洁的前提下自然去拟合那个思想——而不是让你亲手写的代码去跪舔一张查到的截图。模式是思想脚手架建完楼脚手架就该消失。留下的只有结构清晰、扩展顺畅的代码本身。
返回列表