ARTICLE DETAIL

资讯详情

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

C++设计模式实战:从值语义到RAII,避开Java思维陷阱

C++设计模式实战:从值语义到RAII,避开Java思维陷阱 设计模式在 C 里是个挺特别的话题。很多人学设计模式拿 Java 那套直接往 C 上套结果项目里不是内存泄漏就是多线程崩溃然后开始怀疑模式本身有问题。其实不是模式的锅是 C 的“脾气”跟 Java 不一样。值语义、RAII、模板、多继承这些特性决定了同一个模式在 C 里往往有更贴近底层的写法也踩过更多的坑。这篇文章既照顾刚学完 C 语法、准备做设计模式大作业或者期末复习的同学也照顾已经写了两三年业务代码、面试前想系统梳理一遍的从业者。我会从模式的分类讲起把每种模式在 C 里最容易出问题的点挑出来说重点讲“为什么 C 不能照抄 Java 写法”然后给出我认为比较靠谱的实现方案和避坑经验。1. C 里学设计模式先别急着照搬 Java1.1 值语义与对象语义C 的第一道分水岭GoF 那本《设计模式》成书于 1994 年背景是 Smalltalk 和 C 混用的年代。书里的模式描述大量基于“对象引用”“对象创建”“对象析构”这些概念默认你是通过指针或引用来操作对象的。而 Java 继承了这个取向所有对象都是引用语义new 出来一个就是一个堆对象没有“意外拷贝”这回事。但 C 不一样。你写MyClass a b;的时候如果 MyClass 没禁拷贝这就是一次实实在在的拷贝构造。这个差异直接改变了一批模式的实现路径。比如原型模式Java 里普遍是clone()方法返回对象副本C 里如果类是值语义的拷贝构造本身就是一个原型复制器你甚至不太需要刻意实现一个clone()虚函数。反过来讲如果类里有裸指针资源你又不能简单用拷贝构造顶替必须遵守三五法则好好写拷贝和移动。很多人在 C 里实现抽象工厂返回值用std::shared_ptrAbstractProduct这没问题。但要注意 C 还讲究“返回什么语义”你是想让调用方共享所有权还是独占所有权还是说仅仅借用一下不接管生命周期。这个思考维度在 Java 里几乎不存在在 C 里直接决定你的接口该怎么设计。1.2 RAII所有模式都绕不开的底层逻辑RAIIResource Acquisition Is Initialization是 C 最核心的惯用法资源在构造函数里获取在析构函数里释放。它表面上看只是个“内存管理技巧”实际上它改变了你实现很多模式的方式。拿观察者模式举例。Java 里标准做法是subject.addObserver(observer)程序退出前再removeObserver做不干净也不至于炸。C 里如果你用一个全局主题对象注册了一堆观察者对象某些观察者先析构了主题还保留着指向它的裸指针下一次事件广播就会发生空悬指针访问运气好是崩溃运气差是读到已经释放的堆内存里的残留数据定位起来极为痛苦。所以在 C 里我习惯把观察者注册做成 RAII 式订阅对象构造时把自身地址注册到主题析构时自动取消订阅。这就是把“生命周期管理”塞进了模式的实现细节里而不是等调用方去“记得”清理。类似的思路也应用在装饰器、代理这些涉及包装关系的模式上。1.3 虚函数与模板C 有两条路实现同一个模式最初的设计模式都建立在“继承 虚函数”的多态体系上。C 除了这套还有模板这个天然武器。模板偏重编译期绑定虚函数偏重运行期绑定。这就导致很多模式在 C 里有两派写法经典的多态写法和模板风格的写法。比如策略模式经典写法是定义IStrategy抽象基类每个策略一个派生类运行时注入。但在 C 里如果策略在编译期就能确定直接用模板参数注入零虚函数开销代码反而更直白。还有访问者模式经典实现依赖双分派和继承体系但现代 C 提供std::variantstd::visit在类型集合固定的场景下直接干掉一坨继承结构。这算不算“设计模式”我认为算。模式的核心是“解决问题的方案”而不是“必须使用虚函数”。在 C 里写模式你得先想清楚这个变化点是运行期变化还是编译期变化。运行期变化用虚函数那套编译期变化用模板那套两者强行互换只会让代码又慢又绕。2. 创建型模式对象从哪来是个大问题2.1 单例模式线程安全只是及格线单例是 23 种模式里最“简单”也最容易被写崩的一个。初学者版本常是这样class Singleton { public: static Singleton* getInstance() { if (m_instance nullptr) { m_instance new Singleton(); } return m_instance; } static Singleton* m_instance; };这个版本的问题大家都熟了线程不安全多线程下可能 new 出两个实例甚至发生内存泄漏。加锁版本能解决线程安全但锁开销和代码啰嗦程度都上来了。C11 之后我建议直接用 Meyers Singletonclass Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() default; };这个写法的关键是函数内的局部静态变量初始化是线程安全的编译器会帮你生成保护代码C11 开始这是标准保证的不是行为未定义。返回引用而不是指针也杜绝了调用方误删单例的风险。析构函数在程序退出时自动调用资源不会泄漏。不过这还不够。单例最容易翻车的点是依赖顺序。如果两个单例 A 和 BA 的构造函数里调用了 B 的实例而 B 的构造函数又调用了 A 的实例在静态初始化阶段就会陷入死锁或未定义行为。我的经验是单例的构造函数里绝对不要调用其他单例如果有依赖拆成显式的初始化函数按业务顺序执行。2.2 工厂模式从裸指针到 unique_ptr工厂模式本身不复杂复杂的是返回值怎么写。看很多老代码喜欢这么干Product* createProduct(const std::string type) { if (type A) return new ProductA(); if (type B) return new ProductB(); return nullptr; }裸指针返回的隐患一眼可见谁负责 delete如果调用方忘了删泄漏如果中间抛了异常连删除机会都没有。C 里正解是直接用智能指针做返回值std::unique_ptrProduct createProduct(const std::string type) { if (type A) return std::make_uniqueProductA(); if (type B) return std::make_uniqueProductB(); return nullptr; }调用方拿到unique_ptr之后所有权转移清清楚楚。如果你需要多个地方共享产品对象再转成shared_ptr也不迟。工厂方法一般用一个注册表避免一长串 if-else 越来越臃肿using FactoryFunc std::functionstd::unique_ptrProduct(); std::unordered_mapstd::string, FactoryFunc registry; // 注册 registry[A] [] { return std::make_uniqueProductA(); }; // 创建 auto product registry.at(A)();这种用 map 保存工厂函数的方式在游戏开发和服务端代码里都很常见。新增产品类型时你只需要往注册表里塞一条不用动已有逻辑符合开闭原则。要注意的是 map 的键和查找的字符串要统一管理最好抽成常量避免魔法字符串混乱。2.3 建造者模式链式调用与参数校验建造者模式在 C 里表现力很强尤其适合那种构造函数参数巨多的类。直接给构造函数塞七八个参数调用方根本分不清哪个是哪个还容易传错。用建造者可以做到class HttpRequestBuilder { public: HttpRequestBuilder setUrl(const std::string url) { m_url url; return *this; } HttpRequestBuilder setMethod(const std::string method) { m_method method; return *this; } HttpRequestBuilder setHeader(const std::string k, const std::string v) { m_headers[k] v; return *this; } HttpRequest build() { // 这里可以做参数校验 if (m_url.empty()) throw std::invalid_argument(url is required); return HttpRequest(m_url, m_method, m_headers); } private: std::string m_url; std::string m_method GET; std::mapstd::string, std::string m_headers; };注意 setter 返回自身引用实现链式调用最终调用 build()。关键点是把“参数合法性校验”集中在 build() 里这样业务代码构造请求时不会被一堆校验逻辑打乱。实际项目里我还会在 build() 里区分哪些参数是必填、哪些有默认值必要的时候用状态标志记录“是否调用某个 setter”。建造者模式额外的优势是支持不可变对象HttpRequest的字段都是 const 或只读所有可变化的部分都发生在 builder 内部。这在并发环境里非常省心对象一旦构造出来就不会被改坏。3. 结构型模式接口之间怎么粘3.1 适配器模式包装外部代码的常规操作适配器模式是日常开发中出现频率最高的模式之一只是你不一定意识到自己在用它。最常见的场景是老系统里有一个类提供了一组接口新模块要求另一种接口你又不能直接改老类因为改一处可能影响几十处调用。怎么办写一个适配器把新接口的请求翻译成老接口的调用。C 里实现适配器我建议优先考虑组合而不是继承。适配器内部持有一个旧接口的对象或者引用、指针对外暴露新接口class NewInterface { public: virtual ~NewInterface() default; virtual void execute(const std::string cmd) 0; }; class LegacyAdapter : public NewInterface { public: LegacyAdapter(LegacyService* svc) : m_svc(svc) {} void execute(const std::string cmd) override { // 转换为旧接口的参数 m_svc-runLegacy(cmd, 0); } private: LegacyService* m_svc; };继承适配器接口是为了满足多态内部组合老对象是为了复用其实现。组合优于继承这条原则在适配器场景下非常适用因为继承旧类会把旧类的实现细节也带进来破坏封装。常见问题适配器的生命周期。如果LegacyAdapter持有一个裸指针指向LegacyService但LegacyService先被销毁了execute就是空悬调用。工程上要么用shared_ptr共享所有权要么让适配器和被适配对象生命周期严格对齐用代码注释或者 RAII 机制约束。不要觉得这是小事我见过一个项目跑一两个月才偶发崩溃最后定位到就是适配器里的裸指针空悬。3.2 装饰器模式运行时叠加功能装饰器模式的本质是“组合一套接口相同、实现层层包装的对象”让调用方感觉是在用一个对象实际上每个方法都被各层装饰器处理过一遍。C 里最典型的场景是流类std::ifstream加boost::iostreams的各种过滤器一层套一层读数据的时候自动完成压缩、加密、编码转换。我实现装饰器时的习惯是抽象基类定义接口并保存一个“被装饰对象”的引用或指针每个装饰器只做自己的事然后调用被装饰对象的方法。代码结构大概是class DataSource { public: virtual ~DataSource() default; virtual void write(const std::string data) 0; }; class FileSource : public DataSource { /* 真正写文件 */ }; class EncryptionDecorator : public DataSource { public: EncryptionDecorator(DataSource* wrapped) : m_wrapped(wrapped) {} void write(const std::string data) override { auto encrypted encrypt(data); m_wrapped-write(encrypted); } private: DataSource* m_wrapped; };需要谨慎的是对象的生命周期装饰器析构时是否负责析构m_wrapped这又是一个所有权问题。推荐的做法是装饰器只组合不拥有析构时不动底层对象让创建者统一管理整条对象链。或者干脆用shared_ptr所有装饰器都持有一份所有权谁最后释放由引用计数决定。两种方案都可以但必须写清楚接口注释别让人猜。3.3 代理模式与所有权别把代理做成裸指针裸奔代理模式常被跟装饰器混淆。区别在于意图代理控制访问方式装饰器增强功能。C 里常见的代理有远程代理、虚拟代理延迟加载大对象、智能指针本身也算一种代理。实现代理最需要注意的还是生命周期。比如虚拟代理内部持有真实对象的裸指针class ImageProxy : public Image { public: void draw() override { if (!m_real) { m_real new RealImage(loadFromDisk()); } m_real-draw(); } private: RealImage* m_real nullptr; };如果ImageProxy析构时不 deletem_real内存就泄漏了如果调用方同时持有了RealImage又可能双重释放。我通常会写成内部用std::unique_ptrRealImage持有真实对象这样析构自动释放又避免了共享所有权。如果你是希望调用方和代理共享同一个真实对象那用shared_ptr更合理两者目的完全不同动手前先想清楚。现代 C 里std::shared_ptr的别名构造函数shared_ptrBase(shared_ptrDerived)本质上就是一种代理它把 Base 的接口暴露出去但底层对象是 Derived同时正确维护引用计数。这种利用标准库完成代理语义的思路比手写代理类要稳得多。4. 行为型模式把变化封装成一等对象4.1 策略模式从虚函数基类到 std::function策略模式的核心是“用组合代替继承把算法抽出来独立变化”。教科书做法是这样class IStrategy { public: virtual ~IStrategy() default; virtual int calculate(int a, int b) 0; }; class AddStrategy : public IStrategy { int calculate(int a, int b) override { return a b; } }; class Context { public: void setStrategy(IStrategy* strategy) { m_strategy strategy; } int doCalc(int a, int b) { return m_strategy-calculate(a, b); } private: IStrategy* m_strategy; };这个写法完全正确但 C 实操里策略往往不是“一整类对象”而只是一个函数或一个 lambda。你为了一个 lambda 去写一个派生类实在是杀鸡用牛刀。C11 之后我更推荐class Context { public: using Strategy std::functionint(int, int); void setStrategy(Strategy strategy) { m_strategy std::move(strategy); } int doCalc(int a, int b) { return m_strategy(a, b); } private: Strategy m_strategy; };调用时直接塞 lambdacontext.setStrategy([](int a, int b) { return a * b; });。这本质上仍然是策略模式只是策略载体从“对象”变成了“可调用物”。好处是轻量坏处是如果策略内部需要复杂状态还是老老实实用对象。我个人的取舍标准策略超过三个成员变量或者有内部缓存就用传统策略接口策略只是一个算法片段就用std::function。不要为了“模式正统性”牺牲代码简洁度。4.2 观察者模式生命周期的血泪教训观察者模式是 GUI、事件系统、消息总线的老熟人。C 实现它最头疼的不是“如何通知”而是“通知给谁”。主题对象里存观察者存裸指针有悬空风险存shared_ptr又有两个问题一是主题会延长观察者生命周期违背预期二是观察者回调里可能操作主题本身形成死锁或重入。我的建议分两种场景如果观察者生命周期明确比主题短而且必然在线程安全的节点上被销毁可以用裸指针加“标记销毁”机制如果项目里对象生命周期非常混乱最好用弱指针方案class Subject { public: void attach(const std::weak_ptrObserver obs) { m_observers.push_back(obs); } void notify() { for (auto it m_observers.begin(); it ! m_observers.end();) { if (auto obs it-lock()) { obs-update(); it; } else { it m_observers.erase(it); // 清理已失效的观察者 } } } private: std::vectorstd::weak_ptrObserver m_observers; };weak_ptr方案的好处是观察者析构后通知时自动跳过不会空悬。代价是每次通知都要 lock 一次性能有一点损耗但绝大多数业务场景毫秒级都无所谓。真正需要注意多线程下notify被并发调用的问题怎么加锁、怎么避免回调中再次调用 attach 导致迭代器失效这些是观察者模式在 C 里的深水区面试时能聊到这个层面会很加分。还有一个小 Tip回调里如果执行了耗时操作比如网络请求或磁盘 IO最好把通知改成“投递事件到队列异步处理”否则一个慢观察者会卡住整个主题的广播。4.3 访问者模式双分派与 std::variant访问者模式是 23 种模式里最难懂的几个之一。它解决的核心问题是“在类型稳定的结构上增加新操作”。经典实现有两个关键accept 方法里调用访问者的 visit访问者里再根据具体类型调用对应重载形成双分派。C 经典写法代码量很大但结构还算清晰class Visitor; class Element { public: virtual ~Element() default; virtual void accept(Visitor v) 0; }; class ElementA : public Element { public: void accept(Visitor v) override { v.visit(*this); } }; class Visitor { public: virtual ~Visitor() default; virtual void visit(ElementA e) 0; // 每个具体元素一个重载 };这个模式真正的问题是每新增一个 Element 子类所有 Visitor 都要加一个 visit 重载对扩展并不友好。所以现代 C 里如果元素类型是有限集我更喜欢用std::variantusing Element std::variantCircle, Square, Triangle; void draw(const Element e) { std::visit([](const auto shape) { shape.draw(); }, e); } double area(const Element e) { return std::visit([](const auto shape) - double { return shape.area(); }, e); }这里std::visit做的就是“类型分派”而variant本身就是类型安全的联合体。新增一种形状只需要扩展 variant 的模板参数函数内部用autolambda 自动适配。这种风格在编译期就完成了类型的穷举检查少了很多运行期虚函数分发。不过也要注意如果类型集合经常变variant会让所有visit的使用点都参与编译编译时间会上升类型集稳定时这个方案性价比很高。5. 23种模式的记忆框架与C实现要点5.1 一张表理清三大类模式面试和期末考试都是围绕 GoF 23 种模式出题。我建议先把分类框架背进脑子里再来补细节。这张表就是我的速查底稿分类模式C 核心要点创建型单例C11 Magic Static、禁拷贝、依赖顺序创建型工厂方法返回 unique_ptr、注册表驱动创建型抽象工厂产品族一致性、容器管理产品创建型建造者链式 setter、build 时统一校验创建型原型拷贝构造与 clone() 的选择结构型适配器组合优先、生命周期对齐结构型装饰器层层包装、所有权明确结构型代理控制访问、延迟加载、智能指针替代结构型外观门面类封装子系统不暴露内部结构型组合树形结构、透明/安全取舍结构型桥接抽象与实现分离接口和平台解耦结构型享元共享内在状态、避免重复对象行为型策略std::function 或策略接口行为型观察者weak_ptr 订阅、异步通知行为型模板方法NVI 非虚接口、钩子方法行为型状态状态对象封装行为切换行为型命令请求对象化、可撤销行为型责任链链表式处理器、请求传递行为型迭代器STL 迭代器风格、begin/end行为型中介者对象间交互集中到中介者行为型备忘录状态快照、不破坏封装行为型访问者双分派、std::variant 替代行为型解释器AST 节点递归解释、语法树记忆方法上可以按口诀走创建型“工抽单建原”结构型“适装代外组桥享”行为型“策观模状命责迭中备访解”。先念熟再做题比硬记英文名效率高很多。5.2 面试和考试里躲不开的坑面试题里那些“说说单例模式”“什么是工厂模式”都是开胃菜真正刷人的是这几个第一让手写一个线程安全的单例。很多人上来就写双检锁却忽略了内存序。C11 以后正确答案应当是 magic static 或带std::call_once的版本。双检锁也可以用但必须配合std::atomic的 acquire/release 语义写错就是未定义行为。第二让比较“装饰器”和“代理”模式的区别。很多人只背定义一举例就露馅。核心区分是意图装饰器增强功能代理控制访问。第三让设计一个“支持撤销的命令模式实现”。这时候得把命令对象保存栈、每次执行压栈撤销时弹栈调用unexecute()这属于业务设计能力比较拉开差距。还有一类高频题C 里实现观察者模式要注意什么如果你只答“用列表存观察者”面试官大概率会追问“观察者对象被释放了怎么办”“多线程广播怎么保证安全”。所以前面写的那套 weak_ptr 异步队列方案建议好好消化面试时能画个结构图说明就非常加分。5.3 我的学习与复现顺序建议如果现在让你自己从零学完 23 种模式我不建议按目录顺序一个个来那样学到后面容易泄气。我给一个按“上手难度 实用频率”排列的顺序第一批单例、工厂方法、策略、观察者、模板方法。这几个日常写业务代码几乎天天碰代码量小适合建立信心。第二批抽象工厂、建造者、适配器、外观、装饰器、状态、命令。这些需要理解对象之间的关系能帮你建立“模式组合使用”的意识。第三批桥接、组合、代理、责任链、中介者、解释器、迭代器、备忘录、享元、访问者、原型。低频但面试会问特别是代理、访问者、享元。实操方法上我建议每个模式至少写一个“脱离业务的小例子”比如单例写日志管理器观察者写事件总线命令写撤销栈。用 VSCode 配好 C 环境建一个简单的 CMake 工程把每个模式单独放一个文件夹编译跑通、加断点看调用链。真实调试一遍比你对着书看十遍都管用。我当年就是这样花两周把 23 个例子全过了一遍之后面试聊设计模式基本不带怕的。6. 避开这些 C 特定的大坑6.1 容器存基类对象导致切片实现组合模式或者观察者列表时有人图省事直接std::vectorBase。这是个大坑基类对象存进容器后派生类部分会被“切掉”只剩基类子对象多态直接失效。C 容器存对象是值语义要么存指针、要么存智能指针。标准做法是std::vectorstd::shared_ptrBase或std::vectorstd::unique_ptrBase前者用于共享后者用于独占。切片的坑不只在容器函数参数传值时也一样。所以设计模式的接口方法里凡是涉及多态对象的参数记得用指针或引用传递别按值传。刚开始写 C 模式代码很容易忽略这一点等调试的时候发现调用的总是基类方法再回来改就要动不少代码了。6.2 回调里的 Access Violation很多 C 程序运行一段时间后偶发崩溃报错类似0xC0000005 access violation排查到最后往往发生在回调函数里。这正是观察者模式或者命令模式最容易踩的地雷回调触发的时候回调对象已经析构了或者回调内部访问的资源已经被释放。我处理这类问题的经验是所有回调注册的入口都要求调用方明确传递对象的生命周期保证。做不到就用weak_ptr方式注册执行回调前先 lock 检测有效性。另外在一个对象内部注册回调时析构函数里务必要有取消注册的动作。如果你实现了 RAII 风格订阅这一步就自动覆盖了。线上环境里“偶发崩溃”往往就是这么来的查起来极其消耗时间所以在一开始写模式代码时就该把生命周期当成接口约定的一部分。6.3 多线程下的模式失效很多单例、观察者、命令模式在多线程下会出现诡异问题。单例除了初始化要线程安全还面临“实例不再使用但业务线程还在访问”的问题这时析构时机很难把控。观察者广播和订阅线程不一致时容易产生数据竞争。命令模式的撤销栈如果被多个线程操作必须加锁或者改成无锁队列。我一般不推荐在设计模式代码里到处加锁更建议从顶层设计规避单例在程序启动阶段就创建不要中途销毁观察者广播在单独的事件线程投递订阅和退订都通过队列命令执行串行化。这些手段比给每个方法加锁要清晰得多。7. 学习设计模式的进阶心得模式这东西知道多少不重要用对地方才重要。我见过一些人学了模式以后把所有类都套上抽象基类接口满天飞getter/setter 绕三层实际上业务逻辑就两三行。过度设计比没有设计更折磨人因为代码可读性严重下降后维护的人骂娘。反而是那些看起来“不够模式”的代码如果能做到单一职责清晰、依赖方向明确、生命周期可控就已经解决了大部分工程问题。设计模式是工具箱不是装修模板。每个类都要套模式那叫仪式感不叫工程能力。我的体会是真正掌握一个模式要经历三个阶段。第一阶段能照着例子写出来第二阶段能在自己的项目里识别出“这里适合用这个模式”第三阶段能根据形势调整模式的经典结构让它更好地贴合具体场景。比如策略模式在 C 里用std::function改造就是这个道理。别为了“保持模式原型”而拒绝改进模式是起点不是终点。如果这篇文章能帮你在设计模式大作业里少走点弯路或者在面试前把 C 版模式的关键点理顺那就值了。写代码这么多年回头看那些调了一整天才发现的 bug基本都是生命周期和所有权的问题这也正是 C 设计模式实现里最值得花心思琢磨的地方。
返回列表