跳到主要内容

坏味道:什么时候该重构的气味罗盘

1. 这一章讲什么

会做手术不等于知道该给谁做手术。这一章回答「何时重构、闻到什么该伸手」:由 Kent Beck 提出的一套「坏味道」清单。本章原书由 Beck 与 Fowler 合写(自嘲:「插科打诨的部分是我写的,其余都是他写的」)1,24 个味道各自指向一批重构手法。我们不复述成目录,而是按「你站在代码面前,先看什么、再看什么」的决策顺序把它们重新组织成七组。

先给罗盘的使用说明。味道不是精确判据——「我们并不试图给你一个何时必须重构的精确衡量标准」2;它是见识广博者直觉的支架,让你在还没有直觉时有地方对号入座。起名「味道」来自 Beck 家奶奶的名言:「如果尿布臭了,就换掉它」3

2. 顶层全景:七组味道

看见一段代码 ─┬─ 读得懂吗? ──── 神秘命名 ────────────── 组1 名字与重复
├─ 装得下吗? ──── 过长函数/过长参数列表 ── 组2 函数装不下
├─ 数据危险吗? ── 全局/可变/裸类型/泥团 ── 组3 数据不设防
├─ 改动怎么扩散? 发散式变化 vs 霰弹式修改 ─ 组4 变化的方向
├─ 东西在对的地方吗? 依恋/内幕/链/中间人 ── 组5 放错了位置
├─ 是为未来造的吗? 投机通用性/冗赘/遗赠 ── 组6 为不存在的未来
└─ 注释在解释什么? switch/循环/注释本身 ── 组7 三个老生常谈

图说:每组回答一个判断问题;一个味道常同时属于几组,罗盘给方向,不给判决。

3. 组 1:名字与重复——一切检查的起点

神秘命名。整洁代码最重要的一环是好名字;而「命名是编程中最难的两件事之一」,所以改名是最常用的重构4。它还有诊断价值:「如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题」——起不出名的纠结常逼你把代码精简5

重复代码。判据朴素:「如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一」6。重复的代价是双份阅读(留意细微差异)和双份修改(漏一处就出错)。对治路径按重复的形态走:完全相同→提炼函数;相似而不相同→先移动语句把相似部分聚拢再提炼;散在不同子类→函数上移(第 11 章讲)。

4. 组 2:函数装不下

过长函数。经验断言:「活得最长、最好的程序,其中的函数都比较短」7。但「长」不是以行数论,作者给的真正判据是语义(用词实际承载的意思)上的距离:函数名声称的「做什么」与函数体实做的「如何做」之间差多远。

按用途命名的小函数让你只读名字就懂;提炼会多出一次函数调用(程序转到另一段代码执行的动作),原书甚至说「哪怕替换后的函数调用动作比函数自身还长」也值得。

这条判据有一个巧妙的探测器:注释。需要注释来解释一块代码时,就把这块代码提炼成以注释为名的函数——「如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数」8

提炼受阻时(局部变量和参数太多),手法链在第 05 章展开;参数问题本身的两个味道是过长参数列表(能查询得到就别传、总一起出现就打包成对象)。循环和 switch 也是提炼信号:难命名的循环说明它干了不止一件事,先拆分(第 07 章讲)。

5. 组 3:数据不设防

全局数据:从代码库任何角落都能改它,而且没有机制告诉你谁改的——「全局数据仍然是最刺鼻的坏味道之一」,诡异 bug 的根源远在千里之外9。第一道防线是封装变量:至少你能看见谁在碰它。可修改的全局数据比只读的更糟;作者引帕拉塞尔斯「良药与毒药的区别在于剂量」——少量或许无妨,但越多,处理难度越呈指数(越翻越快、不是匀速变糟)上升10

可变数据:修改常导致出乎意料的结果,另一处期待着完全不同的数据11。函数式编程(数据永不改变、要更新就返回新副本的编程风格)整座建立在这个判断上;就算不用它,也可以用封装约束更新、把更新的代码和没有副作用的代码分开。

基本类型偏执:钱被当作普通数字来算、英寸和毫米直接相加、电话号码只是一串字符——后一种常见到有了专门的外号(stringly typed,「类字符串类型」)12。对治:给这些值建自己的类型(第 06 章讲)。

数据泥团:三四项数据总结伴出现在多个类和函数签名里。判据很好用:「删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?」失去意义,就该为它们建一个对象13

