马丁·福勒的《重构》是软件开发领域的经典著作。

这本书第一版出版于1999年,第二版(JavaScript版)出版于2018年。二十多年过去了,这本书里的思想依然不过时,甚至越来越重要。

我第一次读这本书,是刚工作不久。那时候觉得,重构不就是改代码吗,有什么好说的。后来工作几年,见过了太多"屎山"代码,也踩过了太多改代码引出的bug,再重读这本书,才有了深刻的体会。

读完这本书,我最大的收获不是记住了多少种重构手法,而是改变了对代码的态度和工作方式。本文分享我读《重构》的最大收获。

一、什么是重构

先说说什么是重构。

很多人以为重构就是"改代码""优化代码",但马丁·福勒给了一个精确的定义:

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

这个定义里有几个关键点:

1. 不改变可观察行为

重构不是修bug,不是加功能,不是改需求。重构前后,软件的外部行为是一样的,用户感觉不到任何变化。

这一点很重要。很多人改代码的时候,顺便改了行为,结果出了问题,就说是重构的锅。其实那不是重构,那是改需求。

2. 提高可理解性,降低修改成本

重构的目的,是让代码更容易理解,更容易修改。不是为了重构而重构,不是为了用设计模式而用设计模式。

如果重构之后,代码更难理解了,那就是失败的重构。

3. 一系列重构手法

重构不是随便改,而是有一套成熟的手法。每种手法都有明确的步骤和动机,可以安全地执行。

二、为什么要重构

为什么要重构?这是我读这本书最大的收获之一。

以前我觉得,代码能跑就行,为什么要花时间重构?读完书我才明白,重构不是浪费时间,而是节省时间。

1. 重构改进软件设计

如果不重构,代码会随着时间的推移,慢慢腐化。需求变更、bug修复、新人加入,都会让代码变得越来越乱。

重构可以让代码保持良好的设计,避免技术债越积越多。

2. 重构使软件更容易理解

好的代码是写给人看的,不是写给机器看的。你写的代码,几个月后你自己都看不懂,更别说别人了。

重构可以让代码更清晰、更易读,降低理解成本。

3. 重构帮助找到bug

重构的时候,你需要仔细理解代码的逻辑。在这个过程中,你经常会发现隐藏的bug。

而且,重构后的代码更清晰,bug也更容易被发现。

4. 重构提高编程速度

这是最反直觉的一点。很多人觉得重构浪费时间,会拖慢进度。但实际上,重构能提高编程速度。

因为代码质量好,理解快,改bug快,加功能快。短期看,重构花了时间;长期看,重构省了更多时间。

马丁·福勒说:"重构不会让软件跑得更快,但会让软件开发跑得更快。"

三、什么时候重构

什么时候重构?这也是我以前很困惑的问题。

马丁·福勒的回答是:随时随地重构

不是专门留一段时间重构,而是在日常工作中,随时重构。

1. 三次法则

这是重构的一个重要原则:

  • 第一次做某件事,只管做
  • 第二次做类似的事,尽管重复,还是可以勉强做
  • 第三次做类似的事,就该重构了

简单说就是:事不过三,重复三次就该重构。

2. 添加功能时重构

加新功能的时候,如果发现现有代码结构不好,不容易加新功能,就先重构,再加功能。

先把地基打好,再盖房子。

3. 修bug时重构

修bug的时候,如果发现代码逻辑混乱,不容易定位bug,就先重构,再修bug。

代码清晰了,bug自然就找到了。

4. 代码审查时重构

代码审查的时候,发现不好的代码,就提出来重构。或者自己审查自己的代码,发现问题就改。

5. 什么时候不该重构

当然,不是所有时候都该重构。

  • 代码太烂,重写比重构更划算的时候,不如重写
  • 项目马上要上线,时间很紧的时候,不要重构
  • 没有测试覆盖的时候,重构风险大,先补测试

四、重构的原则

重构有几个重要原则。

1. 小步重构

