设计模式核心解析:六大原则、UML类图与五个高频模式

发布时间:2026/10/24 18:31:50
设计模式核心解析:六大原则、UML类图与五个高频模式

简介:王桂林撰写的《设计模式》PDF文档,面向希望系统掌握设计模式的软件开发者与在校学生。内容从设计模式的历史渊源讲起,介绍克里斯托弗·亚历山大提出的模式语言以及GOF四位作者的经典著作,并系统讲解模式名称、问题、解决方案、效果与结构等陈述要素;随后给出学习模式的意义与建议,涵盖GOF的实践观点和《Head First Design Patterns》等推荐书目。后半部分重点剖析单一职责、开闭、依赖倒置、接口隔离等核心原则,结合问题由来与解决方案,帮助读者识别代码坏味道并重构出更易维护的软件。资源为单个PDF文件,大小3.09MB,目录结构清晰,适合按章循序阅读或作为案头参考。已有157人学习下载,无论是初学者建立设计模式框架,还是开发者复盘面向对象设计原则,都能从中获得实用指导。

1. 设计模式到底是什么:先看懂"套路"这个词

一说起设计模式,很多刚开始写代码的人第一反应是:又是一堆画满箭头的类图。但设计模式并不是让你背图的考试题。通俗地说,设计模式是软件行业里被无数前辈踩过坑之后总结出的"套路",就像厨师做菜时的下锅顺序和调味节奏,每种经典菜式都有被反复验证的做法。它提供的是"遇到某种情境、碰到某类问题,可以用某种标准解法"的经验,而不是一段可以直接复制的代码。看懂它,别人写的类就不再是一团乱麻,而是能叫出名字的协作结构;你自己写代码时,也能少走很多弯路,这就是这份资料想让你搞明白的第一件事。

2. 为什么需要设计模式:六大原则回答"怎么写才不坏"

2.1 单一职责原则:一个类只管一件事

先从一个最影响日常开发的原则说起。你有没有见过这样的类:一个订单类里既存数据库、又算价格、还负责发通知邮件。功能全挤在一起,看起来"方便",真到改需求的时候,谁都不敢动——改计价规则怕碰坏存库逻辑,调了通知模板又担心影响下单流程。这就是职责过载。

单一职责原则(SRP)的定义很直白:一个类应当只有一个引起它变化的原因。换句话说,一个类只负责干好一件完整的事。如果一件事有变化,只影响这个类;另一件事有变化,不该牵连到它。问题由来很清楚:多个职责耦合在同一个类里,任何一个需求变动都会波及整个类,测试和回归的成本跟着翻倍。

解决方案也简单:拆。把不同的职责分给不同的类,再用组合把它们组织起来。

# 反例:一个类包办了存储、计价、通知三种职责 class OrderService: def save(self, order): pass # 写数据库 def calc_total(self, order): pass # 计算订单总价 def send_email(self, order): pass # 发送通知

拆完之后,会有三个只承担单一职责的类:OrderRepository只管存取,PriceCalculator只管算价,MailNotifier只管通知。OrderService只负责按顺序调用它们。这样做的价值在于:以后价格规则怎么改,跟数据库怎么存没有关系;邮件模板再折腾,也不会影响订单落库。判断一个类是否违反 SRP,可以问自己一句:"这个类如果明天要删掉一半代码,剩下的一半是否能说清它到底在干嘛?"

2.2 开闭原则:加功能不拆老房子

开闭原则(OCP)是设计模式里被引用最多的一条:软件实体应当对扩展开放、对修改关闭。听起来绕,其实一句话就能讲透——要给系统加新功能时,尽量通过增加新代码来实现,而不是回头去改已经稳定运行的老代码。

问题由来也很常见:系统上线后需求不断变,今天支持支付宝,明天支持微信,后天又来银联。如果每加一种支付方式,都要打开原来的支付处理类改一长串 if-else,那么每改一次,原本跑得好好的支付流程都有被改崩的风险。改老代码,本质上是在用旧功能的安全换新功能的落地。

解决方案是依赖抽象:把不变的部分定成接口或父类,把会变的部分交给子类去实现。

