设计模式实战:SOLID原则、UML类图与五个高频C++模式解析
简介由王桂林整理的设计模式学习笔记PDF是一份面向软件开发者、架构师与中高级编程学习者的系统性参考资料旨在帮助读者理解并运用经典设计模式提升代码可维护性与扩展性。文档以GOF著作《Design Patterns》为线索从历史渊源讲起完整覆盖模式名称、问题、解决方案、效果等陈述要素并针对单一职责、开闭、依赖倒置、接口隔离等核心设计原则展开剖析配有代码示范、学习建议与推荐书目帮助读者既理解概念又能落地实践。资源为单份PDF电子文档大小约3.09MB内部按章节目录组织层次分明可通读学习也可按需检索特定原则或模式。已有157人学习适合自学、复习或在团队内部作为设计模式培训材料能帮助读者快速建立起完整的设计模式知识框架并减少开发中重复踩坑。1. 设计模式面试八股还是重构后悔药设计模式在项目里从来不是加分题而是欠债题——早期没选对结构后期需求一变更改一个功能要牵连五六个文件这时候才想起模式的价值。这份设计模式 PDF 的特别之处是没有把二十三个模式平铺着念一遍而是先把 SOLID 五原则和 UML 类图的六种关系讲透再挑单例、观察者、策略、代理、装饰这五个实际出场率最高的模式逐一带类图和可运行代码。适合两类人一类是刚掌握继承和多态、想在老项目里动手重构的开发者另一类是被“需求一改连锁改”折磨过、想弄明白边界到底在哪的从业者。读的时候建议按第 4 章的代码顺序从头敲一遍敲完再回头看原则部分理解会完全不一样。2. 设计模式的原则SOLID 五原则落进代码的判定标准选模式之前得先过原则。这份资料把原则放在模式前面是有道理的原则是判断“该不该用模式”的尺子模式只是尺子量完之后的具体方案。五条原则单拎出来都不难懂难的是合在一起用的时候怎么取舍。资料里也引了那本经典书上的原始建议——不要为了套模式而用模式要从实际问题出发。备查的书可以配两本互补一本图解风格的 Head First 系列用来建立直观印象一本实践导向的 Effective Java 用来校正编码习惯。2.1 单一职责与开闭两个变化源就要拆类单一职责原则第一句话是“一个类应该只有一个引起它变化的原因”。问题由来很直白一个订单类既管金额计算、又管校验、还管数据库读写价格规则一变要改它换数据库还要改它一个类背上两个变化源测试时牵一发动全身。解决方案就是按变化源拆类计算逻辑放一个类存取逻辑放另一个类各自独立演化。判断标准也很简单写需求的时候问一句“这次改动是因为什么原因发生的”如果同一个类因为两个不同原因都要改就该拆了。开闭原则要求软件实体对扩展开放、对修改关闭。常见做法是定义一个抽象接口固定行为模板新业务走新子类老代码一行不动。最常见的落地场景是支付渠道接入class IPayment { public: virtual ~IPayment() default; virtual bool pay(double amount) 0; virtual std::string channel_name() const 0; }; class WechatPayment : public IPayment { public: bool pay(double amount) override { // 微信支付请求逻辑 return true; } std::string channel_name() const override { return Wechat; } }; class OrderService { private: std::shared_ptrIPayment payment_; public: explicit OrderService(std::shared_ptrIPayment p) : payment_(std::move(p)) {} void checkout(double amount) { if (payment_-pay(amount)) { // 更新订单状态 } } };这段代码的逻辑是OrderService 只依赖 IPayment 抽象支付渠道是构造时注入的。新增渠道时只需要新建一个实现类OrderService 和既有渠道代码完全不用碰。参数说明里有几个点值得注意构造参数用 shared_ptr 而不是裸指针是为了让生命周期由调用方和 shared_ptr 共同管理pay 方法返回 bool 表示结果调用方据此决定后续流程。这就是对修改关闭——老代码一行不改新功能照样进去。2.2 依赖倒置与接口隔离抽象界的红线和接口瘦身依赖倒置原则说的是高层模块不依赖低层模块两者都依赖抽象。用发送通知的场景最容易说清楚业务层不该直接 new 一个短信发送器而应该依赖一个“能发消息”的抽象。代码示范如下class IMessageSender { public: virtual ~IMessageSender() default; virtual void send(const std::string title, const std::string body) 0; }; class SmsSender : public IMessageSender { public: void send(const std::string title, const std::string body) override { // 短信网关调用 } }; class NoticeService { public: explicit NoticeService(std::shared_ptrIMessageSender sender) : sender_(std::move(sender)) {} void publish(const std::string title, const std::string body) { sender_-send(title, body); } private: std::shared_ptrIMessageSender sender_; };NoticeService 不关心你用的短信还是邮件它只认 IMessageSender 这个抽象。换实现的时候组装层 new 一个别的 Sender 传进去就行业务代码零改动。这里有一个容易忽略的点IMessageSender 的接口粒度很关键如果把“查询余额”“获取发送报告”也塞进这个接口NoticeService 就被迫依赖它用不上的能力这就是接口隔离原则要管的事。接口隔离原则的典型病状是“胖接口”一个 Player 接口同时有 play、record、pause、save一个只负责播放的类也被逼着实现 record 和 save最后只能空实现然后抛异常。判断标准就是看有没有实现类被逼着空实现某个方法有就拆。拆成 Playable、Recordable、Saveable 三个小接口每个实现类只实现自己真正需要的部分。2.3 里氏替换子类行为不能比父类更“任性”里氏替换原则是所有继承关系的安全红线任何基类对象能出现的地方换成子类对象程序行为不应该变。经典反例就是正方形继承长方形。长方形有宽高两个独立属性正方形重写 setWidth 时把高也改了基类的不变量被破坏。代码示范如下class Rectangle { public: virtual void setWidth(int w) { width_ w; } virtual void setHeight(int h) { height_ h; } int area() const { return width_ * height_; } private: int width_ 0; int height_ 0; }; class Square : public Rectangle { public: void setWidth(int w) override { Rectangle::setWidth(w); Rectangle::setHeight(w); } void setHeight(int h) override { Rectangle::setWidth(h); Rectangle::setHeight(h); } }; void resize(Rectangle r, int w, int h) { r.setWidth(w); r.setHeight(h); // 期望 area() w * h }这段代码的逻辑是resize 函数面对 Rectangle 时行为正常一旦传入 Square先 setWidth 后 setHeight高被覆盖成宽度area 结果完全不符合预期。原因就是子类收紧了基类“宽高独立变化”的不变量。解决方式不是硬套继承而是让 Square 组合一个 Rectangle或者让两者都实现同一个 IShape 接口。参数说明里要留意“替换检查”的思路每写一个子类把父类的所有方法调用一遍凡是出现“这个参数值在父类合法、在子类不合法”的情况继承关系就要重新设计。3. UML 类图关系六种关系的判定正解与代码映射类图关系是设计模式的“读图语言”。很多模式类图看不懂卡住的原因不是模式本身而是泛化、实现、组合、聚合、关联、依赖这六种关系的记号没分清。这一章把每种关系的箭头、线型、生命周期语义、代码映射一次摆清楚。3.1 面向过程到面向对象类图关系为什么非学不可面向过程时代函数操作数据数据和行为是分离的代码之间的关系就是“谁调用谁”。面向对象把数据和操作封装进类类与类之间要靠更精确的关系来表达协作方式谁是父类、谁持有谁、谁临时用谁。这两种语义差别直接决定了代码改一处会不会波及其他类。资料里专门用一节讲类间关系综述把关系分成纵向和横向两类——纵向是继承层次的泛化和实现横向是对象之间的组合、聚合、关联和依赖这个分类对画图时快速定位很有用。3.2 六种关系的记号与箭头从线型和菱形读语义六种关系的类图记号整理成一张表实际画图时照着查关系线型箭头生命周期特征泛化Generalization实线空心三角箭头指向父类继承关系is-a实现Realization虚线空心三角箭头指向接口类兑现接口契约组合Composition实线实心菱形在整体端同生共死整体销毁部分跟着销毁聚合Aggregation实线空心菱形在整体端整体与部分各自有独立生命周期关联Association实线可带普通箭头长期持有的引用关系依赖Dependency虚线普通箭头指向被依赖者临时使用局部变量或函数参数判定顺序建议是先把虚实线分开再看箭头形状最后追问生命周期。虚线上只有一种语义要么是实现要么是依赖实线上要重点区分组合和聚合这两个最容易混。3.3 代码倒推关系从成员变量和函数参数看类图画类图最头疼的是从现成代码里倒推关系。下面这段代码覆盖了五种关系逐行看class Engine { public: Engine() { /* 内部构造持有油路状态 */ } }; class Car { private: Engine engine_; // 成员对象Car 销毁它就不存在 - 组合 std::shared_ptrDriver driver_; // 外部传入车报废司机还在 - 聚合 public: explicit Car(std::shared_ptrDriver d) : driver_(std::move(d)) {} void refuel(FuelTank tank); // 函数参数临时借用 - 依赖 }; class Sedan : public Car { // 继承实体类 - 泛化 }; class HybridEngine : public Engine { // 继承抽象/实体类语义二选一 };逻辑说明engine_ 是 Car 的值成员Car 生命周期结束它必然结束这是组合的典型写法driver_ 是外部传入的 shared_ptrCar 析构不影响 Driver 对象本身所以是聚合refuel 的参数 tank 只在函数体内用临时借用是依赖Sedan 继承 Car 是泛化。参数说明里有个容易踩的细节HybridEngine 如果继承的是“引擎”这个抽象接口应该画成实现关系如果继承的是 Engine 实体类才是泛化。同一个代码写法在类图里可能对应两种不同语义。3.4 组合与聚合的边界生命周期就是判决书组合与聚合的差别一句话就能说清组合关系里整体负责部分的创建和销毁聚合关系里整体只是持有一个引用。典型的例子是游戏里的角色系统角色和技能模块是组合角色被销毁技能模块连同冷却状态一起消失角色和装备是聚合装备卸下来还能给别的角色穿。代码层面的判断标准更直接部分是值成员、由构造函数初始化、析构函数释放的是组合部分是通过参数传入、由外部管理的是聚合。画类图时先问“谁拥有谁的生命周期”答案出来关系就定了。4. 五个高频模式实战拆解从类图到可运行的 C 代码这五个模式是实际项目里出场率最高的单例管全局状态观察者管事件解耦策略管算法替换代理管访问控制装饰管职责叠加。每个模式给定义、类图关系、应用场景和可运行代码照着敲就能在自己项目里用。4.1 单例模式全局配置与引擎导演类单例模式确保一个类只有一个实例并提供全局访问点。类图极其简单私有构造函数加静态 instance() 方法类自己持有唯一实例。最常用的场景是全局配置和游戏引擎里的导演类导演负责场景切换、节点管理全引擎只需要一个。代码示范class Config { public: static Config instance() { static Config cfg; // C11 起静态局部变量初始化线程安全 return cfg; } std::string get(const std::string key) const { auto it values_.find(key); return it values_.end() ? : it-second; } void set(const std::string key, const std::string value) { values_[key] value; } private: Config() default; Config(const Config) delete; Config operator(const Config) delete; std::unordered_mapstd::string, std::string values_; };逻辑说明C11 之后函数内静态局部变量的初始化由编译器保证线程安全第一次调用 instance() 时只构造一次不需要自己加锁。构造函数声明为 private拷贝构造和赋值运算符 delete 掉外部无法复制出第二个实例。参数说明里注意 set 和 get 操作的键值类型是字符串实际项目里可以模板化成任意类型。缺点是全局单例很难做单元测试隔离所以只建议用于配置、日志这类横切关注点不要什么对象都往单例里塞。4.2 观察者模式事件分发与监听解耦观察者模式定义一对多依赖主题状态变化时自动通知所有注册的观察者。应用场景包括 UI 事件、网络状态变化、游戏里的事件监听中心。类图上 Subject 持有一个观察者列表提供 attach、detach、notify 三个方法。代码示范一个按事件类型分发的最小事件中心class IEventListener { public: virtual ~IEventListener() default; virtual void onEvent(int eventType, const std::string data) 0; }; class EventDispatcher { public: void addListener(int eventType, IEventListener* listener) { listeners_[eventType].push_back(listener); } void removeListener(int eventType, IEventListener* listener) { auto vec listeners_[eventType]; vec.erase(std::remove(vec.begin(), vec.end(), listener), vec.end()); } void dispatch(int eventType, const std::string data) { for (auto* listener : listeners_[eventType]) { listener-onEvent(eventType, data); } } private: std::unordered_mapint, std::vectorIEventListener* listeners_; };逻辑说明listeners_ 用事件类型做 key每种事件挂一组监听器业务模块只需要在初始化时注册自己关心的事件类型。removeListener 的设计是为了解析前先摘除失效监听器避免悬垂指针。参数说明里有两个要点dispatch 是同步派发监听器里的耗时逻辑会阻塞调用线程删除操作要在遍历副本或先摘除再派发直接在遍历中删除会迭代器失效。这就是游戏引擎里事件分发器的常见实现思路。4.3 策略模式算法运行时替换与消除 if-else策略模式把一系列可互换的算法封装成策略类运行时可切换。类图上 Context 持有 Strategy 引用二者通过抽象接口协作。典型的应用场景是计算器算法切换、排序策略、格式化策略。代码示范class CalcStrategy { public: virtual ~CalcStrategy() default; virtual int calc(int a, int b) const 0; }; class AddStrategy : public CalcStrategy { public: int calc(int a, int b) const override { return a b; } }; class SubStrategy : public CalcStrategy { public: int calc(int a, int b) const override { return a - b; } }; class Calculator { private: std::shared_ptrCalcStrategy strategy_; public: explicit Calculator(std::shared_ptrCalcStrategy s) : strategy_(std::move(s)) {} void set_strategy(std::shared_ptrCalcStrategy s) { strategy_ std::move(s); } int execute(int a, int b) const { return strategy_-calc(a, b); } };逻辑说明Calculator 只认 CalcStrategy 抽象加减法是两种可替换策略set_strategy 可以在运行时切换算法。相比一长串 if-else 判断运算符新增一种运算只加一个策略类老代码不用动。参数说明里注意策略对象用 shared_ptr 管理切换时旧策略在引用计数归零后自动释放。这个模式的应用不止于计算器原文里还提到汽车发动机档位——不同的档位策略对应不同的换挡算法发动机控制器只依赖档位策略接口。4.4 代理模式虚代理、智能代理与保护代理代理模式为另一个对象提供一个替身或占位符控制对这个对象的访问。类图上 Proxy 和 RealSubject 实现同一个接口Proxy 内部持有 RealSubject 的引用。按用途分三类虚代理延迟加载大对象智能代理加日志和引用计数保护代理控制访问权限。以图片延迟加载为例class IImage { public: virtual ~IImage() default; virtual void draw() 0; }; class RealImage : public IImage { public: void draw() override { // 从磁盘加载图片资源并绘制 } }; class ImageProxy : public IImage { private: std::unique_ptrRealImage real_; public: void draw() override { if (!real_) { real_ std::make_uniqueRealImage(); // 第一次 draw 才真正加载 } real_-draw(); } };逻辑说明ImageProxy 和 RealImage 实现同一接口调用方无感知。虚代理的效果是延迟加载——对象创建时不加载资源第一次 draw 时才构造 RealImage。参数说明里 real_ 用 unique_ptr 持有生命周期完全由代理管理这是虚代理的标准写法。保护代理在 draw 之前加一层权限校验不通过直接返回访问逻辑和业务逻辑就分离开了。4.5 装饰模式职责叠加与包装顺序装饰模式不修改原类通过包装对象动态叠加行为。类图上 Decorator 持有 Component 引用并且实现同一接口层层包装。经典场景是饮品加料计费咖啡基础价加奶、加糖各加一份钱也可以用在日志增强、缓存叠加。代码示范class Beverage { public: virtual ~Beverage() default; virtual double cost() const 0; }; class Espresso : public Beverage { public: double cost() const override { return 3.0; } }; class MilkDecorator : public Beverage { private: std::unique_ptrBeverage wrapped_; public: explicit MilkDecorator(std::unique_ptrBeverage b) : wrapped_(std::move(b)) {} double cost() const override { return wrapped_-cost() 0.5; } }; // 使用auto coffee std::make_uniqueMilkDecorator(std::make_uniqueEspresso());逻辑说明MilkDecorator 持有被包装的 Beveragecost() 先取内层价格再加自己的价实现了职责叠加。包装两层就是加双份奶包装一层就是单份完全靠组合实现不用为每种组合建子类。参数说明里注意 unique_ptr 不能拷贝构造时用 std::move 转移所有权叠加顺序会影响最终行为这一点在实际项目中要约定统一方向。5. 设计模式实战避坑五个最常见的翻车现场把模式看懂和把模式用好中间隔着不少坑。下面这五条是我见过或踩过的具体问题每条按现象、原因、解决的顺序写照着排查能少走弯路。5.1 单例不做线程保护多线程下拿到两个实例现象多线程并发首次调用 instance()程序里出现了两个不同地址的配置对象后续读写数据错乱。原因如果用的是 C11 之前的经典懒汉写法new 操作和赋值之间可以被其他线程插进来double-check 在指令重排下也不可靠。解决统一用函数内静态局部变量C11 之后编译器保证初始化线程安全。如果项目还在 C03 环境直接用饿汉式静态成员不要自己手写加锁。5.2 观察者互听形成环一次派发直接栈溢出现象模块 A 监听事件 X模块 B 监听事件 YA 在 X 的处理里派发 YB 在 Y 的处理里派发 X一次外部触发后两个模块互相调用栈很快被打爆。原因同步派发没有递归深度限制事件环一旦形成就无限递归。解决事件总线加派发深度计数超过阈值直接丢弃或者在派发前先摘除当前正在处理的监听器。设计上也要避免两个业务模块直接互听对方事件尽量通过上层状态协调。5.3 策略模式用早两三个分支也抽象出五个文件现象一个方法里只有两三种分支也建了策略接口、三个实现类加一个工厂代码量翻倍改动成本反而比 if-else 更高。原因把模式当目的而不是手段忽略了对“变化频率”的判断。解决分支数超过四五个、算法还在稳定增加时再上策略模式。两个分支且基本不变就老实写 if-else不要为了简历上好看而过度设计。5.4 虚代理只做了一半延迟加载变成每次重建现象ImageProxy 的 draw 方法里 new RealImage 后直接画画完不保存引用下一次 draw 又 new 一次加载了十次资源。原因代理类的成员没有缓存创建好的真实对象创建代码写在了调用路径上。解决用成员 unique_ptr 持有真实对象判空创建创建后复用。关键参数是“创建后的对象必须赋值给成员”不能只在局部变量里存在。虚代理的价值在“加载一次”没缓存就等于没做代理。5.5 装饰器包装顺序颠倒叠加效果差出两倍现象同样的两个装饰器先包 A 再包 B 与先包 B 再包 A最终计算结果完全不同费用多出一截。原因装饰器逐层包裹每层都在内层基础上叠加方向决定了结果。解决把叠加规则写进文档按业务语义约定统一包装顺序。比如先加基础料、再加混合料、最后加额外料所有调用点必须遵循同一方向。参数说明里这条不是代码 bug是设计规范问题比改代码更花时间。6. 认出模式到用对模式代码走查里的三条习惯6.1 第一习惯先数变化源再决定动不动模式走查代码时拿到一个类第一件事是问“这个类会因为几个原因发生变化”。两个以上就拆类拆完看清变化源再决定用哪个模式。不要一上来就翻模式表模式是尺子变化点才是要量的尺寸。6.2 第二习惯继承树超过三层做一遍替换检查继承超过三层每层都可能收窄父类不变量。把所有父类方法逐一用子类实例调用一遍凡是出现“父类合法、子类不合法”的边界立刻重构。里氏替换不是理论条款是走查清单里的一条硬检查。6.3 第三习惯让模式名直接出现在类名里Config 单例就写 Config::instance()代理类就叫 ImageProxy装饰器就叫 MilkDecorator。类名自带模式信息新同事看代码一眼就知道设计意图走查时也不用翻文档猜。模式价值一半在结构另一半在可读的命名。从那以后我每次重构前都强制把变化点列出来再对着“变化源 → 模式映射”走一遍该用策略的用策略、该用装饰的用装饰该什么都不用的也不硬套。这套流程帮我挡掉了不少未来三个月的返工希望你也能用上。这份 PDF 适合放在手边当参考下载后按第 2 章到第 4 章的顺序过一遍代码收获会比通读全部二十三个模式大得多。希望帮到你。本文还有配套的精品资源点击获取