编码 · 设计模式
设计模式详解:23 种 GoF 设计模式
简短回答
设计模式是针对软件设计中反复出现的问题所总结的、有名字的可复用解决方案,描述层次高于具体代码。最经典的模式目录是 1994 年由 Gamma、Helm、Johnson 和 Vlissides(即“四人帮”,Gang of Four,GoF)合著的《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software),书中列出 23 种模式,分为三类:5 种创建型、7 种结构型和 11 种行为型。设计模式为团队提供了共同的交流语言,但它们是解决特定问题的工具,而不是目标本身。
什么是设计模式
设计模式不是可以直接复制的库或代码片段,而是对一个反复出现的问题、解决方案的核心思路以及使用它的权衡取舍的描述。具体代码仍然需要你根据所用语言和自己的程序来编写。
GoF 一书用四个基本要素来描述每一种模式:
- 名称:一两个词,方便开发者交流设计,例如“可以用装饰器(Decorator)把它包一层”。
- 问题:何时应该使用这个模式,以及什么样的场景需要它。
- 解决方案:涉及的类或对象、它们各自的职责以及协作方式。
- 效果:使用后的收益与代价,例如以更多的类换取更高的灵活性。
这个思想来自建筑学。Christopher Alexander 在 1977 年出版的《A Pattern Language》中总结了建筑和城镇规划中反复出现的解决方案,GoF 的作者把同样的思路应用到了面向对象软件上。
四人帮(GoF)与那本书
《设计模式:可复用面向对象软件的基础》由 Addison-Wesley 于 1994 年出版。书中的示例使用 C++ 和 Smalltalk,但这些模式适用于任何面向对象语言,包括 Java、C#、TypeScript、Python 和 Kotlin。
除了模式目录,这本书还提出了贯穿大多数模式的两条通用原则:
- 针对接口编程,而不是针对实现编程。代码应当依赖对象能做什么,而不是它的具体类。
- 优先使用对象组合,而不是类继承。通过组合对象来构建行为,通常比构建很深的类继承层次更灵活。
书中还按作用范围对模式分类:类模式使用继承,在编译期就已确定(工厂方法、类适配器、解释器、模板方法);对象模式使用组合,可以在运行时改变(其余所有模式)。
按类别列出全部 23 种 GoF 模式
创建型模式关注对象如何创建;结构型模式关注如何把类和对象组合成更大的结构;行为型模式关注对象之间如何通信并分配职责。
| 模式 | 类别 | 一句话意图 |
|---|---|---|
| Abstract Factory(抽象工厂) | 创建型 | 创建一族相关对象,而无需指明它们的具体类。 |
| Builder(生成器) | 创建型 | 分步骤构造复杂对象,把构造过程与对象的表示分离。 |
| Factory Method(工厂方法) | 创建型 | 由子类决定实例化哪个类。 |
| Prototype(原型) | 创建型 | 通过复制已有实例来创建新对象。 |
| Singleton(单例) | 创建型 | 保证一个类只有一个实例,并提供全局访问点。 |
| Adapter(适配器) | 结构型 | 把一个接口转换成客户端所期望的另一个接口。 |
| Bridge(桥接) | 结构型 | 把抽象与实现分离,使两者可以独立变化。 |
| Composite(组合) | 结构型 | 用相同的方式对待单个对象和对象树。 |
| Decorator(装饰器) | 结构型 | 通过包装对象,动态地为其增加职责。 |
| Facade(外观) | 结构型 | 为复杂的子系统提供一个简单的统一接口。 |
| Flyweight(享元) | 结构型 | 共享公共状态,以高效支持大量细粒度对象。 |
| Proxy(代理) | 结构型 | 提供一个替身,用来控制对另一个对象的访问。 |
| Chain of Responsibility(责任链) | 行为型 | 让请求沿着处理者链传递,直到有一个处理者处理它。 |
| Command(命令) | 行为型 | 把请求封装成对象,使其可以排队、记录日志或撤销。 |
| Interpreter(解释器) | 行为型 | 用类表示简单语言的语法,并解释执行该语言的句子。 |
| Iterator(迭代器) | 行为型 | 按顺序访问集合中的元素,而不暴露其内部结构。 |
| Mediator(中介者) | 行为型 | 集中管理对象之间的通信,使对象不必直接相互引用。 |
| Memento(备忘录) | 行为型 | 在不破坏封装的前提下捕获并恢复对象的状态。 |
| Observer(观察者) | 行为型 | 当一个对象发生变化时,自动通知所有依赖它的对象。 |
| State(状态) | 行为型 | 让对象在内部状态改变时改变其行为。 |
| Strategy(策略) | 行为型 | 让一族算法可以在同一个接口之后互相替换。 |
| Template Method(模板方法) | 行为型 | 定义算法的骨架,把其中某些步骤交给子类实现。 |
| Visitor(访问者) | 行为型 | 在不修改类层次结构的前提下,为其增加新的操作。 |
实战中的模式:TypeScript 策略模式
下面是一个模式实际应用的小例子。结账流程需要用不同的方式计算运费。与其写一长串不断增长的 if/else,不如把每条规则做成同一个接口背后的可互换对象:
interface ShippingStrategy {
cost(weightKg: number): number;
}
class FlatRate implements ShippingStrategy {
cost(): number { return 5; }
}
class PerKilogram implements ShippingStrategy {
cost(weightKg: number): number { return 2 * weightKg; }
}
class Checkout {
constructor(private shipping: ShippingStrategy) {}
setShipping(s: ShippingStrategy): void { this.shipping = s; }
total(itemsPrice: number, weightKg: number): number {
return itemsPrice + this.shipping.cost(weightKg);
}
}
const checkout = new Checkout(new FlatRate());
console.log(checkout.total(40, 3)); // 45
checkout.setShipping(new PerKilogram());
console.log(checkout.total(40, 3)); // 46在 TypeScript 中,函数本身就是值,所以同样的设计可以用更少的代码写出来。这是一个常见的规律:在支持头等函数(first-class function)的语言里,许多模式会变得更简单,甚至消失。
type Shipping = (weightKg: number) => number;
const flatRate: Shipping = () => 5;
const perKilogram: Shipping = (w) => 2 * w;
function total(itemsPrice: number, weightKg: number, shipping: Shipping): number {
return itemsPrice + shipping(weightKg);
}
console.log(total(40, 3, flatRate)); // 45
console.log(total(40, 3, perKilogram)); // 46如何选择设计模式
从问题出发,而不是从模式出发。只有当一个模式能消除代码中真实存在的痛点时,它多出来的那些类才物有所值。下面这些问题可以帮你找到常见的候选模式:
| 如果你的问题是…… | 可以考虑 |
|---|---|
| 代码不应依赖它所创建的具体类 | 工厂方法、抽象工厂 |
| 构造函数有很多可选参数 | 生成器(Builder) |
| 需要把现有的类适配到另一个接口 | 适配器(Adapter) |
| 复杂的库需要一个简单的入口 | 外观(Facade) |
| 想给对象增加功能,又不想子类数量爆炸 | 装饰器(Decorator) |
| 要处理树形结构,例如菜单、文件或 UI 组件 | 组合、访问者、迭代器 |
| 一个对象变化时,许多部分要做出反应 | 观察者(Observer) |
| 行为取决于模式或状态,分支很多 | 状态(State) |
| 需要可互换的算法或规则 | 策略(Strategy) |
| 操作需要撤销、排队或记录日志 | 命令、备忘录 |
- 先写出最简单、能正常工作的代码。
- 当同一种修改反复带来麻烦时(例如每增加一种支付方式就要修改五个文件),把这个问题明确地说出来。
- 查找针对该问题的模式,并阅读它们的效果与代价。
- 只应用能消除痛点的最小版本的模式,到此为止。
批评与局限
设计模式很有用,但经常被误用。下面这些常见的批评值得了解:
- 过度使用:“以防万一”而添加工厂、接口和管理类,会让简单的代码难以阅读。这有时被称为“模式狂热”。
- 语言特性缺失:1996 年,Peter Norvig 指出 23 种 GoF 模式中有 16 种在 Lisp 和 Dylan 等动态语言中不需要或更简单。头等函数可以取代策略和命令,内置迭代器可以取代迭代器模式。
- 尤其是单例,当它被当作全局状态使用时,被普遍认为是一种反模式,因为它隐藏了依赖关系,也让测试更困难。
- 这套目录出自 1994 年。后来的实践又产生了书中没有的其他知名模式,例如依赖注入、仓储(Repository)以及各种 Model-View-Controller 变体。
这本书长久的价值在于它提供的词汇,以及权衡取舍的思维习惯。说一句“这是个适配器”,两个词就能向其他开发者传达大量信息。
相关原则
设计模式通常与面向对象设计的 SOLID 原则一起讲解。例如,策略和装饰器是遵循开闭原则的直接方式,抽象工厂是应用依赖倒置原则的常见做法。学习这些原则,有助于理解模式为什么是现在这个样子。
常见问题
设计模式一共有多少种?
四人帮的书中收录了 23 种模式:5 种创建型、7 种结构型和 11 种行为型。此后又出现了许多其他模式,例如针对并发、企业应用和用户界面的模式。
应该先学哪些设计模式?
先学实际代码中最常遇到的:策略、观察者、工厂方法、装饰器、适配器和外观。然后再学习生成器、组合和命令。
设计模式现在还有意义吗?
有,既可以作为共同的交流语言,也可以作为反复出现的问题的解决方案。有些模式如今已内置于语言和框架中,你无需自己写出完整的类结构就能使用它们。
设计模式只适用于面向对象语言吗?
GoF 模式是为面向对象语言编写的,但其背后的思想无处不在。在函数式语言中,许多模式会变成简单的高阶函数。
设计模式和算法有什么区别?
算法是解决计算问题(例如排序)的一组精确步骤。设计模式描述的是如何组织代码和职责,需要根据每个程序的具体情况进行调整,而不是照着步骤执行。