ARTICLE DETAIL

资讯详情

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

现代C++设计模式实战:避开常见坑,用智能指针与RAII写出优雅代码

现代C++设计模式实战:避开常见坑,用智能指针与RAII写出优雅代码 如果让我选一个C项目里最容易被高估、也最容易被低估的技术点我会选设计模式。说它被高估是因为很多人把23种模式背得滚瓜烂熟一到写代码仍然只会复制粘贴说它被低估是因为真正用得好的设计模式能直接决定一个C项目能撑多久、能改多快。这篇文章不打算把23种模式逐个念一遍而是站在实际写C工程的角度聊聊哪些模式在C里最常用、最实用哪些实现方式藏着C特有的坑以及怎么用现代CC11/14/17把这些模式写得更简洁、更安全。适合正在准备软考、期末设计模式考试的同学也适合刚入门C、想在项目里落地设计模式的开发者。1. 为什么我要在C里重新聊设计模式1.1 设计模式不是Java的专利但C的实现确实更“拧巴”设计模式这个概念最早由GoFGang of Four在1994年的《Design Patterns: Elements of Reusable Object-Oriented Software》里系统整理那本书里的示例代码用的就是C和Smalltalk。也就是说C才是设计模式的老家。但你如果去搜网上的教程十篇里有八篇是Java版这导致一个很尴尬的局面很多人把Java里的单例、工厂背得滚瓜烂熟回到C项目里一写就翻车。翻车的原因不复杂。C和Java在对象模型上有本质差异Java有垃圾回收C要自己管内存Java的虚方法默认多态C要手动加virtualJava的类对象天生可以拷贝C要面对深浅拷贝、移动语义、析构函数这一堆生命周期问题。设计模式说白了是一套“在特定情境下解决问题”的套路语言变了套路的具体姿势就必须跟着变。比如一个单例模式Java里惰性初始化用双重检查加volatile就完事C里一个局部静态变量就解决了还顺手把线程安全也解决了。1.2 在掌握23种模式之前先确认这几块C地基是否扎实很多人学设计模式感觉吃力其实不是模式本身难而是C基础没过关。模式是对类和对象的组织方式你连虚函数、虚析构、const正确性、智能指针都没玩明白看任何一个模式都会觉得“这代码怎么这么绕”。我给自己带的新人列过一个最低清单把它称之为“理解设计模式的前置知识”虚函数与动态绑定理解virtual、override、纯虚函数这是多态的基础也是大部分模式的基石。虚析构函数基类析构函数不写virtualdelete基类指针就是未定义行为这几乎是我见过最多的C初学者崩溃原因。RAII与智能指针unique_ptr、shared_ptr、weak_ptr的语义以及它们和“对象所有权”的关系这直接决定你写出的模式是安全还是满身漏洞。拷贝与移动语义拷贝构造函数、拷贝赋值、移动构造、移动赋值以及delete、default的用法这决定了你的单例、原型模式能不能写对。模板与泛型模板是C实现策略、观察者、工厂等模式的重要工具不要求你精通模板元编程但至少能看懂模板类和模板函数。如果上面这几项里有含糊的先把地基补上再回来看设计模式你会觉得大部分模式其实很简单。1.3 一份更适合C的23种模式学习路线23种模式一次性学完绝大多数人撑不过两周因为记不住也用不上。我的建议是分三批消化第一批最常用单例、工厂方法、抽象工厂、观察者、策略、模板方法、适配器、外观、装饰器。这9个在业务代码里出现频率极高先把它们吃透。第二批常用但场景受限建造者、原型、代理、组合、迭代器、状态、命令、职责链、备忘录、中介者。这些在特定场景很有用但不必死记用到时能想起来就行。第三批C里可以被语言特性替代桥接、享元、解释器、访问者。不是说它们没用而是在现代C里往往有更简洁的替代方案比如访问者模式在C17里用std::variant可以直接干掉。这篇文章下面我会逐个突破最核心的几组模式用C给出可直接编译的实现和拆解。2. 创建型模式从单例到工厂哪些坑我替你踩过2.1 单例模式最熟悉也最容易翻车的一个单例模式可能是被滥用得最厉害的模式但它依然是面试和大作业的常客。先看一个现代C里最推荐的单例写法class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; void doSomething() { // ... } private: Singleton() default; ~Singleton() default; };这个写法在C11之后是完全线程安全的。C标准规定函数内局部静态变量的初始化是线程安全的编译器会在第一次进入getInstance时自动加锁。这比Java里那一套双重检查锁加volatile的写法优雅得多。我把这种写法叫作“Meyers Singleton”因为Scott Meyers在《Effective C》里推荐过。这个模式有三个坑都是我在真实项目里踩过的第一不要返回裸指针。static Singleton* getInstance()这种写法容易让人在外面误delete一旦delete了第二次调用getInstance就返回悬垂指针。返回引用从语义上就告诉调用者“这对象不归你管”。第二析构函数不要写成public。如果外部代码执行delete操作或者不小心把析构函数权限放开了单例的“唯一性”就彻底崩了。把构造函数和析构函数都放private配合delete拷贝才算把一个类真的钉成单例。第三不要在单例析构里访问其他单例。C里静态局部变量的析构顺序和构造顺序相反两个单例互相依赖时析构顺序会产生难以排查的崩溃。2.2 工厂家族简单工厂、工厂方法、抽象工厂到底怎么选工厂模式是三个模式不是一个大而全的东西。我见过太多大作业把三个混在一起写最后代码膨胀到谁都不想看。简单工厂Simple Factory其实不算GoF的正式模式它就是一个函数根据参数返回不同类型对象。C里最朴素的实现是这样的enum class ProductType { A, B }; class Product { public: virtual ~Product() default; virtual void use() 0; }; class ProductA : public Product { public: void use() override { std::cout Product A\n; } }; class ProductB : public Product { public: void use() override { std::cout Product B\n; } }; std::unique_ptrProduct createProduct(ProductType type) { switch (type) { case ProductType::A: return std::make_uniqueProductA(); case ProductType::B: return std::make_uniqueProductB(); } return nullptr; }这里有个核心细节返回值应该是std::unique_ptr 而不是Product*。C14开始有了std::make_unique返回智能指针之后调用方不需要手动delete异常安全也顺带解决了。这是C实现工厂和Java实现工厂最大的区别Java里new完直接返回C里得考虑“创建出来的对象谁负责释放”。简单工厂的问题在于每加一个产品类型就得改createProduct里的switch违背开闭原则。这时候工厂方法模式登场把创建动作延迟到子类。抽象工厂则是工厂方法套了一层专门解决“一系列相关产品”的创建。以我的经验除非你的产品族真的很复杂比如一个UI框架同时要创建按钮、输入框、弹窗否则大部分场景用简单工厂就够。C里还能用std::map std::function做注册表式工厂新增产品无需改动工厂代码这是我在插件架构里最常用的技巧class ProductFactory { public: using Creator std::functionstd::unique_ptrProduct(); static bool registerCreator(const std::string name, Creator creator) { creators()[name] std::move(creator); return true; } static std::unique_ptrProduct create(const std::string name) { auto it creators().find(name); return (it ! creators().end()) ? it-second() : nullptr; } private: static std::mapstd::string, Creator creators() { static std::mapstd::string, Creator instance; return instance; } };注册的动作可以直接放在某个静态变量的初始化里。比如在ProductA的.cpp文件里写一行static bool registered ProductFactory::registerCreator(A, []() { return std::make_uniqueProductA(); });括号里这段基于常见实践的补充写法是插件架构里解耦的核心思路强烈建议在项目里试一试。2.3 建造者模式和原型模式的C改写要点建造者模式在Java里有很出名的流式调用写法C一样能写。核心是让每个setter返回this的引用然后链式调用class Dialog { public: Dialog setTitle(const std::string t) { title t; return *this; } Dialog setWidth(int w) { width w; return *this; } Dialog setHeight(int h) { height h; return *this; } // ... private: std::string title; int width 0; int height 0; }; Dialog dlg; dlg.setTitle(提示).setWidth(400).setHeight(300);这里必须注意如果Dialog里有指针或智能指针成员直接返回*this的拷贝赋值会有问题。所以写流式接口时最好把拷贝构造和拷贝赋值delete掉或者把所有成员都设计成可安全复制的值类型。这两种方案我都用过经验是消息弹出框这种轻量级类直接值类型成员最省心如果类里带着数据库连接或者网络会话这种“不可拷贝”的资源老老实实delete拷贝只保留移动语义。原型模式在C里最大的麻烦是“深拷贝还是浅拷贝”以及“拷贝时虚函数怎么处理”。C里实现clone接口标准姿势是每个子类都override一个返回自身类型unique_ptr的clone方法class Shape { public: virtual ~Shape() default; virtual std::unique_ptrShape clone() const 0; }; class Circle : public Shape { public: std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); } private: double radius 0.0; };注意基类的clone返回unique_ptr 子类返回unique_ptrC里允许协变返回类型。但前提是子类的拷贝构造得是健全的。如果你的Circle里有裸指针浅拷贝就会让两个对象指向同一块内存析构时二次释放。遇到这种情况先拷对象再拷资源或者干脆用智能指针管理资源。3. 结构型模式组合与适配的实现细节3.1 适配器模式用组合把旧接口包成新接口适配器模式解决的是“接口不匹配”问题。我举个实际的例子一个老项目里有个ReportGenerator类提供的接口是generateReport(std::string format)新项目里统一要求调用generatePdf()、generateExcel()。不改老类的情况下写一个适配器class NewReportGenerator { public: virtual ~NewReportGenerator() default; virtual void generatePdf() 0; virtual void generateExcel() 0; }; class ReportAdapter : public NewReportGenerator { public: explicit ReportAdapter(std::shared_ptrReportGenerator oldGen) : oldGen_(std::move(oldGen)) {} void generatePdf() override { oldGen_-generateReport(pdf); } void generateExcel() override { oldGen_-generateReport(excel); } private: std::shared_ptrReportGenerator oldGen_; };这里推荐对象适配器而不是类适配器。类适配器靠多继承两个类来适配C虽然支持多继承但稍有不慎就会出现菱形继承、二义性、甚至布局兼容问题。对象适配器只用一个旧对象的指针组合一个旧对象、实现一个新接口逻辑简单调试轻松。我早期喜欢写类适配器觉得代码少后来项目里遇到一次虚继承的噩梦从那以后统一改对象适配器了。适配器模式还有一个变体叫“接口适配”如果你只是想给某个类的函数换一个更友好的调用签名不一定需要新建一个类一个简单的包装函数就能搞定void exportDataToPdf(DataExporter exporter, const std::string data) { exporter.exportData(data, pdf); }这种轻量改编在C里非常常见也是最容易被忽视的适配器应用。3.2 组合模式与装饰器模式的现代C写法组合模式处理树形结构。一个经典场景是文件系统目录里可以放文件也可以放子目录。C实现组合模式时最需要注意的就是子节点的生命周期。用裸指针管理子节点析构函数要自己递归delete稍有不慎就内存泄漏用unique_ptr管理子节点组合对象被析构时整棵树自动释放干净利落。class Component { public: virtual ~Component() default; virtual void display() const 0; }; class File : public Component { public: explicit File(std::string name) : name_(std::move(name)) {} void display() const override { std::cout 文件: name_ \n; } private: std::string name_; }; class Directory : public Component { public: void add(std::unique_ptrComponent child) { children_.push_back(std::move(child)); } void display() const override { for (const auto child : children_) { child-display(); } } private: std::vectorstd::unique_ptrComponent children_; };这段代码里最关键的一行是children_.push_back(std::move(child))。如果你写成children_.push_back(child)编译器会直接报错因为unique_ptr不能拷贝。理解“unique_ptr是有所有权语义的只能移动不能拷贝”这个点组合模式就顺了。装饰器模式是在不修改原类的情况下给对象增加新职责在C里实现时同样建议用unique_ptr或shared_ptr。装饰器最大的坑是“包装层数多了之后析构顺序容易乱”而智能指针能保证正确的析构链。另外装饰器和组合容易让人混淆我的记忆方法很简单组合模式是“整体与部分”的树形包含关系装饰器模式是“一层套一层”的职责叠加。举个例子文件系统是组合给网络请求加日志、加压缩、加加密就是装饰器。3.3 用好std::variant很多结构型模式能少写一半代码说了这么多传统设计模式我想单独聊聊C17的std::variant因为它真的能改变你组织代码的方式。访问者模式原本要定义一堆visit方法用std::variant std::visit直接一个函数搞定using Value std::variantint, double, std::string; void printValue(const Value v) { std::visit([](auto arg) { using T std::decay_tdecltype(arg); if constexpr (std::is_same_vT, int) std::cout int: arg \n; else if constexpr (std::is_same_vT, double) std::cout double: arg \n; else if constexpr (std::is_same_vT, std::string) std::cout string: arg \n; }, v); }这个写法和访问者模式解决的问题一模一样对一组异构类型做同一套操作。但它完全不需要继承体系不需要virtual函数性能还更好。自从C17普及之后我在项目里基本不再手写GoF那种访问者模式了。如果你还在用C11/14又想实现类似效果可以用boost::variant如果你的编译器只支持C14那传统访问者模式还是值得学的——考试会考老项目里也还有大量存量代码。4. 行为型模式观察者、策略与回调的C实现思路4.1 策略模式从virtual函数到std::function策略模式的核心思想是“把算法抽出来运行时替换”。在Java里通常是定义一个策略接口然后写一堆实现类。在C里你有三档选择传统接口、函数指针、std::function。传统接口写法适合策略不多、且策略本身有状态的情况class SortStrategy { public: virtual ~SortStrategy() default; virtual void sort(std::vectorint data) 0; }; class BubbleSortStrategy : public SortStrategy { public: void sort(std::vectorint data) override { // 冒泡排序实现 } }; class QuickSortStrategy : public SortStrategy { public: void sort(std::vectorint data) override { // 快速排序实现 } }; class DataProcessor { public: explicit DataProcessor(std::unique_ptrSortStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::unique_ptrSortStrategy strategy) { strategy_ std::move(strategy); } void process(std::vectorint data) { strategy_-sort(data); // ... } private: std::unique_ptrSortStrategy strategy_; };如果策略只是简单的一两个函数用std::function会清爽很多完全不用建类class DataProcessor { public: using SortFunc std::functionvoid(std::vectorint); explicit DataProcessor(SortFunc func) : sortFunc_(std::move(func)) {} void setSortFunc(SortFunc func) { sortFunc_ std::move(func); } void process(std::vectorint data) { sortFunc_(data); } private: SortFunc sortFunc_; }; // 用法 DataProcessor processor([](std::vectorint data) { std::sort(data.begin(), data.end()); });这两种方案怎么选我的经验是当策略之间除了算法逻辑还有很多共享数据或成员函数时用类当策略只是“一段可以替换的逻辑”时用std::function。选std::function最大的好处是上下文可以直接用lambda、函数指针、或者std::bind的结果灵活性远高于接口实现。4.2 观察者模式信号槽思路比传统写法更适配C生命周期观察者模式在C里是真正的“坑王”。传统写法里Subject持有一堆Observer指针某个Observer在事件触发前被delete了触发时就变成悬垂指针直接崩溃。我在项目里处理这个问题的方案是观察者注册时传weak_ptr通知时先lock再调用。class IObserver { public: virtual ~IObserver() default; virtual void onEvent(const std::string event) 0; }; class Subject { public: void addObserver(std::weak_ptrIObserver observer) { observers_.push_back(observer); } void notify(const std::string event) { for (auto it observers_.begin(); it ! observers_.end(); ) { if (auto observer it-lock()) { observer-onEvent(event); it; } else { it observers_.erase(it); } } } private: std::vectorstd::weak_ptrIObserver observers_; };这个写法你有没有发现一个细节notify里一边遍历一边erase如果erase后直接it会漏元素或崩掉所以我在erase分支里就不了。这种小坑不自己写一遍根本不知道。更现代的做法是直接用信号槽库比如Qt的信号槽或者非Qt项目里用的boost::signals2。在C里手写观察者模式之前建议先评估一下是不是直接用现成的信号槽更划算如果项目不大手写一个weak_ptr观察者也就半小时的事但如果你要处理多线程通知、连接管理、事件过滤直接上boost::signals2能省很多事。4.3 模板方法模式C里一个被低估的“继承骨架”法术模板方法模式指的是基类定义算法骨架把某些步骤延迟到子类实现。这个模式在C里实现非常简单但它有一个非常值得说的点基类非虚函数调用虚函数驱动整个流程。class DataParser { public: // 这是模板方法定义了整个解析流程子类不要重写 void parse(const std::string input) { open(); readHeader(); readBody(); close(); } protected: virtual void readHeader() 0; virtual void readBody() 0; private: void open() { std::cout 打开输入流\n; } void close() { std::cout 关闭输入流\n; } }; class CsvParser : public DataParser { protected: void readHeader() override { std::cout 读取CSV表头\n; } void readBody() override { std::cout 逐行读取CSV正文\n; } };模板方法模式的坑在于子类重写了受保护虚函数但基类的parse流程必须严格控制调用顺序。如果基类在构造时调用虚函数就会出现经典问题——构造期间虚函数不会派发到子类实现因为子类还没构造完成。我在新人代码里见过好几次这种问题解决办法很简单把初始化工作放到专门的init()方法里不要在构造函数里调用虚函数。另外有个C特色变体叫CRTPCuriously Recurring Template Pattern它把“运行时虚函数派发”变成了“编译期静态绑定”性能更好代价是类型关系更难读。现代C里用CRTP做策略注入非常流行但如果你不熟悉模板我建议先掌握传统模板方法模式CRTP等需要时再深入。5. 设计模式大作业与软考面试速通策略5.1 23种设计模式记忆口诀按三大类先建立框架软考、期末、大作业很多人最怕的就是“23种模式记不住”。其实不用死记先把分类框架搭起来每种模式对应一个生活化场景自然就记住了。创建型5种单例、工厂方法、抽象工厂、建造者、原型。口诀我常用“单工抽建原”——一个工厂里先抽一根烟再建一个圆原形铁板。结构型7种适配器、桥接、组合、装饰器、外观、享元、代理。口诀“适桥组装外享代”——适合桥上的组装工在外面享受代练服务。行为型11种职责链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者。口诀难编我用的是场景记忆法一名观察者通过迭代器命令链按策略和状态模板访问对象中途把备忘交给中介者解释。口诀只是敲门砖真正要理解的是每种模式解决什么问题。我的记忆维度是“变化点”每看到一个模式先问自己它把哪部分变化封装起来了单例封装了实例唯一性工厂封装了对象创建逻辑观察者封装了通知关系的动态建立策略封装了算法替换。这样记下来考试遇到问你“某场景用什么模式”的题其实是在问“要封装什么变化”。5.2 C面试里最高频的5个设计模式题面试官考设计模式很少让你默写23种更多是围绕几个核心模式深挖。我整理过一份高频清单单例模式怎么保证线程安全C11局部静态变量的实现原理是什么观察者模式里观察者和被观察者的生命周期谁管理如果观察者先被释放怎么办工厂方法模式和抽象工厂模式的区别是什么用C给一个实际场景。策略模式和状态模式有什么区别注意很多面试官喜欢拿这两个对比。模板方法模式和策略模式有什么区别一个用继承一个用组合但都能达到“算法复用”的效果。回答这类问题时最好能随口给出C11之后的现代写法尤其是unique_ptr/make_unique、std::function、局部静态单例这些特性。面试官看重的不是你会不会背GoF的类图而是你能不能写出“能上生产环境的C代码”。5.3 用VS Code搭一个C设计模式实验环境大作业和自学都需要一个能快速验证代码的环境。VS Code MinGWWindows或自带clangmacOS/Linux是轻量选择。Windows下安装好MinGW后需要确认g在环境变量里。VS Code里安装C/C扩展然后写两个配置文件tasks.json编译跑起来用的{ version: 2.0.0, tasks: [ { type: cppbuild, label: C/C: g 生成活动文件, command: g, args: [ -fdiagnostics-coloralways, -g, -stdc17, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: { kind: build, isDefault: true } } ] }launch.jsonF5断点调试用的{ version: 0.2.0, configurations: [ { name: C 调试, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, args: [], stopAtEntry: false, cwd: ${fileDirname}, environment: [], externalConsole: false, MIMode: gdb, miDebuggerPath: gdb, preLaunchTask: C/C: g 生成活动文件 } ] }这段配置我自己每次搭环境都是直接复制实测可用。用VS Code写C的好处是不需要装几GB的IDE按CtrlShiftB编译按F5调试对学习设计模式来说完全够了。6. 常见问题与排查技巧实录6.1 编译期错误虚析构缺失导致的“delete不完整类型”新手写设计模式最容易踩的一个编译期问题是在基类析构函数没有virtual的情况下对基类指针执行delete。虽然很多情况下编译器不会直接报错但如果你尝试delete一个“声明为基类、实际指向子类”的对象在某些编译器下会出现“deleting object of abstract class type”或“cannot delete incomplete type”的报错。正确姿势已经在前面强调过基类析构一律virtual ~Base() default;。这几乎是所有多态模式的必备前置条件。如果你发现类里并没有虚函数可以不加virtual但只要类有任何virtual函数析构函数就一定要跟着virtual。这个规则我想强调两遍因为它是C所有OOP模式的地基。6.2 运行时崩溃Access Violation C0000005的常见来源热词里有一条是“C#调用C出现access violation c0000005”这其实是很多跨语言调用的经典问题C侧内存越界或者返回了悬垂指针C#这边一访问就直接非法访问。设计模式代码里最常见的c0000005来源有三个工厂返回裸指针调用方忘记delete二次调用时指针已经被其他代码覆盖。用unique_ptr能彻底规避。观察者模式里通知了一个已经析构的对象。用weak_ptr lock能规避。组合模式里父节点持有子节点裸指针但子节点被外部提前释放。用unique_ptr持有子节点能规避。你看三个常见来源的答案都是“换智能指针”。所以我在带人写C设计模式时第一个要求就是代码里不允许出现裸指针的new/delete配对一切由RAII接管。6.3 什么时候不该用设计模式别把简单问题复杂化聊到最后我必须说点“反模式”的话。设计模式的价值在于应对变化如果你的代码根本没有变化点硬套模式只会让代码变臃肿。比如你只有一个固定类型的日志对象整个项目里就一处调用那你写单例纯粹是自找麻烦你的排序算法永远不会换那策略模式的接口就是多余的间接层。我在项目里见过最惨的代码是为了“符合设计模式规范”给一个只有200行的小工具写了十几个类和接口文件最后没人愿意维护。我的个人经验是写代码之前先问自己三个问题——这段逻辑会有多个实现吗这些实现会在运行时切换吗调用方需要和具体实现解耦吗如果三个答案都是否别用模式写简单函数就够了。等你真的踩到“改一处要动十个文件”的痛感时再引入设计模式才是它发挥价值的时候。这也是为什么我在这篇文章里反复强调“现代C特性优先”因为它能让模式的使用成本降到最低。C11之后lambda、智能指针、std::function、std::variant这些工具让设计模式的很多经典写法的实现成本大幅下降。你用std::function一行替换策略接口、用std::variant替代访问者模式、用局部静态变量实现线程安全单例本质上就是用语言特性替代了模式的样板代码——这在我看来是C设计模式的正确打开方式。最后分享一个小技巧如果你把设计模式的大作业提交给老师记得在文档里写清楚“为什么选这个模式”而不仅仅是“我用了什么模式”。一个能说清“这个模式封装了什么变化、解决了什么问题、在C里遇到了什么坑、怎么用RAII解决”的作业比那些把23个模式堆砌一遍的大作业得分高得多。这一点在任何面试场景中也同样适用面试官问的是思路不是答案。C里的设计模式从来都不是背出来的。
返回列表