重构要小步进行,每一步都很小,每一步都能运行,每一步都能测试。

不要一次改一大堆代码,那样出了问题很难定位。小步重构,出了问题容易回滚。

2. 测试是安全网

重构的前提是有测试。没有测试,你不知道改完之后行为有没有变。

所以,重构之前,先确保有足够的测试覆盖。如果没有,先补测试。

测试是重构的安全网,有了测试,你才敢放心改代码。

3. 重构和性能优化分开

重构的时候不要同时做性能优化。重构关注的是代码结构,性能优化关注的是运行效率。

如果同时做,出了问题不知道是重构的问题还是性能优化的问题。先重构,让代码清晰,再做性能优化。

4. 不要为了重构而重构

重构是为了让代码更好,不是为了用某种设计模式或某种架构。

如果代码已经很好了,就不要重构。不要为了重构而重构,那是浪费时间。

五、常用的重构手法

《重构》这本书里介绍了几十种重构手法。我说说我最常用的几个。

1. 提取函数(Extract Function)

这是最常用的重构手法。如果一个函数太长,或者里面有一段逻辑可以独立出来,就把它提取成一个新函数。

// 重构前
function printOrder(order) {
  console.log('订单号:' + order.id)
  console.log('客户:' + order.customer)
  console.log('商品:')
  for (let item of order.items) {
    console.log('  ' + item.name + ' x' + item.quantity + ' = ' + item.price * item.quantity)
  }
  console.log('总计:' + order.total)
}

// 重构后
function printOrder(order) {
  printOrderHeader(order)
  printOrderItems(order)
  printOrderTotal(order)
}

function printOrderHeader(order) {
  console.log('订单号:' + order.id)
  console.log('客户:' + order.customer)
}

function printOrderItems(order) {
  console.log('商品:')
  for (let item of order.items) {
    console.log('  ' + item.name + ' x' + item.quantity + ' = ' + item.price * item.quantity)
  }
}

function printOrderTotal(order) {
  console.log('总计:' + order.total)
}

提取函数的好处是:每个函数职责单一,更容易理解,也更容易复用。

2. 内联函数(Inline Function)

和提取函数相反。如果一个函数的内容很简单,或者函数名和内容一样清楚,就把函数内联到调用处。

// 重构前
function isAdult(age) {
  return age >= 18
}

if (isAdult(user.age)) {
  // ...
}

// 重构后
if (user.age >= 18) {
  // ...
}

不是所有函数都要提取。太简单的函数,内联反而更清楚。

3. 提取变量(Extract Variable)

如果表达式很复杂,不容易理解,就把它提取成一个有意义的变量。

// 重构前
if (order.total > 1000 && order.customer.level === 'VIP' && order.customer.credit > 5000) {
  // ...
}

// 重构后
const isBigOrder = order.total > 1000
const isVIPCustomer = order.customer.level === 'VIP'
const hasGoodCredit = order.customer.credit > 5000

if (isBigOrder && isVIPCustomer && hasGoodCredit) {
  // ...
}

提取变量让条件更清晰,也方便调试。

4. 改名(Rename)

给变量、函数、类改一个更有意义的名字。

这是最简单但也最重要的重构手法。好的名字能让代码自解释,不需要注释。

// 重构前
function process(a, b) {
  return a * b + a * 0.1
}

// 重构后
function calculateTotalPrice(price, quantity) {
  const tax = price * 0.1
  return price * quantity + tax
}

好的名字是好代码的基础。

5. 以查询取代临时变量(Replace Temp with Query)

如果一个临时变量只被赋值一次,而且可以用一个函数计算,就用函数查询代替临时变量。

// 重构前
function getPrice(order) {
  const basePrice = order.quantity * order.itemPrice
  let discount
  if (basePrice > 1000) {
    discount = basePrice * 0.05
  } else {
    discount = basePrice * 0.02
  }
  return basePrice - discount
}

