ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

设计模式实战:SOLID原则、UML类图与五个高频C++模式解析

设计模式实战: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_ptr<IPayment> payment_; public: explicit OrderService(std::shared_ptr<IPayment> 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_ptr<IMessageSender> sender) : sender_(std::move(sender)) {} void publish(const std::string& title, const std::string& body) { sender_->send(title, body); } private: std::shared_ptr<IMessageSender> 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_ptr<Driver> driver_; // 外部传入,车报废司机还在 -> 聚合 public: explicit Car(std::shared_ptr<Driver> d) : driver_(std::move(d)) {} void refuel(FuelTank& tank); // 函数参数,临时借用 -> 依赖 }; class Sedan : public Car { // 继承实体类 -> 泛化 }; class HybridEngine : public Engine { // 继承抽象/实体类,语义二选一 };

逻辑说明:engine_ 是 Car 的值成员,Car 生命周期结束它必然结束,这是组合的典型写法;driver_ 是外部传入的 shared_ptr,Car 析构不影响 Driver 对象本身,所以是聚合;refuel 的参数 tank 只在函数体内用,临时借用,是依赖;Sedan 继承 Car 是泛化。参数说明里有个容易踩的细节:HybridEngine 如果继承的是“引擎”这个抽象接口,应该画成实现关系;如果继承的是 Engine 实体类,才是泛化。同一个代码写法,在类图里可能对应两种不同语义。

3.4 组合与聚合的边界:生命周期就是判决书

组合与聚合的差别,一句话就能说清:组合关系里,整体负责部分的创建和销毁;聚合关系里,整体只是持有一个引用。典型的例子是游戏里的角色系统:角色和技能模块是组合,角色被销毁,技能模块连同冷却状态一起消失;角色和装备是聚合,装备卸下来还能给别的角色穿。代码层面的判断标准更直接:部分是值成员、由构造函数初始化、析构函数释放的,是组合;部分是通过参数传入、由外部管理的,是聚合。画类图时先问“谁拥有谁的生命周期”,答案出来关系就定了。

4. 五个高频模式实战拆解:从类图到可运行的 C++ 代码

这五个模式是实际项目里出场率最高的:单例管全局状态,观察者管事件解耦,策略管算法替换,代理管访问控制,装饰管职责叠加。每个模式给定义、类图关系、应用场景和可运行代码,照着敲就能在自己项目里用。

4.1 单例模式:全局配置与引擎导演类

单例模式确保一个类只有一个实例,并提供全局访问点。类图极其简单:私有构造函数加静态 instance() 方法,类自己持有唯一实例。最常用的场景是全局配置和游戏引擎里的导演类,导演负责场景切换、节点管理,全引擎只需要一个。代码示范:

class Config { public: static Config& instance() { static Config cfg; // C++11 起静态局部变量初始化线程安全 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_map<std::string, std::string> values_; };

逻辑说明:C++11 之后函数内静态局部变量的初始化由编译器保证线程安全,第一次调用 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_map<int, std::vector<IEventListener*>> 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_ptr<CalcStrategy> strategy_; public: explicit Calculator(std::shared_ptr<CalcStrategy> s) : strategy_(std::move(s)) {} void set_strategy(std::shared_ptr<CalcStrategy> 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_ptr<RealImage> real_; public: void draw() override { if (!real_) { real_ = std::make_unique<RealImage>(); // 第一次 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_ptr<Beverage> wrapped_; public: explicit MilkDecorator(std::unique_ptr<Beverage> b) : wrapped_(std::move(b)) {} double cost() const override { return wrapped_->cost() + 0.5; } }; // 使用:auto coffee = std::make_unique<MilkDecorator>(std::make_unique<Espresso>());

逻辑说明:MilkDecorator 持有被包装的 Beverage,cost() 先取内层价格再加自己的价,实现了职责叠加。包装两层就是加双份奶,包装一层就是单份,完全靠组合实现,不用为每种组合建子类。参数说明里注意 unique_ptr 不能拷贝,构造时用 std::move 转移所有权;叠加顺序会影响最终行为,这一点在实际项目中要约定统一方向。

5. 设计模式实战避坑:五个最常见的翻车现场

把模式看懂和把模式用好,中间隔着不少坑。下面这五条是我见过或踩过的具体问题,每条按现象、原因、解决的顺序写,照着排查能少走弯路。

5.1 单例不做线程保护,多线程下拿到两个实例

现象:多线程并发首次调用 instance(),程序里出现了两个不同地址的配置对象,后续读写数据错乱。

原因:如果用的是 C++11 之前的经典懒汉写法,new 操作和赋值之间可以被其他线程插进来,double-check 在指令重排下也不可靠。

解决:统一用函数内静态局部变量,C++11 之后编译器保证初始化线程安全。如果项目还在 C++03 环境,直接用饿汉式静态成员,不要自己手写加锁。

5.2 观察者互听形成环,一次派发直接栈溢出

现象:模块 A 监听事件 X,模块 B 监听事件 Y,A 在 X 的处理里派发 Y,B 在 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 章的顺序过一遍代码,收获会比通读全部二十三个模式大得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表