class PayGateway: def pay(self, amount): raise NotImplementedError class Alipay(PayGateway): def pay(self, amount): print(f"支付宝支付 {amount} 元") class WechatPay(PayGateway): def pay(self, amount): print(f"微信支付 {amount} 元")

主流程只需要认识 PayGateway 这个接口,新增银联支付时,新建一个 UnionPay 类去实现 pay 方法即可,老代码一行不动。开闭原则并不是要求你永远不修改代码,而是告诉你:当变化频繁发生、又总是落在同一处时,就该考虑用接口把"稳定骨架"和"易变细节"隔离开,这才是它真正的价值。

2.3 依赖倒置原则:面向抽象编程

依赖倒置原则(DIP)解决的是"高层模块和底层模块谁说了算"的问题。很多新人的代码里,业务层直接 new 了一个 MySQL 连接类,用到哪写到哪。这在一开始没毛病,可一旦公司决定换 Oracle,所有用过这个连接类的地方都要跟着改,业务层被迫绑死在一个底层实现上。

依赖倒置的意思是:高层模块不要直接依赖低层模块,两层都要依赖抽象;抽象不依赖细节,细节要依赖抽象。用人话说,你的业务代码只认"存储接口",不认"某个具体的数据库"。

class Storage: def save(self, data): raise NotImplementedError class MySQLStorage(Storage): def save(self, data): print(f"写入 MySQL: {data}") class UserService: def __init__(self, storage: Storage): self.storage = storage # 依赖的是抽象,不是某个具体数据库

上面的 UserService 只认 Storage 类型。想换成 Oracle,就新建一个 OracleStorage 类,在创建 UserService 时传入即可,UserService 的代码完全不用动。依赖倒置和开闭原则经常配合出现:接口负责稳定,具体实现负责变化。这也是"面向接口编程"这句老话的底层逻辑。

2.4 接口隔离与里氏替换:两个容易忽视的规矩

接口隔离原则(ISP)说的是:不要强迫调用方去依赖它根本用不上的方法。有人图省事,把十几个方法塞进一个"万能接口",实现类为了通过编译,不得不把不用的方法也写个空实现。正确的做法是把大接口拆成几个小接口,让每个调用方只依赖它真正需要的那部分。

里氏替换原则(LSP)则是关于继承的:任何用到父类的地方,都应该能用子类替换且不破坏系统行为。经典反例是正方形继承矩形——矩形有"设置宽度"和"设置高度"两个操作,到了正方形里,为了保持四边相等,设置宽度时还得偷偷把高度也改了。结果就是,把正方形当成矩形使用时,程序行为完全乱套。正方形和矩形各有各的长相,正确的建模是让它们都实现一个 Shape 抽象,而不是硬凑继承关系。

这六条原则一起回答了"为什么需要设计模式":它们保证代码在需求不断变化时,仍然可维护、可扩展、团队之间好沟通。后面的每一种模式,本质上都是这些原则的具象化。

3. UML 类图关系:一眼分清六种连接方式

3.1 从面向过程到面向对象:类图为什么存在

早期程序员写程序,靠的是函数:数据传来传去,逻辑按步骤往下走,代码规模一大就变成"面条代码"。面向对象出现后,程序被拆成一个个对象,对象之间靠"发消息"协作。既然对象要协作,它们之间就会有各种连接方式,UML 类图就是用来把这些连接画出来的语言。

面向过程时代,模块之间最直接的"联系"就是一个函数调用另一个函数;到了面向对象时代,类和类之间存在继承、包含、使用等多种关系。类图画的是静态结构,只看它,能知道"谁认识谁""谁拥有谁""谁是谁的子类"。王桂林讲义第 3、4 章把关系分成纵向和横向两条线:纵向是泛化和实现,横向是组合、聚合、关联、依赖,共六种。

3.2 泛化和实现:两条继承轴

先把两条"纵向关系"讲清。泛化(Generalization)就是继承,表示 is-a 关系:狗是一种动物,学生是一个人。类图里用实线加空心三角箭头,箭头指向父类。