// 重构后
function getPrice(order) {
  return basePrice(order) - discount(order)
}

function basePrice(order) {
  return order.quantity * order.itemPrice
}

function discount(order) {
  if (basePrice(order) > 1000) {
    return basePrice(order) * 0.05
  }
  return basePrice(order) * 0.02
}

这样做的好处是,其他函数也可以复用basePrice和discount,而且逻辑更清晰。

6. 分解条件表达式(Decompose Conditional)

把复杂的条件表达式,分解成有意义的函数。

// 重构前
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
  charge = quantity * winterRate + winterServiceCharge
} else {
  charge = quantity * summerRate
}

// 重构后
if (isWinter(date)) {
  charge = winterCharge(quantity)
} else {
  charge = summerCharge(quantity)
}

条件逻辑清晰了,函数名就是注释。

7. 以多态取代条件表达式(Replace Conditional with Polymorphism)

如果有一个根据类型做不同处理的条件表达式,可以用多态代替。

// 重构前
function getSpeed(animal) {
  if (animal.type === 'bird') {
    return animal.wingSpan * 3
  } else if (animal.type === 'fish') {
    return animal.finLength * 5
  } else if (animal.type === 'cheetah') {
    return animal.legLength * 10
  }
}

// 重构后
class Bird {
  getSpeed() { return this.wingSpan * 3 }
}
class Fish {
  getSpeed() { return this.finLength * 5 }
}
class Cheetah {
  getSpeed() { return this.legLength * 10 }
}

// 调用
animal.getSpeed()

这样,加新类型的时候,不用改原来的代码,符合开闭原则。

六、代码的坏味道

《重构》里有一章叫"代码的坏味道",列举了哪些代码说明需要重构。我觉得这一章特别实用。

说说我最常见的几种坏味道。

1. 神秘命名(Mysterious Name)

变量名、函数名、类名起得不好,让人看不懂。比如a、b、c、data、info、process、handle。

好的名字是最好的文档。改名是最简单也最有效的重构。

2. 重复代码(Duplicated Code)

同样的代码出现在多个地方。这是最常见的坏味道,也是最该重构的。

重复代码的问题是:改一个地方,其他地方也要改,容易漏。而且重复代码越多,维护成本越高。

3. 过长函数(Long Function)

函数太长,超过一屏,逻辑复杂,很难理解。

一个函数最好不要超过20-30行。太长了就提取函数。

4. 过大的类(Large Class)

一个类做了太多事,有太多方法和属性。

一个类应该有单一职责。太大了就拆分成多个类。

5. 过长参数列表(Long Parameter List)

函数的参数太多,超过3-4个,调用的时候很容易传错。

可以把参数封装成对象,或者用对象的属性代替参数。

6. 发散式变化(Divergent Change)

一个类因为不同的原因,在不同的方向上变化。比如一个类,有时候因为数据库改,有时候因为UI改,有时候因为业务逻辑改。

这说明这个类职责太多,应该拆分。

7. 霰弹式修改(Shotgun Surgery)

一个小变化,需要改很多个类。比如加一个字段,要改五六个文件。

这说明数据和行为分散在太多地方,应该把相关的代码聚合到一起。

8. 依恋情节(Feature Envy)

一个函数对另一个类的兴趣,超过了对自己类的兴趣。它频繁调用另一个类的方法,访问另一个类的数据。

这说明这个函数应该移到另一个类里。

9. 数据泥团(Data Clumps)

几个数据总是一起出现,比如用户ID、用户名、用户头像,总是一起传来传去。

这说明应该把这些数据封装成一个对象。

10. 基本类型偏执(Primitive Obsession)

该用对象的时候,用基本类型。比如用字符串表示电话号码、用整数表示金额、用布尔值表示状态。

应该用专门的类来表示这些概念,比如PhoneNumber、Money、Status。

七、这本书对我的改变

读《重构》,对我的改变很大。

1. 从"能跑就行"到"代码质量"