纯数据类:只有字段(对象里存放数据的格子)和读写函数、没有行为的「不会说话的数据容器」——通常说明行为放错了地方,该把处理它的行为搬进来。但有一个正名的例外:拆分阶段产出的中转数据结构就是纯数据,而且不可变,不招坏味道(第 01 章走查过的 statementData 正是)14

6. 组 4:变化的方向——两个互为镜像的味道

这一组不看你代码长什么样,看它怎么变

发散式变化:一个模块总因不同的原因朝不同方向变——「如果新加入一个数据库,我必须修改这3个函数;如果新出现一种金融工具,我必须修改这4个函数」15。数据库逻辑和金融逻辑是两个上下文(两块各自独立的话题领域),该分开住:以后每次修改只需理解一个上下文。两个方向有先后次序就拆阶段,来回调用多就建模块搬函数。

霰弹式修改:恰恰相反——每遇到某种变化,要在许多不同的类里做许多小修改,「很容易错过某个重要的修改」16。对治方向也相反:用搬移把散落各处的修改点收拢进同一个模块。临时手段是先内联把分散的逻辑拽回一处,再重新提炼成合理的小块。

同一个病(一个变化要碰 N 处),发散是「N 处挤在一个类里」,霰弹是「N 处散在全代码库」。**判断块(我们的,不是书里的):**这两味共用同一剂药的两种煎法——先聚拢、再按上下文切开。如果错,会错在: 如果某段代码的变化方向既不发散也不霰弹(单一原因、单一位置),这两组手法都不适用,别硬套。

7. 组 5:东西放错了位置

依恋情结:一个函数对别的模块的数据兴趣远大于自己模块——典型形象是「为了计算某个值,从另一个对象那儿调用几乎半打的取值函数」17。判据写得很清楚:数一数哪个模块拥有此函数用到的数据最多,就把函数搬过去。有规则的破例:策略、访问者这类模式故意把行为和数据分开以对抗发散式变化,代价是多一层间接18

过大的类:一个类做太多事→字段太多→重复代码接踵而至。线索常在使用者身上:谁只用了这个类功能的一个子集,那个子集就能拆成一个独立的类。

消息链与中间人:客户端(使用这个对象的代码)顺着 a.b().c().d() 一路取下去,就和导航结构焊死了——该把「最终拿它干什么」提炼成函数搬进链里。反向的问题是委托过度:一个类一半函数都只是转发,它就成了中间人,该让客户端直连。注意这两味互为反向,「合适的隐藏程度」没有定数(第 06 章展开)。

内幕交易:两个模块总在「咖啡机旁边窃窃私语」——私下交换大量数据。把交换摆到明面上:要么搬移减少交流,要么新建一个双方共用的模块。

异曲同工的类:两个类接口不一致导致不能互相替换——把签名改一致,再反复搬移行为直到协议(双方认可的一套函数约定)相同。

8. 组 6:为不存在的未来造东西

夸夸其谈通用性(命名者 Brian Foote):有人说「总有一天需要」于是造出各种钩子、特殊情况、将来才用的参数。判据:「如果所有装置都会被用到,就值得那么做;如果用不到,就不值得」19。一个阴险的变体:唯一用户是测试用例的函数或类——先删测试,再删代码。

冗赘的元素:名字和实现一模一样的函数、只装了一个函数的类——多半创建时对它有期望(预期它会长大),「那一天从未到来」,内联掉。

临时字段:只为某种特殊情况存在的字段,让人误以为对象任何时候都需要它。给它提炼一个自己的类。

被拒绝的遗赠:子类继承了超类的全部家当却只用几样。这个味道有个罕见的宽容处理:作者明说「十有八九这种坏味道很淡,不值得理睬」——复用实现而不支持接口才是浓烈版本,那才需要彻底划清界限(换委托,第 11 章讲)20

9. 组 7:switch、循环与注释

重复的 switch:第 1 版这味叫「switch 语句」,当年「程序员们太过于忽视多态的价值,我们希望矫枉过正」;第 2 版改名,因为如今多态已被广泛使用、switch 本身不再有害,真正的问题是同一个 switch 在多处重复——每加一个分支就得找齐所有副本逐一更新21。对治是多态(第 09 章讲),但只对「重复的」下手。

循环:作者称它「像喇叭裤和植绒壁纸那样」过时了——函数式语言有了集合管道(map/filter)之后,循环多半该退休(第 07 章讲)22