class Animal: def eat(self): print("进食") class Dog(Animal): # 泛化:Dog 继承自 Animal def bark(self): print("汪汪")

实现(Realization)表示一个类"兑现"了某个接口定义的契约。类图里用虚线加空心三角箭头,箭头指向接口。在 Python 中接口通常借助 abc 模块模拟,语义上跟 Java 的 interface 一致。

class Speaker: def speak(self): raise NotImplementedError class Dog(Speaker): def speak(self): print("汪汪")

区分这两个关系有个很实用的判断方式:泛化是"继承已有代码和身份",是父子血缘;实现是"承诺完成一组约定动作",是履行合同。一个类可以继承一个父类,同时实现多个接口,这在类图里并不冲突。

3.3 组合与聚合:同生共死还是一别两宽

横向关系里最容易搞混的,就是组合(Composition)和聚合(Aggregation)。两条都是"整体包含部分"的关系,区别全在一个问题上:部分能不能脱离整体独立存活。

聚合用空心菱形表示,整体持有部分,但部分有自己的生命周期。学校和老师就是典型的聚合:学校解散了,老师们依然存在,可以再去别的学校。组合用实心菱形表示,整体和部分同生共死。比如人和心脏:心脏是人体的一部分,人没了心脏这个器官也就失去了意义。

class Teacher: def __init__(self, name): self.name = name class School: def __init__(self, teachers): self.teachers = teachers # 聚合:老师从外面传入,可独立存活 class Heart: pass class Person: def __init__(self): self.heart = Heart() # 组合:心脏随 Person 创建,同生共死
判断维度聚合(空心菱形)组合(实心菱形)
生命周期部分可独立于整体存活部分与整体同生共死
创建方式整体创建前甚至可以不依赖部分整体创建时创建部分
典型例子学校与老师、公司与员工人与心脏、订单与订单项

一个口诀是:聚合是"拼装",组合是"生长"。拼装的零件拆下来还能用,生长的器官离开整体就失去意义。

3.4 关联与依赖:两条弱关系也别看轻

再看另外两种横线。关联(Association)用一条实线表示,双方都持有对方的引用,彼此认识。客户和订单是关联关系:客户知道自己的订单,订单也指向所属客户,它们互相引用,谁也不是谁的附属品。

依赖(Dependency)是六种关系里最弱的一种,用带虚线的箭头表示。它的特征是:一个类的方法在运行过程中临时用到了另一个类,这个类只出现在方法的局部变量、参数或返回值里,并不作为字段保存。比如方法里临时构造了一个格式化工具来拼接字符串,方法结束,联系结束。

六种关系按强度排个序:泛化和实现属于继承轴,是"血缘关系";组合强于聚合,聚合强于关联,关联强于依赖。在类图里认关系有个小技巧:先找空心三角箭头(继承/实现),再找菱形(组合/聚合),剩下没有菱形的实线是关联,带虚线的箭头是依赖。这六种关系掌握之后,看任何一份设计模式的类图,你都不会再发怵。

4. 易混概念:设计模式学习中最容易翻车的五个误区

4.1 误区一:设计模式就是代码模板

容易把设计模式当模板背,是因为网上的示例代码都长得很像,给人一种"抄下来就能用"的错觉。但模式的核心从来不是那几行代码,而是"在什么情境下、遇到什么问题、采用什么解法"。同一个单例,在 Java、Python、C++ 里的写法各不相同,可模式思想完全一致。只背代码而不理解适用场景,换一种语言或换一个项目,代码根本套不进去。怎么区分:先问自己"这个模式解决的是什么问题",答得上来,才算真正认识它。

4.2 误区二:设计模式和算法是一回事

两者都给出"步骤和结构",确实容易混。但算法解决的是"怎么算":排序、查找、哈希,关心的是计算过程和复杂度;设计模式解决的是"怎么组织代码":类和对象怎么划分、怎么协作、怎么应对变化。算法是零件,模式是装配方案。策略模式就可以把排序算法封装成一个策略对象,运行时替换;但你能说排序是设计模式吗?显然不能。

