1. 写在前面:为什么“看完就忘”,又为什么必须看
先说个大实话:设计模式这个东西,尤其是 GoF 23 种设计模式,几乎是所有面向对象开发者绕不过去的一座山。面试要问、源码里全是、代码评审时同事张口就来,你以为你懂了,一到自己写代码,还是继承套接口、接口套抽象类,最后整成一坨“高射炮打蚊子”的过度设计。我见过太多人把《设计模式》这本书从头翻到尾,合上之后脑子里只剩下“单例、工厂、观察者”三个名字,其他模式全部还给作者了。
这不能怪你记性差。23 种模式本来就是 23 个不一样的工具,每种工具要解决的具体问题、适用边界、类结构都不一样,硬背当然记不住。更麻烦的是,很多资料把它们当成“24 个定义 + 24 张 UML 图”来讲,你看的时候觉得懂了,遇到真实需求根本不知道用哪个。所以你需要的不是又一份粘贴复制的讲义,而是一份“从问题出发”的实战解析:这个模式解决什么痛点、不解决什么问题、什么时候用它是在加分、什么时候用它是自找麻烦。
这篇文章我打算按 GoF 原书的三大分类来讲:创建型 5 种、结构型 7 种、行为型 11 种,一共 23 种。每种模式我都会先讲清楚它出现的背景和核心思想,再给一个贴近日常开发的例子,最后补上实战中容易踩的坑。最后我会单独整理一份模式对比表和选型建议,方便你学完之后按图索骥,或者直接当复习提纲用。
不管你是正在备战面试的在校生,还是工作了三五年想系统梳理知识的开发者,甚至是被“设计模式期末大作业”逼到墙角的同学,这篇文章都能让你在一两个小时内建立一套完整的认知框架。前提是你别光看,最好动手把文中的类图画一遍,或者拿自己项目里的一坨老代码练练手,这样效果完全不一样。
2. 创建型模式:把“如何产生对象”这件事从代码里剥离
创建型模式一共 5 种,它们的共同目标是解决同一个问题:对象怎么来。别小看这一步,如果你的代码里到处都是new Xxx(),那么一旦 Xxx 的构造函数改了、实现类换成别的了,你就得到处跟着改。创建型模式的核心思路是把“创建”这件事从业务代码中抽离出来,让调用者不关心对象到底是怎么构造出来的。
2.1 单例模式:最常用,也最容易写错
单例模式大概是所有模式里知名度最高的一种:整个进程里某个类只能存在一个实例,并且提供一个全局访问点。日志器、配置管理器、连接池、线程池这些天然需要全局唯一的组件,都非常适合用单例。
但单例也是最容易被写坏的。很多新手的第一版代码就是“懒汉式 + 不加锁”:
public class ConfigManager { private static ConfigManager instance; public static ConfigManager getInstance() { if (instance == null) { // 多线程下这里会出事 instance = new ConfigManager(); } return instance; } }这段代码在单线程下没问题,可一旦并发调用getInstance(),两个线程可能同时看到instance == null,然后各自 new 一个实例出来,全局唯一性就被打破了。最稳妥的写法省略掉各种历史包袱,直接推荐两种:一是枚举单例,天然线程安全还能防反射攻击;二是双重检查锁加volatile,兼顾性能和线程安全。
我个人的建议是:能不用单例就不用单例。全局实例本质上就是全局状态,会偷偷给模块之间增加隐式依赖,也让单元测试变得非常难受。如果你用它只是为了方便到处取数据,不如考虑依赖注入把实例显式传下去,代码会干净得多。
2.2 工厂方法:把“创建什么”交给子类
工厂方法模式定义了一个创建对象的接口,但让子类决定具体实例化哪个类。它的核心价值在于:当你有一族产品类,但事先不知道运行时到底需要哪一个时,工厂方法能让选择逻辑和业务逻辑解耦。
举个例子,你在做一个日志系统,开发环境输出到控制台,生产环境写入文件,未来可能还要上报到远程服务。如果业务代码里写死了new FileLogger(),将来切换日志目的地又要改动业务代码。用工厂方法的话,你定义LoggerFactory接口,里面放一个createLogger()方法,然后派生出ConsoleLoggerFactory、FileLoggerFactory等子类,运行时获取哪个工厂实例就产生哪个日志器。
关键词是“让子类决定”。基类的LoggerFactory只负责规定创建入口,具体返回什么对象完全延迟到子类。这跟“简单工厂”不是一个东西,简单工厂通常用switch或if-else在一个类里决定返回哪个产品,它不在 GoF 官方 23 种之列,但作为编程技巧非常常用。你可以理解成:简单工厂是把“选择逻辑”集中到一个地方,工厂方法则是把“选择逻辑”分布到各个子类。
2.3 抽象工厂:创建一族兼容的对象
抽象工厂可以看作是工厂方法的升级版,它要解决的是产品族的问题:你不只是想创建某一个产品,而是想创建一系列彼此关联、必须配套使用的产品。
最经典的是 GUI 工具包的例子。你做一个跨平台桌面应用,按钮、输入框、弹窗这些控件在 Windows 上和 macOS 上的渲染风格完全不一样。你不可能允许界面上出现“Windows 按钮搭配 macOS 输入框”这种组合,所以必须保证同一时刻创建的控件属于同一个操作系统风格。
这时候抽象工厂就能派上用场:UIFactory接口里声明createButton()、createInput()、createDialog(),然后分别实现WindowsUIFactory和MacUIFactory。业务代码只面对UIFactory接口,不知道也不关心控件到底在哪套风格下被创建。
“产品族”和“产品等级”是理解抽象工厂的两根轴。按钮、输入框是不同的产品等级,Windows、Mac 是不同的产品族。抽象工厂解决的是“选族”的问题,一旦族定下来,族内产品就不会混搭。实际项目中这种场景非常常见,比如多数据库支持、多存储介质支持、多支付渠道支持,只要这些资源需要成套切换,抽象工厂都是很顺手的选择。
2.4 建造者模式:告别构造函数参数爆炸
你有没有见过这种构造函数?
new Product("手机", 5999, 6.1, 196, "A14", 128, 12, true, false, "黑色")参数一多,阅读的人完全不知道每个数字代表什么,稍不留神就把“屏幕尺寸”和“重量”传反了。而且产品可选配置非常多,有些字段可能根本用不上,你只能全塞进构造函数,传一堆null和0充数。
建造者模式就是来治这个病的。它把一个复杂对象的构造过程和表达过程分开:Builder 负责逐步设置各个属性,最后再调用build()生成目标对象。调用方写起来是这样的:
Product builder = Product.builder() .name("手机") .price(5999) .screenSize(6.1) .weight(196) .memory(128) .support5G(true) .build();西式的链式调用不仅在语义上一目了然,还能天然屏蔽掉那些不关心的字段。网络上讲建造者的时候老是举做套餐、盖房子这些例子,其实更贴近实际的是“配置对象”“请求参数”“报表选项”这类场景。只要一个类的字段大于等于 5 个,或者有好几个必填和可选组合,你就该考虑是不是要用建造者了。Java 的 Lombok@Builder注解很多人在用,但理解它背后的建造者原理,比单纯会用注解重要得多。
2.5 原型模式:拷贝一份再改,省事又安全
有些对象的创建成本很高——比如需要查数据库、调远程接口、做复杂计算才能得到一个完整的对象。这种情况下,每新建一个对象都走一遍完整流程就太蠢了。原型模式的思路是:从一个已有的“样板对象”上复制一份,再在这份拷贝上做修改。
JavaScript 的原型链、Java 的Object.clone()、C++ 的拷贝构造函数,本质上都和原型模式的思想沾边。但这里有个大头坑:浅拷贝和深拷贝。浅拷贝只复制引用地址,两个对象内部的子对象还是同一个;深拷贝则会把整个对象图完整复制一遍。如果你的原型对象内部有可变对象,浅拷贝之后一改子对象,原始对象也被改了,这个 bug 非常隐蔽。
原型模式的应用场景比想象中少,通常集中在“对象创建昂贵但拷贝便宜的配置快照”“需要保留现场再恢复”这类需求上。平时用得不多,但在面试里它是一个容易踩坑的知识点,尤其要注意 Java 的clone()方法默认是浅拷贝,要实现深拷贝得自己重写或者走序列化。另外,现代编程里很多库提供的“基于原型的对象克隆”能力,其实就是这个模式在实际工程中的变形。
3. 结构型模式:对象之间的“组装学”
创建型模式管“怎么造”,结构型模式管“怎么拼”。你已经有了一堆类和对象,现在的问题是怎么把它们组合起来,形成更大的结构,同时还要保持灵活、低耦合。7 种结构型模式,每种都是一种不同的“拼法”。
3.1 适配器模式:让老接口重新上岗
适配器模式就是搞兼容的。你的系统里有一段老代码,它的接口长这样:
public void sendBySms(String content) { ... }现在你接了个新服务,对外暴露的接口叫sendMessage(Message msg)。你不可能把老代码全部改掉,也不想因为换服务就把上层逻辑推翻,于是你写一个适配器:
public class SmsServiceAdapter implements NewMessageService { private OldSmsService oldService; public void sendMessage(Message msg) { oldService.sendBySms(msg.getText()); } }适配器模式做的事一句话就能概括:把一个类的接口转换成调用方期望的另一个接口。它有两个常见实现形态:对象适配器使用组合,类适配器使用多继承。现代开发里组合优先于继承,所以对象适配器更常见。
真实的例子到处都是:读取 SD 卡的读卡器、电源转换插头、老系统里无法修改的第三方 SDK 接入……你在接各种第三方厂商时候写的那一层“Wrapper”,只要目的是“抹平接口差异”,本质上都是适配器模式。它的代价也很明显——多了一层间接。你要是新的老接口差异实在太大,适配器代码就会变成一团乱麻,这时候就得考虑在更高层统一模型,而不是靠适配器硬扛。
3.2 桥接模式:多维变化的解药
桥接模式想解决的问题是“多维度的变化”。看一个经典例子:你想做一个消息发送系统,消息可以从短信、邮件、站内信三种渠道发出去,消息本身可以分普通消息和加急消息。如果按不同维度组合,你能用继承做:
- 普通短信消息
- 加急短信消息
- 普通邮件消息
- 加急邮件消息
只要渠道再加一个,或者消息级别再加一个,类数量就成倍膨胀,继承体系瞬间爆炸。桥接模式的思路是:把“消息类型”和“发送渠道”这两个维度拆开,分别建立抽象,再用组合的方式把它们桥接起来。
抽象那边只管消息内容处理,实现那边只管真正发送动作。业务代码里把具体的渠道对象传给消息对象,运行时再决定哪两个搭配。这个模式特别适合“抽象部分和实现部分都可以独立演化”的系统。GUI 框架里“窗口”和“操作系统绘制接口”的关系就是典型的桥接;日志系统里“日志格式”和“日志输出地”也是两个独立变化的维度。
最容易搞混的是桥接模式和适配器模式:适配器主要是“事后弥补不兼容”,桥接则是“事前设计分离变化维度”。用生活化的话讲,适配器是“转接头”,桥接是“把插头和线分开设计,各自升级互不干扰”。
3.3 组合模式:树形结构一把梭
组合模式处理的是“部分-整体”这种层次结构。最经典的是文件系统:一个文件夹里可以装文件,也可以装子文件夹,无论你面对的是叶子节点还是容器节点,你都希望用统一的方式去处理它。组合模式通过让“叶子对象”和“容器对象”实现同一个接口,使得客户端能够一致地对待单个对象和组合对象。
菜单系统、公司组织架构、树形表格、XML/HTML 文档树的节点遍历,都属于这种场景。比如你要做一个页面渲染引擎,一个节点可能是纯文本,也可能是一个包含若干子节点的容器,渲染逻辑希望递归调用render()方法,组合模式就能让遍历和渲染代码极简。
设计时还有分支:一个组合对象要不要直接暴露出 add/remove 子节点的方法?如果放在父类接口里,叶子节点就退化成调 add 会抛异常的状态,这叫做透明式,优点是客户端统一,缺点是安全性差。如果把 add/remove 只放进容器类接口,就是安全式,调用要强转。实际开发中我一般推荐安全式,宁可强转一下,也不要让一个文件对象身上挂着 addChild 这种说不通的方法。
3.4 装饰器模式:不碰源码也能增强功能
装饰器模式是我个人用得最多的结构型模式之一。它解决的问题是:你想给某个对象增加新行为,但又不希望修改原有类,也不希望用继承写死一大堆子类。装饰器模式的做法是拿一个包装对象把原对象包起来,包装对象里比原对象多做一点事,然后再把多数调用转发给原对象。
Java 的 I/O 流库是教科书级别的例子:new BufferedInputStream(new FileInputStream("a.txt"))。FileInputStream 负责读文件,BufferedInputStream 负责在它外面套一层缓冲,提高了读取效率,但它并没有修改 FileInputStream 的源码。你再套一层DataInputStream又能加一层新的能力。一层包一层,能力逐层叠加。
装饰器的价值在于“叠加”和“自由组合”,它符合组合优于继承的原则。日志系统里给基础日志器加格式化能力、给文本过滤器加压缩能力、给 HTTP 客户端加超时重试能力,都能用装饰器优雅实现。但要注意装饰器也有坏处:包装层太多会导致调试特别痛苦,堆栈信息里全是 Wrapper 套 Wrapper;而且装饰器和被装饰对象的身份会不同,equals()、instanceof这些判断都可能失效。我见过有人把装饰器用了五六层,后来排查一个性能问题翻代码翻到崩溃,所以装饰器也讲究适可而止。
3.5 外观模式:给复杂系统开一扇小门
外观模式的目的很简单:给一个复杂的子系统提供一个统一、简化的高层接口。你不需要知道子系统里面有多少个类、多少个步骤,只需要调用门面类上的一两个方法,剩下的事都交给门面去协调。
举个例子,你接一个支付系统,内部涉及用户校验、账户扣款、余额变动、流水记录、通知发送,调用方每用一个功能都去跟五个类打交道,所有人都崩溃。这时候提一个PaymentFacade.pay(),内部帮你把五个类挨个调一遍,外部只感知到一个入口。
这跟适配器有什么区别?适配器是“让接口匹配”,外观是“让接口简单”。适配器是一种被动的兼容手段,外观是一种主动的封装策略。外观模式特别适合老系统改造,你可以不动底层逻辑,先加一个门面,让新业务走门面,老业务慢慢迁移。但外观也不是乱加的,如果一个门面类把所有业务逻辑全揉进去,它就变成了“上帝对象”,这时候不是简化,而是灾难。
3.6 享元模式:共享实例,省内存
享元模式关注性能,核心思想是“能共享就不重复创建”。什么叫能共享?如果一个对象的内在状态(不会随环境变化)是相同的,那它就可以被多个地方共享;外在状态则由使用场合决定,不放进共享对象里。
Java 里的Integer缓存就是享元思想的体现:Integer a = Integer.valueOf(127); Integer b = Integer.valueOf(127);这两个变量实际指向同一个对象,因为Integer默认缓存了 -128 到 127 之间的值。字符串常量池也是,字符串字面量相同的引用直接复用。
游戏开发里经常用享元模式处理大量粒子效果,几个复用的纹理加上每个粒子的坐标、速度,就能避免创建上万个重复纹理对象。前端表格组件里也会缓存单元格样式对象,避免每个单元格都持有完整的样式对象副本。
使用享元的代价是引入了“内存换判断”的复杂度:对象一共享,状态就得小心区分,谁改坏了谁负责很难说清。如果一个对象的共享状态发生了修改,全部引用方都会受影响。所以享元模式一般不作为日常开发的首选,只有当你通过性能分析确认“大量重复对象占了太多内存”的时候,才值得引入。
3.7 代理模式:为对象找一重“替身”
代理模式和装饰器模式的结构非常像,都是写一个包装类,都是转发调用。但它们的意图完全不同:装饰器是为了“添加新功能”,代理是为了“控制对原对象的访问”。
经典的代理场景有以下几类:
- 远程代理:本地对象是远程服务的替身,调用本地方法实际上走网络请求。
- 虚拟代理:对象创建代价高,先用代理顶着,真正需要时才懒加载。
- 保护代理:控制调用权限,比如只允许特定角色执行特定方法。
Spring AOP 就是典型的动态代理应用,你对 Service 方法加事务、加日志、加权限校验,业务代码完全无感知,实际上 Bean 已经被包装成了代理对象。权限系统里想对敏感接口做“只能管理员调用”的拦截,用代理模式很自然。
记一个最容易考的对比:代理和装饰器。代理偏向“控制访问权限、控制生命周期”,装饰器偏向“增强能力”。同一个HttpClient类,你加个重试功能,那是装饰器;你加个请求白名单过滤,那是代理。面试时把这个区别讲清楚,比背定义加分得多。
4. 行为型模式:对象之间怎么说话
创建型管“生”,结构型管“形”,行为型管“为”。这一类一共 11 种,是所有模式中最多的。它们关心的是对象之间如何分配职责、如何通信、如何协同完成某一个任务。这部分信息量最大,也最容易让人懵,我按“谁和谁说话、怎么说”的思路逐个拆。
4.1 观察者模式:状态一变,全员通知
观察者模式的核心是“发布-订阅”:一个对象状态改变时,所有依赖它的对象都得到通知并自动更新。最经典的例子是 Excel 里的图表,数据变,图表跟着变;你在前端写的addEventListener,本质也是观察者模式。
发布者只维护一堆订阅者列表,发布状态变化时挨个调用订阅者接口。这样发布者不需要知道订阅者具体是谁、具体做什么,两者解耦。消息队列、事件总线、MVC 里的 Model 通知 View 更新,都是观察者的变体或者应用。
用观察者模式的时候有两个地方必须留神:一是回调顺序,订阅者被通知的顺序如果不确定,而订阅者之间存在依赖的话,就会出诡异 Bug;二是循环通知,如果 A 通知 B,B 又反过来改了 A 的状态,那就会陷入无限循环。我在线上遇到过一版实现,通知事件里又触发了另一个通知,最后栈溢出。后来加了一个“是否在通知中”的守卫标记,才把问题压住。
4.2 策略模式:把算法换着用
策略模式是最容易理解也最好上手的模式之一:定义一族算法,各自封装起来,让它们可以互相替换。客户端选择哪个策略,就传入哪个策略对象;以后要加新算法,你只需要加一个新策略类,不需要改动原有逻辑。
处理订单时,不同的会员等级对应不同的折扣算法,新手最容易写if (level == 1) ... else if (level == 2),等级一多就是灾难。策略模式把折扣算法抽成接口,黄金会员策略、白银会员策略各自一个实现类,运行时从工厂或容器里取。支付渠道的选择也同理:微信支付、支付宝、银行卡,每一种支付都是一个策略。
策略模式不算难,但有一个细节很重要:策略类经常是无状态的,应该共享使用而不是每次 new。所以很多框架里的策略都会注册成单例或者从容器里取。另外,策略的选择逻辑如果不在客户端而在某个类里,那可以结合简单工厂,把创建和选择封装起来,否则调用方还是一堆 if-else,等于白做。
4.3 状态模式:状态机的一种优雅写法
状态模式容易和策略模式混淆,因为类图长得几乎一样。区别在于:策略模式的客户端主动选择策略,可以随时切换;状态模式的“状态迁移”是被内置在状态对象里的,调用方并不需要关心当前处于哪个状态。
最常见例子是订单状态机:待支付、已支付、已发货、已签收、已取消。不同状态下执行同一个“支付”动作,效果完全不一样;状态之间还有迁移规则,比如已取消的订单不能发货。如果用一堆 if-else 判断状态,随着状态增多,代码会越来越难维护。状态模式把每个状态封装成类,状态类里定义“这个状态下执行什么操作、下一个状态迁到哪”,相当于把状态机的流转逻辑按状态拆分。
我在项目里维护过一个审批流程状态机,一开始用枚举加 if-else,后来加了四五个状态以后到处都是if (status == A && action == B)。用状态模式重构之后,新增一个状态只需要新增一个类,再把迁移规则写进去,代码清晰多了。不过也别过度,状态少、流转简单,直接用一个状态枚举加一张迁移表就够了,没必要上状态模式。
4.4 模板方法模式:骨架我定,细节你填
模板方法模式定义一个操作中的算法骨架,把某些具体步骤延迟到子类实现。白话讲,父类把流程定死,子类只能填其中的空,不能改写流程顺序。
比如“泡一杯饮料”的过程:烧水、冲泡、倒杯、加料。茶和咖啡在“冲泡”和“加料”上完全不同,但整体流程一致。你可以在抽象类里写死makeBeverage()的流程,再留出brew()和addCondiments()两个抽象方法让子类实现。
模板方法模式在框架设计里无处不在。Spring 的JdbcTemplate、JSF 的生命周期、各种工作流引擎,都把流程定好,把“差异点”留给你。它非常符合“好莱坞原则”——别打电话给我们,我们会打电话给你。继承体系里,如果你的父类方法已经把流程写得七七八八,只有局部步骤需要子类填空,那基本上就是模板方法模式。这里有个小技巧:非必须的步骤可以做成“钩子方法”,父类里给个空实现,子类想增强就重写,不影响其他流程。
4.5 命令模式:把请求变成对象
命令模式把“一个操作”封装成一个对象,从而让请求的发起者和请求的执行者彻底解耦。这个“命令对象”可以被排队、被记录、被撤销。
编辑器里的撤销功能是命令模式的绝佳例子。你在文本框里敲一个字,系统不直接执行写入操作,而是生成一个“插入命令”对象丢到命令队列里。要撤销,就从队列尾部取出命令对象,调用它的undo()方法。重做也是一样的逻辑。操作日志、事务回滚、宏录制,背后都是命令对象在起作用。
命令模式的好处是把“干什么”和“怎么干”分开。调用方只需要command.execute(),不需要关心接收者是谁。缺点也很明显:每个操作都要新建命令类,类数量会变多。所以这种模式用于“操作种类多、需要支持撤销/重做/日志”的场景时价值最大,普通业务里贸然引入会让代码变得很啰嗦。
4.6 责任链模式:层层过滤,谁行谁上
责任链模式把多个处理者串成一条链,请求沿着链依次传递,每个处理者要么处理它,要么把它传给下一个。它的价值是让发送者不用关心具体哪个对象能处理请求,收到请求的对象自己决定是否处理以及是否继续传递。
最典型的例子是过滤器和拦截器。请求到 Servlet 之前会经过一堆 Filter,每个 Filter 都决定“放行”还是“中断”,这就是一条责任链。日常开发里的审批流、风控规则链、参数校验器,也适合用责任链来做。新增一个校验规则,不用改动原来的校验逻辑,只要往链上追加一个节点。
设计责任链的时候要留心“短路”和“照顾不周”两个问题。短路是好事,某个节点处理完就直接 return,后面的节点就不用执行;但如果某个节点判断有误,请求悄悄被吞掉,排查起来很头疼。我踩过这种坑:日志中间件链里有个节点因为配置错误,直接吞了请求,后面业务全没执行。定位问题花了大半天,后来在链的尾部加了一个兜底节点,任何“链走完了还没处理”的请求都会触发告警,问题才彻底暴露出来。
4.7 迭代器模式:不暴露内部也能遍历
迭代器模式大概是所有模式里最“基础”的一个:提供一种方法顺序访问一个聚合对象里的各个元素,而不暴露它内部的表示。Java 的Iterator、C++ 的 STL 迭代器、各种语言的 for-each 语法,背后都是迭代器思想。
它的主要目的是解耦“遍历逻辑”和“集合实现”。你的集合底层可能是数组、链表、树,但客户端只需要一套统一的hasNext()/next()就能遍历。你改底层实现,客户端的遍历代码完全不用变。
这个模式现在基本被语言内置特性取代了,很少需要你亲自写一个迭代器。但理解它的意义在于:当你设计一个自定义集合对象时,如果希望客户端能用 for-each 遍历它,就得实现对应的迭代器接口。面试也喜欢考 ArrayList 和 LinkedList 迭代器的行为差异,比如遍历过程中对集合做修改会抛出ConcurrentModificationException,本质上是因为迭代器内部维护了一个修改计数,集合一变,迭代器就“作废”了。
4.8 中介者模式:别让对象互相纠缠
中介者模式的核心是“用一个中介对象来封装一组对象的交互”。没有中介者的时候,多个对象之间互相调用、互相引用,关系像一张乱网;加了中介者,所有对象都只和中介者通信,网状关系变成星状关系。
聊天室是最经典的例子:用户 A 发消息,不是直接发给用户 B、C、D,而是发给聊天室服务器,再由服务器转发给其他人。如果没有聊天室,每个用户都得知道其他所有用户的存在和地址,用户一多就炸了。
MVC 里的 Controller 也是一个中介者角色:View 不直接操作 Model,Model 变化也不直接改 View,全部通过 Controller 协调。中介者模式的代价是中介者本身可能变得非常庞大,承担所有业务协调逻辑,变成一个大管家。所以用的时候要有意识地拆分中介者的职责,别让它膨胀成一个什么都要管的“上帝对象”。
4.9 备忘录模式:时光回溯的存档点
备忘录模式用来捕获一个对象的内部状态,并在将来某个时刻把对象恢复到之前的状态,实现“撤销/回滚”,而且整个过程不破坏封装性。
文本编辑器的撤销功能、游戏里的存档读档、数据库事务的回滚日志,都是备忘录模式的现实应用。它一般涉及三个角色:发起人(要被保存/恢复的对象)、备忘录(保存状态的快照)、负责人(管理备忘录的存取)。
这个模式最大的坑是:如果备忘录里保存的是对象的内部引用,而不是深拷贝,那“存档”实际上存了个引用,发起人一改,备忘录里的状态也被改了,恢复功能就失效了。所以备忘录的“快照”必须做到深拷贝,或者用序列化把对象完整复制一份。代价是可能消耗不少内存,所以通常还得做快照数量限制和分代管理,不可能无限存档。
4.10 解释器模式:小而美的语法引擎
解释器模式用来定义一门“语言”的语法,并解析执行用该语言写出的句子。正则表达式、SQL 解析器、配置文件解析、表达式计算,凡是能归纳出语法规则的场景都能用它来建模。
它的思路是:每一种语法规则都对应一个表达式类,用表达式对象的组合来描述一个复杂表达式。比如定义一个加减乘除表达式,你会有数字节点、加法节点、乘法节点,解析2 + 3 * 4的时候,就构建一棵由这些节点组成的语法树,然后递归求值。
解释器模式之所以在实战中用得少,是因为它很容易把类建得很多,而且做真正的语法解析需要考虑优先级、括号、错误处理,复杂度会迅速失控。如果你只是想算个表达式,直接调现成的表达式引擎或第三方库比自己实现靠谱得多。真正的解释器模式更适合面试题和特定领域的小型 DSL,我不会在正常业务里轻易手写解释器,那往往是过度设计的开始。
4.11 访问者模式:不改元素类,添加新操作
访问者模式是 GoF 23 种模式里公认最难理解、用得也最少的一种。它的目标是:在不修改现有元素类的前提下,给这些元素增加新的操作。
典型场景是编译器里的语法树。你有IfNode、WhileNode、AssignNode各种节点,每个节点结构已经定型,但你今天可能要遍历树做“变量检查”,明天要做“代码生成”,后天要做“代码格式化”。如果每次加操作都去改每个节点类,改动会非常爆炸。访问者模式把“操作”抽成一个访问者类,每种操作一个实现,节点类只留下一个accept(Visitor)方法,访问者进来就能对节点施加新的逻辑。
它的核心机制是“双分派”:调用哪个visit方法,不仅取决于访问者类型,还取决于节点类型,由节点自己把自身传给访问者。代价也很明显:增加一个新的节点类,所有访问者都要改;访问者内部访问具体元素类型时往往要强转,类型安全性打折。所以除非你的元素类集合非常稳定、但操作经常变化,否则慎用。
5. 模式对比与选型:别再拿着锤子找钉子
看完成 23 种模式,最痛苦的问题来了:我到底该用哪个?很多人学完以后变成“手里只有一把锤子”,看什么代码都像钉子。实际上设计模式的选型应该反过来,从问题出发,先识别你要解决的“坏味道”,再去匹配对应的模式。
5.1 易混淆模式对比表
最容易混淆的是下面这几对,我按“意图”和“使用场景”做了个速查表:
| 模式组合 | 核心区别 | 一句话选型 |
|---|---|---|
| 工厂方法 vs 抽象工厂 | 前者创建单个产品,后者创建一整套产品族 | 只换一个产品用工厂方法,要成套换用抽象工厂 |
| 策略 vs 状态 | 策略由客户端主动切换,状态由状态对象内部驱动迁移 | 调用方决定算法用策略,对象自己变行为用状态 |
| 装饰器 vs 代理 | 装饰器增强功能,代理控制访问 | 加功能用装饰器,拦访问用代理 |
| 适配器 vs 外观 | 适配器为兼容接口,外观为简化入口 | 接口不匹配用适配器,系统太复杂用外观 |
| 命令 vs 策略 | 命令封装的是请求和执行者,策略封装的是算法 | 要排队/撤销用命令,要换算法用策略 |
| 观察者 vs 中介者 | 观察者是发布-订阅一对多,中介者是多个对象一对一协调 | 通知扩散用观察者,网状关系收敛用中介者 |
| 组合 vs 装饰器 | 组合表示部分-整体树结构,装饰器层层包装增强 | 树形递归用组合,层层叠加能力用装饰器 |
这张表你如果只看结构,很多模式画出来确实像;但设计模式看的是意图和动机,而不是类图长得像不像。面试被问“策略和状态的区别”,光说“一个是算法一个是状态”不够,最好加上“切换行为由谁触发”这个关键点。
5.2 按场景选型:从问题出发而不是从模式出发
在真实项目里,我的选型思路大致是“三段问”,没有先入为主的模式清单:
第一问,我遇到的是创建对象问题吗?如果new到处都是、换实现类很痛苦,往创建型那 5 种里找。具体来说:只要全局唯一,考虑单例;产品会成套替换,考虑抽象工厂;对象构造参数太多,考虑建造者;创建成本高又需要多个相似对象,考虑原型;创建逻辑需要延迟到子类,考虑工厂方法。
第二问,我遇到的是组合结构问题吗?如果类与类之间的组合关系在爆炸,往结构型那 7 种里找。接口不兼容,用适配器;两层维度都在变化,用桥接;有树形结构要统一处理,用组合;不想改旧类又想增强,用装饰器;子系统对外太复杂,用外观;重复对象太多吃内存,用享元;要控制对象访问,用代理。
第三问,我遇到的是对象间的协作问题吗?如果对象之间通信混乱、职责分配失控,往行为型那 11 种里找。一对多通知,用观察者;算法可选和互换,用策略;状态迁移复杂,用状态;流程骨架固定,用模板方法;请求要排队撤销,用命令;过滤链式处理,用责任链;需要遍历聚合对象,用迭代器;网状关系解耦,用中介者;需要保存恢复快照,用备忘录;要实现简单语法,用解释器;要给稳定的类族增加操作,用访问者。
这样从“问题的标签”倒推模式,比背 23 个定义高效得多。我建议你把这张“问题 → 模式”速查表做成思维导图的第一层分支,复习的时候直接按问题索引,而不是按模式索引。
5.3 模式不是孤立存在的,组合拳才好使
现实项目里,一个完整功能往往是多个模式配合的结果,很少有一个类只用一个模式的情况。比如一个订单支付模块,可能是这样拼出来的:
- 用工厂方法创建不同的支付渠道策略;
- 用策略模式封装不同支付渠道的算法;
- 用观察者模式在支付成功后通知订单状态更新和库存扣减;
- 用命令模式记录每一笔支付操作,支持对账时重放;
- 用外观模式对外暴露一个简单的
pay()入口。
这就是设计模式真正的使用姿势:它们是积木,不是孤岛。学 23 种模式的时候,单看每一种都不难,难的是当你面对一段复杂的业务逻辑时,能敏锐地发现“这里适合上模板方法”“那里需要一个中介者”。
另外要记住,模式不是银弹。如果代码已经乱成一锅粥,加再多模式也只是给这锅粥加了调料,改变不了它还是一锅粥的事实。遇到这种情况,先做职责拆分和重构,模式是重构完成后的自然产物,而不是重构的开端。
6. 常见问题与实战排查:那些文档里不会写的事
聊完 23 种模式,最后补点真正的“实战体会”。这些内容大多不在教科书里,但你在真实编码中迟早会遇到。我在这里集中整理一批最常见的误区和踩坑记录。
6.1 学完就忘怎么办?给自己搭“模式记忆卡”
如果你发现学了后面忘了前面,非常正常。23 个模式的记忆量确实不小,硬背很快会丢。我自己用的方法是“一卡一图一句话”:每学一个模式,给自己做一张记忆卡,卡片上只有三样东西——一个能立刻想起来的生活案例、一张画了核心对象关系的简图、一句“这个模式是干什么的”大白话。
比如策略模式的卡片:生活案例是“用地图 App 选不同路线”,对象关系只是“接口 + 若干实现 + 运行时切换”,大白话是“把算法单独拿出来换着用”。复习的时候只看卡片上的这三样,不看完整笔记。
等到复习阶段,再把这 23 张卡按三大分类挂到一张思维导图上:创建型、结构型、行为型,每类的核心关键词分别提炼成“怎么生”“怎么拼”“怎么聊”。以后再看到任何代码,先从脑子里把这三大问题的答案扫一遍,你的模式识别能力会越来越敏锐。
6.2 最容易踩的 5 个坑
第一个坑是单例的并发安全。懒汉式不加锁、双重检查不加volatile,这两种写法在线上并发环境下都可能出问题。用过枚举;或者用静态内部类持有实例,也能兼顾线程安全和懒加载。
第二个坑是装饰器叠加顺序。包装顺序不同,执行顺序可能完全不同。比如一个过滤器链,先做压缩再做加签名,和先做加签名再做压缩,结果完全不同。调试这种问题时先别慌,把包装顺序画出来,一层层往下剥。
第三个坑是观察者通知回调里的异常。一个订阅者抛了异常,后面所有订阅者都收不到通知。所以发布事件时一定要在通知循环里做异常隔离,别让一颗老鼠屎坏了一锅汤。
第四个坑是状态模式里的状态转移判断。状态对象内部直接写“下一个状态是谁”,容易把迁移规则写散。实践上我会把状态迁移表单独抽出来集中管理,状态类只关心行为,迁移关系统一查表,这样状态多了也不会乱。
第五个坑是过度设计。为写设计模式而写设计模式,是初学者最爱犯的错。一个只有两个实现类的逻辑,硬套抽象工厂,真的是自找麻烦。设计模式的正确打开方式是“识别坏味道 → 确定根因 → 挑选最小可行的模式”,只有当你看到if-else越来越长、类越来越多、改动总是这里动一下那里动一下的时候,才值得引入模式。
6.3 在源码里认出模式的技巧
如果你是边读框架源码边学设计模式,这里有一个很实用的偷懒技巧:先看类名。Java 生态里大量类直接带模式关键词,比如XxxFactory、XxxBuilder、XxxAdapter、XxxProxy、XxxStrategy、XxxTemplate、XxxObserver,名字本身就是下一层线索。
跟着再看“构造方式”:类里有没有全局唯一的静态实例?有就是单例;有没有把接口实现类注入进来?有就是策略或装饰器;有没有在构造时传别的类型又能动态替换?有就是桥接或适配器。
最后看“协作方式”:一堆类相互调用很乱,还是都通过一个中间对象协调?通过中间对象就是中介者;谁被通知了就会更新,那就是观察者。源码读多了你会发现,模式识别其实就是“命名 + 结构 + 协作方式”三个维度综合判断,不用背类图。
有一点值得专门提一下:很多框架里所谓的“模板方法”,类名不一定叫 Template,而是通过“骨架方法在父类、钩子方法在子类”这种结构体现出来。Spring 的很多扩展点就是模板方法模式的实践。你读源码的时候如果看到一个父类方法里调用了一堆抽象或空实现方法,那大概率就是模板方法的味道。
写给自己的一点总结
从 2000 年左右开始接触设计模式到现在,我最大的体会是:设计模式不是用来背诵的,而是用来“看见”的。你能在别人写的代码里看见模式的影子,能在自己的老代码里闻出坏味道,能为了一个小需求选择最轻量的方案而不是最复杂的方案,这才是真的学会了。23 种模式,保守地讲,90% 的业务代码能用到的最多不超过 10 种,剩下的 13 种更像是一个共享词汇表——知道它们存在,能看懂别人的设计,就足够了。我建议你把这个过程当成一场持续练习:每读一个开源项目,试着标注出自己认出的模式;每重构一段老代码,想想有没有更合身的结构。时间的复利会告诉你,当初花在理解设计模式上的功夫,真的很值。