注释:清单里唯一「不是坏味道」的味道——「从嗅觉上说,注释……事实上它们还是一种香味呢」23。收录它是因为注释常被当除臭剂用:长长的注释往往说明代码本身很糟。规则:感觉要写注释时,先试着重构,让注释变得多余;真要留,留「为什么」而不是「做什么」24

10. 作者的判断与证据

判断性质
味道 > 量度规矩:没有任何自动检查比得上见识广博者的直觉Beck 与 Fowler 的经验立场,全书不给阈值
switch 不该一刀切消灭,该消灭的是重复与第 1 版的显式立场变化,给出理由(矫枉过正已完成使命)
被拒绝的遗赠大多不值得理睬与传统忠告相反的宽容,给出边界(拒绝接口才严重)
注释是香味,被当除臭剂才是问题反主流立场,与过长函数的「注释=提炼信号」同一逻辑

判断(我们的,不是书里的): 这份清单的价值排序里,组 4(变化的方向)最特别——它是唯一需要时间维度(要看过它怎么变才能判断)才能闻到的味道,新代码几乎闻不出来,所以它也是「重构要在代码库上生活一段时间才有效」的最好证据。如果错,会错在: 如果一个代码库的修改模式被流程锁死(比如所有需求都恰好只改一个模块),发散/霰弹的信号会系统性失真。

11. 边界与局限

  • 味道清单是启发式(靠经验找答案、不保证最优)的方法,作者拒绝给出任何数值阈值;拿它当 lint 规则用违背本意。
  • 清单基于面向对象时代的代码形态;第 1 版到第 2 版的措辞变化(switch、循环)说明它自身也随时代修订,不是永恒真理。
  • 「闻到味道→用哪个手法」的对应关系分散在各章;书末速查表是导航工具,本拆解不逐条复刻,按决策主线重组即本文结构。

12. 可带走的

  1. 先问「读得懂吗」,再问「装得下吗」,再问「改起来会怎样」——顺序别反。
  2. 注释是信号:想写注释时,先试提炼函数。
  3. 重复出现第二次忍住,第三次动手。
  4. 数一数函数用了谁的数据最多,函数就该住在哪。
  5. 删掉一项数据,剩下的失去意义,就证明它们是一个对象。
  6. 一个类因多种原因朝多个方向变=发散;一个变化要碰散落多处的代码=霰弹。
  7. 「总有一天用得上」的装置,现在就是负债。
  8. switch 只在「重复」时才是坏味道。
  9. 写「为什么」的注释可以留,写「做什么」的注释换成代码。

13. 原文地图

主题原书章原文位置
味道比喻的由来 / 尿布第3章 开篇text/11-ch03.txt:17(搜「用味道来形容重构的时机」) · text/11-ch03.txt:5(搜「尿布臭了」)
不给量度只给直觉第3章 开篇text/11-ch03.txt:25(搜「精确衡量标准」)
神秘命名第3章 §3.1text/11-ch03.txt:40(搜「最难的两件事之一」) · text/11-ch03.txt:45(搜「如果你想不出一个好名字」)
重复代码第3章 §3.2text/11-ch03.txt:51(搜「一个以上的地点看到相同的代码结构」)
过长函数 / 语义距离第3章 §3.3text/11-ch03.txt:62(搜「活得最长、最好的程序」) · text/11-ch03.txt:78(搜「关键不在于函数的长度」)
注释即信号第3章 §3.3text/11-ch03.txt:91(搜「寻找注释」) · text/11-ch03.txt:92(搜「替换成一个函数」)
过长参数列表第3章 §3.4text/11-ch03.txt:104(搜「过长参数列表」)
全局数据第3章 §3.5text/11-ch03.txt:122(搜「最刺鼻的坏味道」) · text/11-ch03.txt:134(搜「良药与毒药的区别在于剂量」)
可变数据第3章 §3.6text/11-ch03.txt:139(搜「出乎意料的结果和难以发现的bug」)
发散式变化第3章 §3.7text/11-ch03.txt:169(搜「不同的方向上发生变化」)
霰弹式修改第3章 §3.8text/11-ch03.txt:185(搜「许多不同的类」)
依恋情结 / 数据最多原则第3章 §3.9text/11-ch03.txt:203(搜「半打」) · text/11-ch03.txt:208(搜「使用的数据最多」)
数据泥团判据第3章 §3.10text/11-ch03.txt:226(搜「删掉众多数据中的一项」)
基本类型偏执 / stringly typed第3章 §3.11text/11-ch03.txt:240(搜「最佳培养皿」)
重复的 switch 的改名史第3章 §3.12text/11-ch03.txt:257(搜「20世纪90年代末期」) · text/11-ch03.txt:264(搜「逐一更新」)
循环过时第3章 §3.13text/11-ch03.txt:269(搜「喇叭裤」)
夸夸其谈通用性第3章 §3.15text/11-ch03.txt:287(搜「把它搬开吧」)
纯数据类例外第3章 §3.22text/11-ch03.txt:382(搜「中转数据结构」)
被拒绝的遗赠的宽容第3章 §3.23text/11-ch03.txt:397(搜「不值得理睬」)
注释与除臭剂第3章 §3.24text/11-ch03.txt:406(搜「除臭剂」) · text/11-ch03.txt:417(搜「变得多余」)

