1977 年,建筑学家克里斯托弗·亚历山大(Christopher Alexander)出版了《建筑模式语言》——一本记录了 253 种建筑设计中反复出现的解决方案的书:如何设计楼梯、如何安排窗户、如何创造让人感到舒适的广场。他相信,这些模式是独立于具体设计的"反复出现的结构关系"。
1994 年,四位软件工程师——Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides——把这个想法引入软件设计,出版了《设计模式:可复用面向对象软件的基础》。这本书简称 GoF(Gang of Four,四人帮),定义了 23 种在面向对象设计中反复出现的模式,成为软件工程史上最有影响力的书籍之一。
它的影响有具体的量级:这本书 1994 年在 OOPSLA 大会首发,此后以英文及另外 13 种语言累计售出逾 50 万册,常被称为"最有影响力的、却不讲某一门具体语言的编程书"。四位作者中的 John Vlissides 长期任职于 IBM 沃森研究中心,2005 年因脑瘤并发症去世,年仅 44 岁。
从亚历山大到四人帮之间,其实还有两级台阶。1987 年,Kent Beck 与 Ward Cunningham 在 OOPSLA '87 的研讨会上发表《Using Pattern Languages for Object-Oriented Programs》,第一次把亚历山大的方法搬进软件:他们为 Smalltalk 的用户界面设计写了 5 个模式,拿去试验"新手照着模式描述,能否做出老手水准的设计"。1993 年 8 月,Beck、Grady Booch、Jim Coplien 等人在科罗拉多的一次山间聚会上成立了 Hillside Group,专门推动模式写作;次年首届 PLoP(Pattern Languages of Programs)会议举办,软件模式社区自此成形。GoF 的书正是在这个社区的酝酿中诞生的——它不是四个人的突发奇想,而是一场持续七年的方法迁移的结晶。
破除误解:模式不是"万能套路"
设计模式常被初学者误解为"把这些模式记住,遇到问题套进去就行"。这是最危险的使用方式。
GoF 书中最重要的一句话是:"找到变化的部分,把它封装起来"。模式是解决特定问题的方案,不是应该随处安插的"好设计"标志。
这句话背后有两条总纲,23 个模式几乎都是它们的应用。其一,"针对接口编程,而不是针对实现编程":调用方只依赖抽象接口,具体实现可以在运行期替换。其二,"优先使用对象组合,而不是类继承":继承在编译期就把行为固化、还把父类的内部细节暴露给子类(所谓"脆弱基类"问题——父类一改,子类莫名崩掉);组合则把变化点留在运行期。理解了这两条,你会发现策略、装饰器、观察者这些模式本质上都是同一件事的不同切面:把继承做不到的运行期可替换性,用组合和接口做出来。
把不需要工厂模式的地方硬加一个 AbstractFactory,把简单的函数包裹进 Strategy 对象——这是"过度工程化"(over-engineering)的典型症状,让代码更难理解,不是更好。
就连作者自己也不把这 23 种当成定论。2009 年,Gamma、Helm、Johnson 在《设计模式 15 年后》访谈中说,如果重写这本书,他们会重新归类、删掉几个、补上另外几个:把"工厂方法"并进更一般的"工厂",把解释器、享元挪进单独的"其他/复合"类,还会加入依赖注入、空对象(Null Object)、类型对象等新模式。Gamma 个人甚至主张直接删掉单例(Singleton),认为它的使用"几乎总是一种设计坏味(design smell)",只是三人没能就此达成一致。把一本书当圣经背诵,恰恰违背了作者的本意。
三类模式
有一个词 GoF 用得极谨慎:模式是被发现的,不是被发明的。亚历山大从至今仍被人喜爱的老建筑里挖掘模式,GoF 从当时已被反复验证的系统中归纳模式——一个方案要在多个互不相关的系统里反复出现,才够格被收录。这也是模式目录与"算法手册"的区别:手册教你没见过的东西,模式则是给你早已见过、却一直叫不出名字的东西命名。
GoF 把 23 种模式分为三类:
创建型(Creational):如何创建对象
工厂方法(Factory Method):定义创建对象的接口,由子类决定实例化哪个类。例:日志框架根据配置创建不同的日志输出(文件/控制台/网络)。
抽象工厂(Abstract Factory):创建相关对象的家族,而不指定具体类。例:跨平台 UI 库,根据操作系统返回 Windows 风格或 macOS 风格的控件。
单例(Singleton):确保一个类只有一个实例。例:数据库连接池、全局配置对象。
建造者(Builder):分步骤构建复杂对象。例:StringBuilder、SQL 查询构建器。
原型(Prototype):通过克隆现有对象创建新对象,而不是 new。
结构型(Structural):如何组合对象
适配器(Adapter):让接口不兼容的类能够合作。例:把老旧 API 包裹成符合新接口的形式,无需修改原始代码。
装饰器(Decorator):动态地给对象添加职责,比继承更灵活。算术对比很直观:一杯咖啡有 4 种可选配料,用子类表达就要为每种组合建一个类,共 个,新增一种配料类数翻倍;装饰器把每种配料做成一个包装类,类数随配料数线性增长,运行期自由套叠。Java I/O 流是最著名的应用:new BufferedInputStream(new FileInputStream(path))——可以无限叠加功能。代价同样具体:包装后的对象不再是原来的类型,instanceof 判断失效;层数一多,异常栈轨迹变深,调试时要在包装层里逐层剥开。
代理(Proxy):为另一个对象提供代理,控制对原对象的访问。例:懒加载(访问时才真正创建)、权限检查、缓存。
外观(Facade):为复杂子系统提供一个简化的接口。例:编译器对用户只暴露 compile(source) 接口,内部的词法分析、语法分析、代码生成全部隐藏。
组合(Composite):把对象组合成树形结构,让叶节点和容器节点有统一的处理方式。例:文件系统(文件和目录都可以"获取大小")、GUI 组件树。
桥接(Bridge):把抽象和实现分离,使两者可以独立变化。
享元(Flyweight):共享大量细粒度对象,节省内存。例:游戏中渲染大量树木,每种树只创建一个对象,位置等数据在外部存储。
行为型(Behavioral):对象如何通信
观察者(Observer):定义对象间一对多的依赖,当一个对象状态改变,所有依赖者自动通知。例:事件系统、MVC 中 Model 变化时通知 View——MVC(模型-视图-控制器)本身由挪威人 Trygve Reenskaug 于 1978–79 年在施乐 PARC 研究 Smalltalk 时提出,比 GoF 早了十多年。观察者也是现代响应式编程(RxJS、React 状态管理)的核心思想。实现上有推(push)与拉(pull)之分:通知时直接把新状态推给观察者,还是只发"变了"的信号、让观察者自己来取。这个模式还有一个经典的事故来源:长寿命的被观察者持有短寿命观察者的引用,观察者用完忘了注销就无法被垃圾回收——GUI 与前端代码里相当比例的内存泄漏由此而来。
策略(Strategy):定义一组算法,封装每个算法,使它们可以互换。例:排序算法可以在运行时切换(快排/归并/插入),调用者代码不变。在有一等函数的语言里,这个模式塌缩成了一个参数——list.sort(comparator) 传入一个 lambda,就是策略模式的日常形态,这也是 Norvig 批评的一个活例(见后文"代价与争议")。
命令(Command):把请求封装成对象,支持撤销、队列、日志。例:编辑器的撤销/重做功能,每个操作是一个命令对象。
迭代器(Iterator):提供顺序访问集合元素的方式,而不暴露内部表示。几乎所有语言的 for-each 循环都是这个模式。
模板方法(Template Method):在父类中定义算法骨架,把具体步骤推迟到子类实现。例:JUnit 的测试框架定义了 setUp → test → tearDown 的骨架。
状态(State):对象在内部状态改变时改变行为,好像改变了类。例:订单系统中的"待付款/已付款/已发货/已完成"状态机。
职责链(Chain of Responsibility):把请求的发送者和接收者解耦,由多个对象依次处理。例:HTTP 中间件管道、异常处理链。
访问者(Visitor):在不修改元素类的情况下,定义作用于元素的新操作。适合稳定的元素类型但频繁添加操作的场景(如编译器的 AST 遍历)。
中介者(Mediator):用一个中介对象封装对象间的交互,降低耦合。例:飞机场控制塔(飞机之间不直接通信,通过控制塔协调)。
备忘录(Memento):在不破坏封装的前提下,捕获和恢复对象的内部状态。
解释器(Interpreter):为语言定义文法,并实现解释该文法的解释器。
反模式(Anti-Patterns)
与设计模式相对的是反模式——在软件开发中反复出现的糟糕解决方案:
- 大泥球(Big Ball of Mud):没有清晰架构,随意添加功能,最终成为无法维护的巨大混乱
- 过度工程化(Over-Engineering):为未来可能不存在的需求过度抽象
- 神类(God Class):一个类知道太多、做太多,违反单一职责
- 意大利面条代码(Spaghetti Code):控制流混乱,难以追踪执行路径
代价与争议
模式是语言缺陷的补丁? Peter Norvig 在 1996 年的演讲《动态语言中的设计模式》里指出,GoF 的 23 个模式中,有 16 个在 Lisp 或 Dylan 这类语言里要么"消失"、要么实现起来简单得多。原因不止于 lambda:这些语言把类型和函数都当作一等公民,还提供宏、多重分派、模块等机制——在他的清单里,只有策略、命令、模板方法、访问者这 4 个真正对应"一等函数",其余则靠一等类型、宏或多重分派化解。这引出了持续至今的争论:设计模式是通用的,还是只在 Java/C++ 这类语言里才显得必要?
模式的滥用与误用:GoF 书出版后,大量 Java 代码库充斥着不必要的 AbstractSingletonProxyFactoryBean 风格的类名,成为过度设计的代名词。这个名字并非段子手的虚构——它是 Spring 框架中真实存在的类,抽象、单例、代理、工厂四个模式名词叠在一个类上。它之所以成为梗,恰恰因为把"用了模式"误当成了"设计得好"。Rails 框架的作者 DHH 明确批评"依赖注入容器"等模式为过度工程化。
单例:模式还是反模式? 单例提供一个"全局唯一访问点",听起来无害,却把全局可变状态藏进代码深处:依赖关系不再写在函数签名里,单元测试也很难把它换成假对象来隔离。正因如此,不少工程师干脆把单例归入反模式,前面提到 Gamma 想删的也正是它。常见替代是依赖注入——把依赖作为参数显式传入,而不是在内部偷偷取全局实例。这里要分清:DHH 批评的是重型"依赖注入容器"框架,而非手写传参这种朴素做法本身。
连"正确地实现一个单例"都比看起来难。为了避开每次取实例都加锁的开销,教科书里流行过"双重检查锁"(double-checked locking)写法:先无锁检查一次,再加锁检查一次。但在 Java 5 之前它是坏的——构造对象与写入引用这两个操作可能被重排,另一个线程会拿到一个"构造到一半"的实例。直到 2004 年 JSR-133 修订 Java 内存模型、强化了 volatile 语义,这个写法才算被修好。这是"模式易抄、语义难懂"的经典一课:模式告诉你结构长什么样,但结构的正确性取决于语言底层那些模式书不会讲的规则。
模式过时了吗? 在函数式编程、响应式编程兴起的背景下,许多 GoF 模式(如命令、策略、观察者)都有更简洁的函数式表达。争论的焦点是:学 GoF 模式是否还值得?共识是:理解模式背后的问题意识仍然有价值,即使实现方式已经改变。
跨域连接
- 函数式编程:二十三个模式里有一大半,在一等函数、一等类型、宏与多重分派俱全的语言中要么消失、要么塌缩成几行代码。可检验推论:模式的数量是语言表达力的补集,而不是设计智慧的度量——它记录的是这门语言缺什么。于是更有用的问法不是该用哪个模式,而是这门语言为什么需要它。
- 词与句:模式最实在的价值是命名:把一段反复出现的结构压成一个词,讨论就从描述实现细节升级为交换名词。由此可推,模式的收益随协作规模上升、随独自开发下降,因为它省下的是沟通成本而非编码成本。
- 美:那位建筑学家的模式语言带着"生成有生命的整体"这条价值判据,软件界继承了目录形式却把判据丢了。后果是可检验的:没有判据,"用了模式"就无法回答"是否更好",过度工程化在形式上因此完全合规。而判断一个设计好不好,最终仍要回到它所服务的人与场景。
- 教育与文凭主义:模式目录容易被做成可考核的知识点,于是"背得出二十三种"变成资历信号,与它本要解决的设计问题脱钩。一旦某种实践被制度化为考核项,它最易被检验的形式部分就会被过度生产。同理,反模式清单之所以有用,也是因为它给出了可判定的负面标记。
- 外部性:单例把依赖从函数签名里隐去,调用处省事,成本却转嫁给测试者与后来的维护者——这是标准的外部性。把依赖显式传参相当于把成本重新内部化,代价是签名变长,收益是依赖关系重新可见。同理,全局可变状态的代价不在写它的那一刻,而在此后的每一次调试。
参考文献
- Gamma, E., Helm, R., Johnson, R., Vlissides, J. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994.
- O'Brien, L. (interviewer). Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, and Ralph Johnson. InformIT, 2009. (作者自述会如何重写、删改这本书)
- Alexander, C., Ishikawa, S., Silverstein, M. A Pattern Language. Oxford University Press, 1977.
- Beck, K. & Cunningham, W. "Using Pattern Languages for Object-Oriented Programs." OOPSLA '87 Workshop on the Specification and Design for Object-Oriented Programming, 1987.(软件模式的第一次公开尝试)
- Alexander, C. The Origins of Pattern Theory, the Future of the Theory, and the Generation of a Living World. Keynote, OOPSLA '96; IEEE Software, 16(5), 1999.
- Reenskaug, T. Models–Views–Controllers. Xerox PARC technical note, 1979. (MVC 的一手起源记录)
- Fowler, M. Patterns of Enterprise Application Architecture. Addison-Wesley, 2002.
- Buschmann, F. et al. Pattern-Oriented Software Architecture, Volume 1. Wiley, 1996. (POSA,与 GoF 并列的架构模式参考)
- Evans, E. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003. (DDD,以领域为中心的更成熟建模方法)
延伸阅读
- Freeman, E. & Robson, E. Head First Design Patterns. O'Reilly, 2004. (最易读的入门书)
- Norvig, P. Design Patterns in Dynamic Languages. 1996. (http://norvig.com/design-patterns/)
- Martin, R. C. Agile Software Development, Principles, Patterns, and Practices. Prentice Hall, 2002. (SOLID 原则的系统阐述)