4.3 误区三:组合和聚合分不清

这个误区在第 3 章详细讲过,这里再浓缩成两句话。组合是"心脏和人体"的关系——整体没了部分也没意义;聚合是"老师和学校"的关系——学校没了老师依然活着。判断标准只有一条:看部分能不能脱离整体独立存活。画类图时先看菱形是实心还是空心,实心是组合,空心是聚合,看一眼箭头和菱形就够。

4.4 误区四:单例模式就是全局变量

单例的确提供全局访问点,看起来和全局变量一样,随手一拿就能用,但本质不同。全局变量是一个"公开的名字",谁都能重新赋值,任何一处改了都会影响所有使用者,风险完全失控。单例则限制了一个类只能有一个实例,并把这个唯一实例的创建时机、访问入口都管理起来。不过它也有自己的隐患:所有调用方共享同一个可变状态,本质上仍然是全局状态。能用参数传递解决的问题,就不必急着上单例。

4.5 误区五:代理模式和装饰模式长得像

这两个模式在类图里都是"包一个对象、实现同一个接口",第一次接触很容易看成同一个东西。区分的关键在意图。代理模式的目的,是控制对原对象的访问——做权限校验、延迟加载、记录访问日志,它像经纪人或替身,核心是"把关"。装饰模式的目的,是给对象动态增加功能——加缓存、加日志、加格式转换,它像给奶茶加料,核心是"增强"。一个管进门,一个管能力,方向完全不同。

这五个误区覆盖了初学者最容易摔跤的地方:把模式当模板、把模式当算法、混淆关系、错用全局思想、张冠李戴结构相似的模式。带着这些区分再去学具体模式,会顺畅很多。

5. 五个出场率最高的模式:先学会用这几个

5.1 单例:全局唯一的一份

单例模式确保一个类只有一个实例,并提供一个全局访问点。最典型的应用是全局配置文件:整个系统只需要一份配置数据,任何模块读取的都是同一份,改配置时也只需要改一处。游戏引擎里的导演类、项目管理器、日志模块,用的都是单例。

实现方式里,延迟创建(懒汉式)是常见写法:第一次访问实例时才真正创建。

class Config: _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance

这段代码有个需要注意的地方:在多线程环境下,两个线程可能同时发现 _instance 是 None,然后各自创建出实例,单例就失效了。解决方案是加锁,或者用"双重检查锁定"——先判断一次,再加锁,锁内再判断一次。参数说明:_instance 是类级变量,所有实例共享,这正是它保证唯一性的关键。理解单例,要同时看到它的两面:提供了统一入口,也引入了共享状态,用不用它取决于系统是不是真的需要"唯一"。

5.2 观察者:一呼百应的发布订阅

观察者模式解决的是"一对多通知"的问题。被观察对象状态变化时,自动通知所有注册过的观察者,双方互不绑死。公众号推送文章、UI 按钮监听点击、游戏引擎里的事件系统,都是观察者的应用。

它之所以好用,是因为被观察者根本不需要知道观察者具体是谁,只要知道它们都实现了同一个 update 接口。

class Subject: def __init__(self): self._observers = [] def attach(self, observer): self._observers.append(observer) def notify(self, message): for observer in self._observers: observer.update(message)

Subject 维护一个观察者列表,notify 方法负责遍历并通知。未来新增一个观察者类,只要实现 update 方法就能接入,Subject 的代码完全不用改。这就是解耦的价值:新功能以"增加"的方式加入系统,而不是以"修改"的方式侵入系统。

5.3 策略:把算法抽出来换着用

策略模式把一族算法分别封装起来,让它们在运行时可以互相替换。遇到支付方式、价格计算、排序规则这类"行为有多种方案"的场景,它是消灭 if-else 连招的有力工具。

不用策略时,代码长这样:

def calc(type, a, b): if type == "add": return a + b elif type == "mul": return a * b

这种写法每来一种新规则,就要改一次 calc 函数,改着改着函数越来越长、越来越难维护。用策略模式重构后,每种算法独立成一个类:

class AddStrategy: def execute(self, a, b): return a + b class MultiplyStrategy: def execute(self, a, b): return a * b

主流程只面向策略接口调用 execute,新增平方、取余之类的规则时,新建一个类就行,主流程一行不改。这正是开闭原则的落地:扩展靠新增类,不靠改动老代码。

5.4 代理:替身演员兼经纪人

代理模式提供一个替身对象来控制对真实对象的访问。三种最常见的形态:虚代理负责延迟加载,比如图片浏览列表里先显示占位,滚动到某张图时才真正加载大图;智能代理负责在访问前后做附加操作,比如记录调用次数、缓存结果;保护代理负责权限校验,先确认调用者有权限才放行。

class Image: def __init__(self, path): self._path = path self._load_image() def display(self): print("显示图片:", self._path) class ImageProxy(Image): def __init__(self, path): self._real = None self._path = path def display(self): if self._real is None: self._real = Image(self._path) # 真正需要时才加载大图 self._real.display()

ImageProxy 和真实图片实现同一接口,调用方感知不到代理的存在。大图加载被推迟到第一次 display 时,这就是虚代理的核心思路。参数说明:_real 字段保存真实对象,第一次访问时为 None,触发加载后复用同一个对象,避免重复创建。记住代理的目的是"控制访问",不是"增强功能"——后者要找装饰模式。

5.5 装饰:一层一层往上叠

装饰模式给对象动态添加职责,比继承更灵活。拿奶茶举例:一杯原味奶茶 10 元,加珍珠加 3 元,加椰果加 2 元,加奶盖加 4 元。如果用继承去写,十种配料要组合出上百个类,类直接爆炸;用装饰模式,每种配料包一层就行。

class MilkTea: def cost(self): return 10 class PearlDecorator: def __init__(self, base): self._base = base def cost(self): return self._base.cost() + 3 class CoconutDecorator: def __init__(self, base): self._base = base def cost(self): return self._base.cost() + 2

要一杯"珍珠椰果奶茶",就从内到外套两层:CoconutDecorator(PearlDecorator(MilkTea())),调用 cost 时一层层累加,总共 15 元。装饰器保持了原对象的接口,调用方拿到的仍然是"奶茶",但能力被叠加上去。与继承相比,装饰模式的优势是自由组合和按需添加,不会产生类数量的组合爆炸。

这五个模式中,单例管"唯一",观察者管"通知",策略管"替换算法",代理管"控制访问",装饰管"动态增强"。它们是最常用也最好入门的五个,把类图和适用场景配合着看,会比孤立记代码有效得多。

6. 设计模式什么时候用、什么时候别用:边界判断

6.1 什么时候该用

判断标准可以凝成三个信号:第一,代码里出现大量重复结构,两个类长得几乎一样,只有局部行为不同;第二,某个功能频繁变化,每次变动都要修改多处老代码;第三,团队成员讨论设计时靠"你懂的"交流,结构说不清楚。出现这些信号,就该停下来画一张类图,问自己:这个变化的点能不能抽象出来?用哪个原则能让我少改老代码?这比闷头继续堆代码有效率得多。

6.2 什么时候别用

反过来,设计模式不是越多越好。一个需求明确、短期内不会变的小脚本,硬套上策略模式,只会让接手的同事绕路。团队里多数人还不熟悉某个模式时,用它带来的沟通成本可能超过收益。判断标准很简单:模式引入的间接层,到底是在帮你隔离变化,还是在单纯增加代码跳转。如果答案偏向后者,就别用。模式是解决问题的工具,不是装饰代码的饰品。

就拿我自己的经历来说,有段时间我为了"显得专业",把一段不到百行的工具脚本硬套上策略模式,结果看代码的人找了三个文件才搞懂入口在哪,直接被骂了一顿。从那以后,我每次接手复杂模块都强制走一遍"先画类图、再对六大原则、最后选模式"的流程,先搞清楚变化是什么,再决定要不要引入设计模式。这套方法是这份讲义最想传达的东西:模式不是背出来的,是问题和情境一步步逼出来的。希望帮到你。

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