Footnotes

  1. 出处:「代码的坏味道」第 22 段(text/11-ch03.txt:22,搜「插科打诨」)。本章署名「Kent Beck和Martin Fowler」见第 3 段(text/11-ch03.txt:3,搜「Kent Beck和Martin Fowler」)。

  2. 出处:「代码的坏味道」第 25 段(text/11-ch03.txt:25,搜「精确衡量标准」)。

  3. 出处:「代码的坏味道」第 5 段(text/11-ch03.txt:5,搜「尿布臭了」)。Beck 提出味道比喻的场景在第 17 段(text/11-ch03.txt:17,搜「用味道来形容重构的时机」)。

  4. 出处:「代码的坏味道」第 40 段(text/11-ch03.txt:40,搜「最难的两件事之一」)。

  5. 出处:「代码的坏味道」第 45 段(text/11-ch03.txt:45,搜「如果你想不出一个好名字」)。

  6. 出处:「代码的坏味道」第 51 段(text/11-ch03.txt:51,搜「一个以上的地点看到相同的代码结构」)。

  7. 出处:「代码的坏味道」第 62 段(text/11-ch03.txt:62,搜「活得最长、最好的程序」)。

  8. 出处:「代码的坏味道」第 92 段(text/11-ch03.txt:92,搜「替换成一个函数」)。语义距离判据在第 78 段(text/11-ch03.txt:78,搜「关键不在于函数的长度」)。

  9. 出处:「代码的坏味道」第 122 段(text/11-ch03.txt:122,搜「最刺鼻的坏味道」)。

  10. 出处:「代码的坏味道」第 134 段(text/11-ch03.txt:134,搜「良药与毒药的区别在于剂量」)。

  11. 出处:「代码的坏味道」第 139 段(text/11-ch03.txt:139,搜「出乎意料的结果和难以发现的bug」)。

  12. 出处:「代码的坏味道」第 240 段(text/11-ch03.txt:240,搜「最佳培养皿」)。stringly typed 译名见第 242 段(text/11-ch03.txt:242,搜「类字符串类型」)。

  13. 出处:「代码的坏味道」第 226 段(text/11-ch03.txt:226,搜「删掉众多数据中的一项」)。

  14. 出处:「代码的坏味道」第 382 段(text/11-ch03.txt:382,搜「中转数据结构」)。

  15. 出处:「代码的坏味道」第 170 段(text/11-ch03.txt:170,搜「我必须修改这3个函数」)。

  16. 出处:「代码的坏味道」第 187 段(text/11-ch03.txt:187,搜「错过某个重要的修改」)。

  17. 出处:「代码的坏味道」第 203 段(text/11-ch03.txt:203,搜「半打」)。

  18. 出处:「代码的坏味道」第 214 段(text/11-ch03.txt:214,搜「总是一起变化」)。

  19. 出处:「代码的坏味道」第 287 段(text/11-ch03.txt:287,搜「把它搬开吧」)。

  20. 出处:「代码的坏味道」第 397 段(text/11-ch03.txt:397,搜「不值得理睬」);浓烈判据在第 399 段(text/11-ch03.txt:399,搜「不愿意支持超类的接口」)。

  21. 出处:「代码的坏味道」第 258 段(text/11-ch03.txt:258,搜「矫枉过正」);重复的 switch 的问题在第 264 段(text/11-ch03.txt:264,搜「逐一更新」)。

  22. 出处:「代码的坏味道」第 269 段(text/11-ch03.txt:269,搜「喇叭裤」)。

  23. 出处:「代码的坏味道」第 406 段(text/11-ch03.txt:406,搜「一种香味」)。

  24. 出处:「代码的坏味道」第 417 段(text/11-ch03.txt:417,搜「变得多余」)。