Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

读书笔记 -《重构 - 改善既有代码的设计》 #47

Open
qufei1993 opened this issue Nov 21, 2022 · 0 comments
Open

读书笔记 -《重构 - 改善既有代码的设计》 #47

qufei1993 opened this issue Nov 21, 2022 · 0 comments
Assignees

Comments

@qufei1993
Copy link
Owner


重构这本书中,介绍的一些技巧、方法,对改善即有的代码设计有一定的帮助。另一方面,掌握了这些重构方法之后对你的编码能力也会有一个显著的提升

孟子的《尽心章句下》中有这样一句话 :“尽信书,不如无书”,意思是读者要有独立思考精神,读书时应该加以分析,辩证的看待问题。在重构这本书中,介绍的重构方法多达将近上百种,不要一上来就被这么多的介绍给吓的退缩,找到适合于自己的才是最重要的,如果你是一个拥有多年编程经验的开发者,更要带着自己的理解去阅读。

为何重构

代码结构的流失具有累积效应

当人们只为短期目的修改代码时,可能并没有很好理解架构的整体设计,当越难看出代码所表达的设计意图时就越难保护其设计,代码结构也会逐渐流失

例如,产品经理告诉你,现在客户提出了一个新需求,很着急,希望尽快完成并上线。做这块的人,也许是一个并不了解该模块原有功能设计的人,也许是一个老队友,无奈于 “时间紧”、“任务重” 怎么办呢?顾不上先设计在开发了,先完成再说,一旦有第一次,就很有可能会产生第二次,周而复始,会发现代码结构越来越难以维护。

我想这种例子,在你身边也许经历过,当面对一些遗留系统的代码时,可能心里也会想 “这写的都是什么啊!”

编程的对象只是计算机吗

我们编写代码,很大程度上都在与计算机对话,告诉它我们的代码指令以及该做出何种响应,那么我们编程的对象只是计算机吗?

不,这里往往忽略另外一个对象,我们编写的代码除了计算机外,还有其他读者因为在一段时间后,会有其他编程人员来阅读这段代码并作出修改,我们每个人都可能成为这个未来的读者,这个是我们系统得以长久维护的一个重要对象

为什么之前我没有考虑到

在每一次的产品新版本迭代时,可能会遇到以前的结构不能满足,需要做一些调整,有时我们会吐槽为什么之前没考虑到呢?现在却要花时间调整它。

在开始代码之前,我们需要先完成软件的设计与架构,这很重要但也不要 “过度设计”,软件永远不应该被视为 “完成”,每当有新功能时,软件就应该做出相应的改变,最后会发现重构是一个长期的过程。

重构意义

内部质量良好的模块划分,我们只需要了解代码库的一小部分,就可以容易的找出要修改的地方,是可以提高编程速度的

当遇到一段糟糕的代码,你可能需要花费更多的时间去思考如何将新功能加入现有的代码库,一不小心还容易引入 bug,修复起来也困难,这份负担会拖累你的开发进度,并且以后的新功能也会越来越难以加入,最后实在难以维护 “我们就重构它吧”。

重构的意义在于让代码有着一个良好的模块划分,让未来的读者更易于理解、提高之后的开发效率

何时重构

“重构风险太大,可能引入 bug”,如果你有这个担忧,是正常的,重构的目的是为了让代码更易于理解,但也不能破坏现有的运行状态。

大多数情况下,我们想重构,得先有可以自测试的代码,哪怕是一个看似很小的改动,这会让重构更加可靠,这也是为什么在开发中我们会强调单元测试的重要性。因此,一开始时,团队有必要投入一定的精力和时间在一些测试工作上,这对于新功能添加也会多一层安全保证。

关于重构,一部分人认为需要安排一段时间来专门做这件事,这个投入成本是很大的,需要团队达成共识,在大多数情况下你的项目也不会给你计划这么多时间允许你做。有这样一句话:“种一棵树最好的时间是十年前,其次是现在”,同样大多数的重构也可在添加新功能、修复 bug 时去做,重构不一定是推翻重来

甄别坏代码