以前我写代码,只要能跑就行。现在我会关注代码的可读性、可维护性、可扩展性。

我知道了,代码是写给人看的,不是写给机器看的。好的代码,能让后来的人(包括几个月后的自己)少加班。

2. 从"大改"到"小步重构"

以前我改代码,喜欢一次改一大堆。结果经常出问题,回滚都麻烦。

现在我学会了小步重构,每一步都很小,每一步都测试。虽然看起来慢,但实际上更安全、更快。

3. 从"害怕改代码"到"敢于改代码"

以前看到烂代码,不敢改,怕改出问题。现在我知道,只要有测试,用正确的重构手法,就可以安全地改。

而且,烂代码越不改,越难改。早改早轻松。

4. 从"重构是额外工作"到"重构是日常工作"

以前我觉得重构是额外的工作,要专门留时间做。现在我知道,重构是日常工作的一部分,随时随地都在做。

加功能的时候重构,修bug的时候重构,代码审查的时候重构。重构不是额外的负担,而是提高效率的手段。

5. 从"追求设计模式"到"简单就是美"

以前我喜欢用设计模式,觉得用了设计模式就是高级。现在我知道,设计模式是为了解决问题,不是为了用而用。

最简单的代码,就是最好的代码。如果一个简单的函数就能解决问题,就不要搞什么设计模式。

八、我的重构习惯

现在我养成了一些重构习惯。

1. 写完代码就重构

写完一个功能,先跑通测试,然后立刻重构。这时候对代码最熟悉,重构效率最高。

2. 看到烂代码就顺手改

看到不好的代码,顺手就改了。不要等"以后再改",以后永远不会改。

当然,要控制范围,不要一次改太多。

3. 提交前做代码审查

提交代码前,自己先审查一遍。看看有没有可以改进的地方,有没有明显的问题。

4. 给变量和函数起好名字

写代码的时候,花时间想名字。好的名字能省很多注释,也能让代码更清晰。

5. 保持函数短小

写函数的时候,尽量短小。超过20行,就想想能不能提取。

6. 写测试

写测试不是为了应付,而是为了让自己敢重构。有了测试,改代码才有底气。

九、适合谁读

这本书适合这些人读:

  1. 软件开发人员,尤其是写了几年代码、遇到瓶颈的人
  2. 想提高代码质量的人
  3. 想学习重构手法的人
  4. 团队的技术负责人,想提升团队代码质量
  5. 想理解"什么是好代码"的人

不适合:

  1. 完全零基础的人(需要有一定的编程经验)
  2. 只想学语法、不想学思想的人
  3. 觉得代码能跑就行的人

十、阅读建议

如果你想读《重构》,给你几个建议。

1. 选对版本

这本书有两个版本:

  • 第一版(1999):Java语言,经典,但有些例子过时了
  • 第二版(2018):JavaScript语言,更新,更现代

建议读第二版,用JavaScript,更通用,例子也更新。

2. 先读前几章

前几章(什么是重构、重构的原则、代码的坏味道)是核心,一定要仔细读。后面的重构手法,可以当工具书,需要的时候再查。

3. 边读边练

读的时候,找自己项目里的代码,试着用书上的手法重构。光读不练,学不会。

4. 不要一次读完

这本书不适合一次读完。读一部分,实践一部分,再读下一部分。

5. 反复读

这本书值得反复读。工作几年后再读,会有新的体会。

十一、写在最后

《重构》是一本改变了我编程方式的书。

它让我明白,好的代码不是一次写出来的,而是不断改出来的。重构不是额外的工作,而是日常工作的一部分。

二十多年过去了,这本书的思想依然不过时。因为不管技术怎么变,代码质量的重要性不会变。

2022年了,我们用的语言、框架、工具,和二十年前完全不一样了。但"什么是好代码""怎么写出好代码"这些问题,依然需要我们思考。

最后,用马丁·福勒的一句话结尾:"任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员。"

愿我们都能写出人类容易理解的代码。