跳转到内容

第 60 章 从重构到设计模式

学设计模式时,书里通常先给出一个模式的完整结构,再教你在什么场合用它。实际写代码时,事情往往反过来:你不会在第一天就知道这里需要一个策略模式,而是写着写着,代码里出现了重复的类型分支,你用以多态取代条件表达式把它整理掉,回头一看,得到的结构正是策略模式。这一章讲重构和设计模式的这种关系:很多手法做到底就是一个模式,重构是通往模式的路,模式是重构的一个可能的目的地。

下表列出本书里结果或过程与某个设计模式对应的手法。“相关的设计模式”一节在各章已经讲过细节,这里汇总起来看全貌。

手法 章 做到底得到的模式 《设计模式》书
以多态取代条件表达式 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 章

本书手法通往设计模式的路线:左边是手法,右边是做到底时得到的模式

第 57 章的账单就是一个现成的例子。最初的代码里,计费规则是一串按课程类型的 if。经过以多态取代条件表达式,它变成了一个计算器父类和三个子类,statement_data 不再关心课程类型,只管向计算器要数字。

按《设计模式》书的说法,这就是策略模式:CourseCalculator 是策略的接口,CodingCalculator、LanguageCalculator、HobbyCalculator 是三个具体策略,statement_data 是使用策略的上下文,create_calculator 负责根据课程类型挑出合适的策略。但重构的过程中,我们从来没有“决定使用策略模式”,只是一步一步地消除了重复的条件判断。模式是重构的结果,而不是起点。

这个顺序有一个重要的好处:你只在需要的时候才引入模式。如果账单永远只有三种课程,规则也从来不变,那串 if 就挺好,不需要计算器类。只有当新需求让条件判断开始重复、开始难改时,重构才把它推向模式。

Joshua Kerievsky 的《重构与模式》专门讲这个视角。他观察到两种常见的失败:一种是过度设计,在还不需要的时候就预先套上模式,代码里到处是只有一个实现的接口和只有一个产品的工厂;另一种是设计不足,从不重构,代码里的重复和条件判断越积越多,直到没人敢改。他的建议是走中间的路:不预先设计模式,而是在代码“闻到味道”时,用一系列小的重构把它推向合适的模式;有时也推离模式,当一个模式已经不再必要时,把它重构掉,换回更简单的结构。

本书很多章节都包含这种“推离”的方向:内联函数、内联类、移除中间人、折叠继承体系、移除子类、以函数取代命令。它们和“推向模式”的手法同样重要。一个只有一个具体策略的策略模式,用以函数取代命令和内联类把它简化回一个函数,也是一次好的重构。

一个推离模式的例子。某个项目早年预见到“以后会有多种导出格式”,于是写了一个 Exporter 接口、一个 CsvExporter 实现、一个 ExporterFactory 工厂,调用方写 ExporterFactory.create("csv").export(rows)。三年过去,导出格式始终只有 CSV 一种。每个新同事读到这里,都要先弄懂接口、工厂、实现三层关系,才发现背后只是一个写 CSV 的函数。

推离的步骤用的都是本书的手法:先用内联函数(第 4 章)把工厂的 create 内联到调用处,调用方直接 CsvExporter().export(rows);再用以函数取代命令(第 49 章),把只有一个方法的 CsvExporter 变成函数 export_csv(rows);最后接口没人实现了,用移除死代码(第 28 章)删掉。调用方从三层间接变成一个函数调用,测试一行不用改。哪天真的来了第二种格式,再用以多态取代条件表达式把结构推回去,那时你知道两种格式的真实差别,抽出来的接口会比三年前凭想象设计的更合适。

反过来,当代码出现下面这些信号时,可以考虑向对应的模式重构。左边是你在代码里看到的现象,中间是该用的手法,右边是做到底时的模式。

你看到的信号 先用的手法 可能得到的模式
同一个类型分支在多处重复,加一种类型要改好几处 以多态取代条件表达式 38 策略、状态
一个对象的行为随它的状态变化,状态还会在运行中切换 以子类取代类型码 52、以委托取代子类 56 状态
到处检查 None 或“未知”,每处的处理方式都一样 引入特例 39 空对象
一个长函数有很多局部变量,还需要撤销、排队或记录 以命令取代函数 49 命令
构造对象时要按条件选择不同的类 以工厂函数取代构造函数 48 工厂方法
几个子类的方法结构相同,只有中间几步不同 函数上移 51、提炼超类 54 模板方法
继承了父类却只用了一小部分,或需要在运行时换掉一部分行为 以委托取代超类、以委托取代子类 56 策略、桥接
调用方要和一个子系统里的很多类打交道 隐藏委托关系 18 外观

表里的每一行都以“信号”开头,而不是以“模式”开头。这正是本章想说的:先看到问题,再选择手法,模式是手法做到底时自然出现的结构。

学完设计模式之后,最容易犯的错误是带着模式去找代码:看到一个 if 就想换成策略,看到一个构造函数就想换成工厂,看到两个类就想抽一个超类。结果是代码变复杂了,却没有解决任何实际问题。

判断该不该向模式重构,标准和本书所有手法一样:有没有一个具体的、正在发生的痛点。条件判断是否在多处重复?加一种新类型是否要改好几个地方?测试是否因为无法替换某个部分而难写?如果答案是肯定的,模式也许是答案;如果答案是“以后可能会”,那就等到以后。

很多经典模式是为 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 章“现代语言里的模式”也有归纳。

模式最大的价值也许不在结构本身,而在名字。代码评审时说“这里的类型分支可以换成策略”,一句话就把一整套结构传达给了同事,这和第 2 章给坏味道起名字是同样的道理。坏味道的名字描述问题,手法的名字描述动作,模式的名字描述目的地,三套词汇合在一起,团队讨论代码就有了共同的语言。

但别把模式名硬塞进类名。CourseCalculatorStrategy、CustomerFactoryImpl 这样的名字说明的是实现方式,而不是业务含义。第 57 章的计算器叫 CodingCalculator 就够了,读的人从结构上自然看得出它是一个策略;需要说明设计意图时,写在注释或文档里。

  • 很多重构手法做到底就是一个设计模式:多态取代条件得到策略或状态,引入特例得到空对象,以命令取代函数得到命令。
  • 模式应该是重构的结果,而不是起点;只在代码真的需要时,用小步重构把它推向模式。
  • 重构也能把代码推离模式:不再需要的模式,用内联、折叠、以函数取代命令把它简化掉。
  • 不要带着模式找代码;判断标准永远是一个具体的、正在发生的痛点。
  • 在 Python 和 TS 里,一等函数、生成器、模块、联合类型让一些模式变得更轻。