书中这一部分称为代码的坏味道,这些是我们在编程中需要警惕的一些规则,很容易造成代码结构流失,不易后期维护。

  • 神秘命名:糟糕的命名常常让人不知所云,不得不去看具体的实现,否则很难搞懂代码实际的意图,例如:变量命名 ab 这种随意的命名要避免。当你花了很长时间还是想不到一个好的名字,也许背后隐藏着更深的设计问题。
  • 重复代码:重复的代码散落在各个地方,会增加维护的负担。
  • 过长函数:函数越长、越难理解。拆解函数体并为小函数给一个好的命名,这样阅读代码的人,只需要通过函数的名称,也能够知道里面实现的意图。
  • 过长参数列表。
  • 全局数据
  • 可变数据
  • 发散式变化:是指某个模块经常因为不同的原因在不同的方向上发生变化。
  • 霰弹式修改:这个有点难理解,个人理解:首先 “霰” 指小冰粒,硬着地常反跳并易碎,可能会散落各处,在本书中 “霰弹式修改” 指的是要修改的代码遍布四处,很难找到它们,容易忽略一些重要的修改。
  • 依恋情结:模块化是力求将代码划出区域,最小化跨区域交互,但有时会发现模块内的一个函数同另一个模块中的函数或数据交互频繁远超模块内部交互。
  • 数据泥团
  • 基本类型偏执:一些基本类型可能无法表示数据的真实意图,偏执的使用基本类型会使得其反。
  • 重复的 switch
  • 循环语句:在 JavaScript 中函数是一等公民,为我们提供了 map、filter、reduce 等常用函数,有些场景下可以帮助我们更快地看到被处理的元素及处理它们的工作。
  • 冗赘的元素:包括用不上的一些代码、重复代码、没必要的函数提炼、类提炼或变量提炼。
  • 夸夸其谈通用性
  • 临时字段
  • 过长的消息链:用户向一个对象请求另一个对象,然后再向后者请求另一个对象...
  • 中间人:过渡封装,可以把那些 “不干实事” 的函数移除掉,例如使用内联函数。
  • 内幕交易
  • 过大的类:就像过长函数一样,处理的内容多了,自然不好理解。
  • 异曲同工的类
  • 纯数据类
  • 被拒绝的遗赠
  • 注释:“当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余”。提炼函数,给函数取一个合适的名字,也可以取代注释并保持程序的结构清晰。这里的注释并不完全是一种坏味道,再遇到复杂逻辑处理时注释有的时候也是需要的。

重构方法

重构方法是本书的重点,每个重构方法都围绕动机做法范例三部分进行介绍,书中你会看到作者的做法很谨慎,分小步一点一点重构、验证,以至于有时会感觉些许 “啰嗦”,但这种小步前进确实会减少出错,不可否认它是一种好的工作方式。

下文我会摘选一些认为在以往编程经历中对自己有帮助的代码编写方法。

提炼函数

“提炼函数” 是最常用的重构之一,在一些面向对象编程语言中是 “提炼方法”。何时提炼?有三种不同的观点:

  • 认为一个函数应该在一屏中展示。
  • 代码复用角度考虑。
  • 将意图与实现分开。

这里我们主要讨论 “将意图与实现分开”,如果你需要花时间浏览一段代码才能弄清楚它的意图时,那么就应该将其提炼到一个函数中,下次再读到这段代码时,根据调用的函数名一眼即可看到函数的用途,无需关心函数体内的具体实现是怎么样的

优化前示例:

function printOwing(invoice) {
  // calculate outstanding
  let outstanding = 0;
  for (const order of invoice.orders) {
    outstanding += order.amount;
  }

  // print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
}

优化后示例:

function printOwing(invoice) {
	const outstanding = calculateOutstanding(invoice);
  printDetails(invoice, outstanding);
  
  function calculateOutstanding(invoice) {
    let result = 0;
    for (const order of invoice.orders) {
      result += order.amount;
    }
    return result;
  }
  function printDetails(invoice, outstanding) {
    console.log(`name: ${invoice.customer}`);
  	console.log(`amount: ${outstanding}`);
  }
}

创建一个新函数提炼我们的代码,根据函数的意图(做什么)来对它命名。原先需要在一段代码的顶部写一段注释描述它做什么,经过函数提炼之后,通过调用函数名称已经知道了它的意图,此时可以不用注释。

这里有个很多人都容易犯难的问题:“如何更好的命名?”,一个改进函数名称的好办法是:“先写一句注释描述函数的用途,再把这句注释变为函数名称”,变量命名也同样如此。但是英语也是一部分人的硬伤,总不能用中文给函数命名吧,建议先用一句简洁的中文描述,之后在借助一些工具翻译为英文,“这也不失为提升英语的一种方式”。

