第 60 章 从重构到设计模式
学设计模式时,书里通常先给出一个模式的完整结构,再教你在什么场合用它。实际写代码时,事情往往反过来:你不会在第一天就知道这里需要一个策略模式,而是写着写着,代码里出现了重复的类型分支,你用以多态取代条件表达式把它整理掉,回头一看,得到的结构正是策略模式。这一章讲重构和设计模式的这种关系:很多手法做到底就是一个模式,重构是通往模式的路,模式是重构的一个可能的目的地。
手法与模式的对应
Section titled “手法与模式的对应”下表列出本书里结果或过程与某个设计模式对应的手法。“相关的设计模式”一节在各章已经讲过细节,这里汇总起来看全貌。
| 手法 | 章 | 做到底得到的模式 | 《设计模式》书 |
|---|---|---|---|
| 以多态取代条件表达式 | 38 | 策略(按类型选算法)或状态(按状态选行为) | 第 17、21 章 |
| 以子类取代类型码 | 52 | 状态、策略(类型会变时用类型对象的子类) | 第 21、17 章 |
| 引入特例 | 39 | 空对象 | 第 30 章 |
| 以命令取代函数 | 49 | 命令 | 第 19 章 |
| 以工厂函数取代构造函数 | 48 | 工厂方法 | 第 3 章 |
| 函数上移、提炼超类 | 51、54 | 模板方法(父类定骨架,子类填细节) | 第 20 章 |
| 以委托取代子类、以委托取代超类 | 56 | 组合优于继承;常落到策略或桥接 | 第 17、14 章 |
| 隐藏委托关系 | 18 | 外观(做到整个子系统时) | 第 11 章 |
| 改变函数声明的迁移式做法 | 6 | 适配器(过渡期的旧接口转发) | 第 9 章 |
| 以管道取代循环 | 27 | 迭代器(语言内置) | 第 22 章 |
| 引入参数对象、以对象取代基本类型、将引用对象改为值对象 | 9、15、33 | 值对象;可共享的前提与享元相同 | 第 15、30 章 |
| 拆分阶段 | 11 | 管道;与责任链形状相近 | 第 23、30 章 |
| 将值对象改为引用对象 | 33 | 仓储 | 第 30 章 |
一个例子:从 if 链到策略
Section titled “一个例子:从 if 链到策略”第 57 章的账单就是一个现成的例子。最初的代码里,计费规则是一串按课程类型的 if。经过以多态取代条件表达式,它变成了一个计算器父类和三个子类,statement_data 不再关心课程类型,只管向计算器要数字。
按《设计模式》书的说法,这就是策略模式:CourseCalculator 是策略的接口,CodingCalculator、LanguageCalculator、HobbyCalculator 是三个具体策略,statement_data 是使用策略的上下文,create_calculator 负责根据课程类型挑出合适的策略。但重构的过程中,我们从来没有“决定使用策略模式”,只是一步一步地消除了重复的条件判断。模式是重构的结果,而不是起点。
这个顺序有一个重要的好处:你只在需要的时候才引入模式。如果账单永远只有三种课程,规则也从来不变,那串 if 就挺好,不需要计算器类。只有当新需求让条件判断开始重复、开始难改时,重构才把它推向模式。
重构是通往模式的路
Section titled “重构是通往模式的路”Joshua Kerievsky 的《重构与模式》专门讲这个视角。他观察到两种常见的失败:一种是过度设计,在还不需要的时候就预先套上模式,代码里到处是只有一个实现的接口和只有一个产品的工厂;另一种是设计不足,从不重构,代码里的重复和条件判断越积越多,直到没人敢改。他的建议是走中间的路:不预先设计模式,而是在代码“闻到味道”时,用一系列小的重构把它推向合适的模式;有时也推离模式,当一个模式已经不再必要时,把它重构掉,换回更简单的结构。
本书很多章节都包含这种“推离”的方向:内联函数、内联类、移除中间人、折叠继承体系、移除子类、以函数取代命令。它们和“推向模式”的手法同样重要。一个只有一个具体策略的策略模式,用以函数取代命令和内联类把它简化回一个函数,也是一次好的重构。
一个推离模式的例子。某个项目早年预见到“以后会有多种导出格式”,于是写了一个 Exporter 接口、一个 CsvExporter 实现、一个 ExporterFactory 工厂,调用方写 ExporterFactory.create("csv").export(rows)。三年过去,导出格式始终只有 CSV 一种。每个新同事读到这里,都要先弄懂接口、工厂、实现三层关系,才发现背后只是一个写 CSV 的函数。
推离的步骤用的都是本书的手法:先用内联函数(第 4 章)把工厂的 create 内联到调用处,调用方直接 CsvExporter().export(rows);再用以函数取代命令(第 49 章),把只有一个方法的 CsvExporter 变成函数 export_csv(rows);最后接口没人实现了,用移除死代码(第 28 章)删掉。调用方从三层间接变成一个函数调用,测试一行不用改。哪天真的来了第二种格式,再用以多态取代条件表达式把结构推回去,那时你知道两种格式的真实差别,抽出来的接口会比三年前凭想象设计的更合适。
从信号到模式
Section titled “从信号到模式”反过来,当代码出现下面这些信号时,可以考虑向对应的模式重构。左边是你在代码里看到的现象,中间是该用的手法,右边是做到底时的模式。
| 你看到的信号 | 先用的手法 | 可能得到的模式 |
|---|---|---|
| 同一个类型分支在多处重复,加一种类型要改好几处 | 以多态取代条件表达式 38 | 策略、状态 |
| 一个对象的行为随它的状态变化,状态还会在运行中切换 | 以子类取代类型码 52、以委托取代子类 56 | 状态 |
到处检查 None 或“未知”,每处的处理方式都一样 |
引入特例 39 | 空对象 |
| 一个长函数有很多局部变量,还需要撤销、排队或记录 | 以命令取代函数 49 | 命令 |
| 构造对象时要按条件选择不同的类 | 以工厂函数取代构造函数 48 | 工厂方法 |
| 几个子类的方法结构相同,只有中间几步不同 | 函数上移 51、提炼超类 54 | 模板方法 |
| 继承了父类却只用了一小部分,或需要在运行时换掉一部分行为 | 以委托取代超类、以委托取代子类 56 | 策略、桥接 |
| 调用方要和一个子系统里的很多类打交道 | 隐藏委托关系 18 | 外观 |
表里的每一行都以“信号”开头,而不是以“模式”开头。这正是本章想说的:先看到问题,再选择手法,模式是手法做到底时自然出现的结构。
不要为了模式而重构
Section titled “不要为了模式而重构”学完设计模式之后,最容易犯的错误是带着模式去找代码:看到一个 if 就想换成策略,看到一个构造函数就想换成工厂,看到两个类就想抽一个超类。结果是代码变复杂了,却没有解决任何实际问题。
判断该不该向模式重构,标准和本书所有手法一样:有没有一个具体的、正在发生的痛点。条件判断是否在多处重复?加一种新类型是否要改好几个地方?测试是否因为无法替换某个部分而难写?如果答案是肯定的,模式也许是答案;如果答案是“以后可能会”,那就等到以后。
语言特性让一些模式变轻
Section titled “语言特性让一些模式变轻”很多经典模式是为 Java、C++ 这类语言设计的,在 Python 和 TypeScript 里,一些模式可以写得更轻,有时轻到看不出模式的样子。一等函数可以直接代替只有一个方法的策略类和命令类,一个函数字典就能代替一组策略子类;生成器就是迭代器;模块本身就是单例;TS 的联合类型加 switch 穷尽检查,有时比状态模式的类层次更简单。
第 57 章的计算器如果写成函数表,也完全可以:
AMOUNT_RULES = { "coding": lambda hours: 400 + 50 * max(hours - 8, 0), "language": lambda hours: 300 + 30 * max(hours - 10, 0) + 5 * hours, "hobby": lambda hours: 40 * hours,}什么时候用类、什么时候用函数?规则只有一个计算时,函数更轻;规则有多个相关的计算(金额、积分、显示名),而且有共同的部分可以放在父类里时,类更合适。这些语言层面的取舍,《程序设计语言特性》一书会系统地讲,《设计模式》书第 29 章“现代语言里的模式”也有归纳。
模式名的用处在交流
Section titled “模式名的用处在交流”模式最大的价值也许不在结构本身,而在名字。代码评审时说“这里的类型分支可以换成策略”,一句话就把一整套结构传达给了同事,这和第 2 章给坏味道起名字是同样的道理。坏味道的名字描述问题,手法的名字描述动作,模式的名字描述目的地,三套词汇合在一起,团队讨论代码就有了共同的语言。
但别把模式名硬塞进类名。CourseCalculatorStrategy、CustomerFactoryImpl 这样的名字说明的是实现方式,而不是业务含义。第 57 章的计算器叫 CodingCalculator 就够了,读的人从结构上自然看得出它是一个策略;需要说明设计意图时,写在注释或文档里。
- 很多重构手法做到底就是一个设计模式:多态取代条件得到策略或状态,引入特例得到空对象,以命令取代函数得到命令。
- 模式应该是重构的结果,而不是起点;只在代码真的需要时,用小步重构把它推向模式。
- 重构也能把代码推离模式:不再需要的模式,用内联、折叠、以函数取代命令把它简化掉。
- 不要带着模式找代码;判断标准永远是一个具体的、正在发生的痛点。
- 在 Python 和 TS 里,一等函数、生成器、模块、联合类型让一些模式变得更轻。