内联函数

“内联函数” 是 “提炼函数” 的反向重构,一个函数的内部实现和它的函数名称同样清晰可读,这种情况下直接使用其中的代码,去掉这个函数

优化前示例:

function getRating(driver) {
  return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver) {
  return driver.numberOfLateDeliveries > 5;
}

优化后示例:

function getRating(driver) {
  return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}

提升变量

当一个表达式非常复杂而难以阅读时,分解表达式、提炼变量也许是个不错的选择

优化前示例:

// base price(底价)、quantity discount(折扣)、shipping(运费)
function price(order) {
  // price is base price - quantity discount + shipping
  return order.quantity * order.itemPrice -
    Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
    Math.min(order.quantity * order.itemPrice * 0.1, 100);
}

优化后示例:

function price(order) {
  const basePrice = order.quantity * order.itemPrice;
	const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
  const shiping = Math.min(basePrice * 0.1, 100);
  
  return basePrice - quantityDiscount + shiping;
}

在一个函数内部,变量能给表达式提供有意义的名字,如上例所示,看起来清晰明了,函数内的注释也可以删除掉了。

内联变量

“内联变量” 是 “提炼变量” 的反向重构。变量是个好东西,但有时当表达式更具表现力时,无需在对表达式提炼变量

优化前示例:

const basePrice = order.basePrice;
return basePrice > 1000;

优化后示例:

return order.basePrice > 1000;

封装变量

对于所有可变的数据,如果作用域超出单个函数,就可考虑将其封装为一个函数进行访问,优点是对变量的修改不会散落在各个地方,可监控数据的变化情况,添加一些修改前的验证或后续逻辑处理也是很方便的。数据作用域越大,封装就越重要,面向对象编程中强调数据的私有(private)背后也是同样的原理。

// 优化前示例:
let defaultOwner = { firstName: 'Martin', lastName: 'Fowler' }; // 全局变量中保存的数据
spaceship.owner = defaultOwner; // 使用地方平平无奇
defaultOwner = { firstName: 'Rebecca', lastName: 'Parsons' }; // 更新数据

上面代码带来两个隐患:

  • 没有限制对全局变量 defaultOwner 的访问,任何引用到的地方都可以修改。
  • 如果引用地方不想共享 defaultOwner 这个全局变量,任何一个地方的改动都会影响到别处对该变量的引用。

以下是优化后示例:

  • 将变量和访问函数移到一个单独文件中,限制了变量的可见性。
  • 修改取值函数,返回原数据的一个副本,控制对变量内容的修改。
// defaultOwner.js
let defaultOwner = { firstName: 'Martin', lastName: 'Fowler' };
export function getDefaultOwner() { return { ...defaultOwner } }; // 个人编码习惯不同,这里有些人可能不喜欢 get 前缀,
export function setDefaultOwner(arg) { defaultOwner = arg };

替换算法

“重构” 可以把一些复杂的东西分解为较简单的小块。随着对问题的理解,会发现在原先做法之外,有更简单的解决方案,此时就可改变原先的算法。

// 优化前示例:
function foundPerson(people) {
  for (let i=0; i<people.length; i++) {
    if (people[i] === 'Don') return 'Don';
    if (people[i] === 'John') return 'John';
    if (people[i] === 'Kent') return 'Kent';
  }
  return '';
}

// 优化后示例:
function foundPerson(people) {
	const condidates = ['Don', 'John', 'Kent'];
  return people.find(p => candidates.includes(p)) || '';
}

拆分循环

经常会看到一些身兼多职的循环,只因可以只循环一次。拆分循环能让每个循环更容易理解,每次修改时也只需要理解修改的那块代码行为。

但这会让许多程序员感到不安的是它会迫使程序执行多次循环。在 “重构” 本书中的建议是先重构让代码结构变得清晰,再进行下一步优化。实际情况下即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈。

优化前示例:

let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
  averageAge += p.salary
  totalSalary += p.salary
}
averageAge = averageAge / people.length

先移动语句微调代码顺序,让存在关联的东西一起出现,可以使代码更容易理解

let totalSalary = 0;
for (const p of people) {
  totalSalary += p.salary
}

let averageAge = 0;
for (const p of people) {
  averageAge += p.salary
}
averageAge = averageAge / people.length

进一步优化,还可以提炼函数,将每个循环提炼到单独的函数中,这要根据实际的情况进行选择。以上示例相对简单, 还可以使用 “以管道取代循环” 做进一步重构,如今的编程语言都提供了更好的语言结构来处理迭代结果,不一定非要使用循环。集合管道就是这样的一种技术,允许使用一组运算来描述集合的迭代过程,JavaScript 中这类运算很多,常见的非mapfilter 莫属,还有 reducefindsome 等。

const totalSalary = people.reduce((total, p) => total + p.salary, 0);
const averageAge = people.reduce((total, p) => total + p.age, 0) / people.length;

重新组织数据

数据结构对于帮助阅读者理解很重要,在本书中介绍了一种专门的 “重构” 手法:“重新组织数据”。

将一个变量用于多个不同的用途,这是催生混乱和 bug 的温床,遇到这种情况可通过 “拆分变量” 方式将不同的用途分开,一个变量只做一件事,同时记得给变量起一个有意义的名字。这个问题看似简单,也是一个常犯的错误。

// 一个变量承担了两件不同的事情
let temp = 2 * (height + width)
console.log(temp)
temp = height * with
console.log(temp)

// 每个变量只承担一个责任
const perimeter = 2 * (height + width)
console.log(perimeter)
const area = height * with
console.log(area)

可变数据是软件中最大的错误源头之一,应尽可能把可变数据限制在最小范围。例如,有些变量可以很容易的计算出来,此时就可考虑去掉这些变量,避免原数据更改时忘记更新派生变量

// 优化前
get production() { return this._production };
applyAdjustment(anAdjustment) {
  this._adjustments.push(anAdjustment);
  this._production += anAdjustment.amount;
}

// 优化后
get production() {
  return this._adjustments
  	.reduce((sum, d) => sum + d.amount, 0)
};
applyAdjustment(anAdjustment) {
  this._adjustments.push(anAdjustment);
}

简化条件逻辑

条件逻辑占据了程序的大部分,如果处理不当也会加大程序的复杂度。一个复杂的条件逻辑,应尽可能的去简化它,提高代码的可读性。这个重构手法称为 “简化条件逻辑”。

例如,要计算购买某样商品的总价(总价 = 数量 * 单价),而该商品在夏季单价计算同其它季节存在差别。下面是优化前代码示例:

// 优化前
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
  charge = quantity * plan.summerRate;
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}

当条件逻辑复杂不易理解时,可以采用 “分解条件表达式” 方式,提炼条件判断为一个新的函数并取一个有意义的名字。根据实际情况也可以提炼为一个变量,包括每一个条件分支都可以做提炼。

// 优化后
if (summer()) {
  charge = quantity * plan.summerRate;
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}

function summer() {
  !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)
}

当发现有一串条件检查,最终行为都一致,这种情况下,应该使用 “逻辑或” 或 “逻辑与” 将它们合并为一个表达式

// 优化前
function disabilityAmount(anEmployee) {
  if (anEmployee.seniority < 2) return 0;
  if (anEmployee.monthsDisabled > 12) return 0;
  if (anEmployee.isPartTime) return 0;
  // compute the disability amount
}

// 优化后
function disabilityAmount(anEmployee) {
  if (isNotEligibleForDisability()) return 0;
  // compute the disability amount
}
function isNotEligibleForDisability() {
  return anEmployee.seniority < 2
		|| anEmployee.monthsDisabled > 12
  	|| anEmployee.isPartTime;
}

使用条件逻辑,还应注意不必要的深层 if...else 嵌套,尽可能的扁平化处理,当该条件为真时立刻从函数中返回。这样能保持代码结构的清晰整洁,一口气就能看得出该函数是做什么的。

// 优化前
function getPayAmount() {
  let result;
  if (isDead) {
    result = deadAmount();
  } else {
    if (isSeparated) {
      result = separatedAmount();
    } else {
      if (isRetired) {
        result = retiredAmount();
      } else {
        result = normalPayAmount();
      }
    }
  }
}

// 优化后
function getPayAmount() {
	if (isDead) return deadAmount();
  if (isSeparated) return separatedAmount();
  if (isRetired) return retiredAmount();
  return normalPayAmount();
}

在一些复杂的条件逻辑编程中除了 if...else 你可能还会见到过使用 **switch...case**,在一组数据类型中,根据每个类型处理各自的条件逻辑,对于这种情况,“重构” 这本书中提出了一种 “以多态取代条件表达式” 的重构手法

多态是面向对象编程中的一个关键特性,这种重构手法是针对 switch 语句中的每个分支逻辑创建一个类,用多态这一特性来承载各个类型特有的行为。

这种重构手法在笔者以往的项目中是没有使用过的,但也不失为一种有趣的思路,可以探索下。

// 优化前
function plumages(birds) {
  return new Map(birds.map(b => [b.name, plumage(b)]));
}
function plumage(bird) {
  switch (bird.type) {
    case 'EuropeanSwallow':
      return 'average';
    case 'AfricanSwallow':
      return (bird.numberOfCoconuts > 2) ? 'tired' : 'average';
    case 'NorwegianBlueParrot':
      return (bird.voltage > 100) ? 'scorched' : 'beautiful';
    default:
      return 'unknown';
  }
}

// 优化后
function plumages(birds) {
  return new Map(birds
                 .map(b => createBird(b))
                 .map(b => [b.name, plumage]));
}
function createBird(bird) {
  switch (bird.type) {
    case 'EuropeanSwallow':
      return new EuropeanSwallow(bird);
    case 'AfricanSwallow':
      return new AfricanSwallow(bird);
    case 'NorwegianBlueParrot':
      return new NorwegianBlueParrot(bird);
    default:
      return new Bird(bird);
  }
}

class Bird {
  constructor(birdObject) {
    Object.assign(this, birdObject);
  }
  get plumage() {
    return 'unknown';
  }
}
class AfricanSwallow extends Bird {
  get plumage() {
    return 'average';
  }
}
class EuropeanSwallow extends Bird {
  get plumage() {
    return this.numberOfCoconuts > 2 ? 'tired' : 'average';
  }
}
class NorwegianBlueParrot extends Bird {
  get plumage() {
    return this.voltage > 100 ? 'scorched' : 'beautiful';
  }
}

分离函数副作用

任何有返回值的函数,都不应该有看得到的副作用。保持了修改和查询的分离,当某个函数只返回一个值时,可以在任意地方调用它,后续对该函数的测试也会变的容易多。

以下示例,检查一群人中是否混进了恶棍(miscreant),如果是则返回恶棍名字并拉响警报。

// 优化前
function alertForMiscreant(people) {
  for (const p of people) {
    if (p === 'Don') {
      setOfAlarms();
      return 'Don';
    }
    if (p === 'John') {
      setOfAlarms();
      return 'John'
    }
  }
  return '';
}

// 优化后
function findMiscreant(people) {
  for (const p of people) {
    if (p === 'Don') {
      return 'Don';
    }
    if (p === 'John') {
      return 'John'
    }
  }
  return '';
}
function alertForMiscreant(people) {
  if (findMiscreant(people) !== '') setOfAlarms();
}

工厂函数取代构造函数

当调用者需要一个新对象时,很多编程语言都提供了构造函数专门用于对象的初始化。也不是所有的场景都用构造函数就是好的,例如工厂函数相比构造函数有更多的灵活性。工厂函数的实现内部可以调用构造函数,也可以是其它的实现方式。

// Class Employee (员工)
constructor() {
  this._name = name;
  this._typeCode = typeCode;
}
get name() { return this._name }
get type() { return Employee.legalTypeCodes[this._typeCode] }
static get legalTypeCodes() { return { E: 'Engineer', M: 'Manager'... } }

// 调用方:类的方式
const candidate = new Employee(document.name, document.empType); // 类的方式一
const leadEngineer = new Employee(document.leadEngineer, 'E'); // 类的方式二

// 调用方:工厂函数
// 例如上面示例中 “类的方式二” 如果不想以字面量的形式传入类型码,可以再新建一个工厂函数
const leadEngineer = createEngineer(document.leadEngineer);
function createEngineer(name) {
  return new Employee(name, 'E');
}

总结

以前当谈及 “重构” 时,想到的会是我们要推翻重来吗?读完本书后改了这一观点,重构不是推倒重来,它可以在不改变外部条件的情况下,小步前进,有条不紊的改善既有代码的设计

如果你是一个经验丰富的程序,书中的一些重构方法会让你感到熟悉,例如,文中笔者列举的一些重构方法有些平常也在这样做,只是没想过原来它还可以有这样一个名字,当看到这些示例后也更坚定了自己的一些做法。也会有些不是很理解,它也需要你带着实践去理解。在不同的时间、环境下阅读都会带来不一样的启发

@qufei1993 qufei1993 self-assigned this Nov 21, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant