重构,改善代码的既有设计——搬移特性

搬移特性

到目前为止,介绍的重构手法都是关于如何新建、移除或重命名程序的元素。此外还有一种类型的重构也很重要,就是在不同的上下文之间搬移元素。

搬移函数

动机

模块化是优秀软件设计的核心所在,好的模块化能够让我在修改程序时只需理解程序的一小部分。但我对模块设计的理解并不是一成不变的,随着我对代码的加深,我会知道哪些设计要素如何组织最为恰当。

任何函数都需要具备上下文环境才能存活,这个上下文环境可以是全局的,但它更多时候是由某种形式的模块所提供的。搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时让它去与那些更亲密的函数相会,通常能取得更好的效果。

同样,如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。

做法

  1. 检查函数在当前上下文里引用的所有程序元素(包括变量和函数),考虑是否将它们一并搬移。
  2. 检查待搬移函数是否具备多态性。
  3. 将函数复制一份到目标上下文中。调整函数,使它们能适应新家。
  4. 执行静态检查。
  5. 设法从源上下文中正确引用目标函数。
  6. 修改源函数,使之成为一个纯委托函数。
  7. 测试。
  8. 考虑对源函数使用内联函数。

搬移内嵌函数至顶层

比如下面这个函数会计算一条 GPS 轨迹记录的总距离

function trackSummary(points) {
    const totalTime = calculateTime()
    const totalDistance = calculateDistance()
    const pace = totalTime / 60 / totalDistance
    return {
        time: totalTime,
        distance: totalDistance,
        pace: pace
    }

    function calculateDistance() {
        let result = 0
        for (let i = 1; i < points.length; i++) {
            result += distance(points[i - 1], points[i])
        }
        return result
    }

    function distance(p1, p2) {
        ...
    }

    function radians(degrees) {
        ...
    }

    function calculateTime() {
        ...
    }
}

我希望把 calculateDistance 函数搬移至顶层,这样我就能单独计算轨迹的距离,而不必计算出汇总报告的其他部分。

// 将 calculateDistance 复制一份至顶层,同时把 distance 也一并搬移
function trackSummary(points) {
    const totalTime = calculateTime()
    const totalDistance = calculateDistance()
    const pace = totalTime / 60 / totalDistance
    return {
        time: totalTime,
        distance: totalDistance,
        pace: pace
    }

    function calculateDistance() {
        let result = 0
        for (let i = 1; i < points.length; i++) {
            result += distance(points[i - 1], points[i])
        }
        return result
    }

    function calculateTime() {
        ...
    }
}

function top_calculateDistance() {
    let result = 0
    for (let i = 1; i < points.length; i++) {
        result += distance(points[i - 1], points[i])
    }
    return result

    function distance(p1, p2) {
        ...
    }

    function radians(degrees) {
        ...
    }
}

之后我需要在原 calculateDistance 函数体内调用 top_calculateDistance 函数

function trackSummary(points)...
function calculateDistance() {
    return top_calculateDistance(points)
}

// 测试通过后,将 top_calculateDistance 改名为 totalDistance
// 此时发现与原来的变量名相同,此时可以直接替换
// 如果不想删除变量,可以再取一个名
function trackSummary(points) {
    const totalTime = calculateTime()
    const totalDistance = calculateDistance()
    const pace = totalTime / 60 / totalDistance(points)
    return {
        time: totalTime,
        distance: totalDistance(points),
        pace: pace
    }
}

范例:在类之间搬移函数

在类之间搬移函数也是一种常见场景,下面有一个账户类

class Account...
    get bankCharge() {
        let result = 4.5
        if (this._daysOverdrawn > 0) result += this.overdraftCharge
        return result
    }

get overdraftCharge() {
    if (this.type.isPremium) {
        const baseCharge = 10
        if (this.daysOverdrawn <= 7)
            return baseCharge
        else
            return baseCharge + (this.daysOverdrawn - 7) * 0.85
    } else
        return this.daysOverdrawn * 1.75
}

上面的代码根据账户类型的不同,决定不同的“透支金额计费”算法,因此,会很自然地将 overdraftCharge 函数搬移到 AccountType 类上去。

我将 overdraftCharge 函数主体复制到 AccountType 类中,并做相应调整。

class AccountType...
    // 此处传入一个 account 对象是为了以后可能要调用 account 的其他数据
    overdraftCharge(account) {
        if (this.isPremium) {
            const baseCharge = 10
            if (account.daysOverdrawn <= 7)
                return baseCharge
            else
                return baseCharge + (account.daysOverdrawn - 7) * 0.85
        } else
            return daysOverdrawn * 1.75
    }

// 完成函数复制后,再将原来的方法代之以一个委托调用
class Account...
    get bankCharge() {
        let result = 4.5
        if (this._daysOverdrawn > 0) result += this.overdraftCharge
        return result
    }

get overdraftCharge() {
    return this.type.overdraftCharge(this.daysOverdrawn)
}

搬移字段

动机

变成活动中你需要编写许多代码,为系统实现特定的行为,但往往数据结构才是一个健壮程序的根基。如果我发现数据结构已经不适应于需求,就应该马上修缮它。搬移字段的操作通常是在其他更大的改动背景下发生的。另外如果你的类进行了封装,那么会比裸数据更容易进行这个重构手法。

做法

  1. 确保源字段已经得到了良好封装。
  2. 测试。
  3. 在目标对象上创建一个字段。
  4. 执行静态检查。
  5. 确保源对象能够正常引用目标对象。如果你没有现成的字段或方法得到目标对象,那么你可能就得在源对象里创建一个字段,用于存储目标对象。
  6. 调整源对象的访问函数,令其使用目标对象的字段。
  7. 测试。
  8. 移除源对象上的字段。
  9. 测试。

范例

下面这个例子中 Customer 类代表一位 ‘顾客', CustomerContract 代表与顾客关联的一个‘合同’。

class Customer...
    constructor(name, discountRate) {
        this._name = name
        this._discountRate = discountRate
        this._contract = new CustomerContract(dateToday())
    }

get discountRate() {
    return this._discountRate
}
becomePreferred() {
    this._discountRate += 0.03
}
apply Discount(amount) {
    return amount.subtract(amount.multiply(this._discountRatre))
}

class CustomerContract...
    constructor(startDate) {
        this._startDate = startDate
    }

此时我想将折扣率 discountRate 字段从 Custoemr 类中搬移到 CustomerContract 中。

class CustomerContract...
    constructor(startDate, discountRate) {
        this._startDate = startDate
        this._discountRate = discountRate
    }
get discountRate() {
    return this._discountRate
}
set discountRage(arg) {
    this._discountRage = arg
}

class Customer...
    constructor(name, discountRate) {
        this._name = name
        this._setDiscountRate(discountRate)
        this._contract = new CustomerContract(dateToday())
    }

get discountRate() {
    return this._contract._discountRate
}
_setDiscountRage(aNumber) {
    this._contract.discountRate = aNumber
}

搬移裸记录 如果我要搬移的字段是裸记录,并被许多函数直接访问,那么这项重构仍然很有意义,不过情况会复杂不少。

范例:搬移字段到共享对象

现在,让我们看另外一个场景。还是那个代表‘账户’的 Account 类,类上有一个代表‘利率’的字段 _interestRage

class Account...
    constructor(number, type, interestRage) {
        this._number = number
        this._type = type
        this._interestRate = interestRate
    }
get interestRate() {
    return this._interestRage
}

class AccountType...
    constructor(nameString) {
        this._name = nameString
    }

我希望利率字段由 AccountType 类来进行维护

class Account...
    constructor(nameString, type) {
        this._name = nameString
        this._type = type
    }
get interestRate() {
    return this._type.interestRage
}

class AccountType...
    constructor(nameString, interestRate) {
        this._name = nameString
        this._interestRate = interestRate
    }
get interestRate() {
    return this._interestRage
}

搬移语句到函数

动机

要维护代码的健康发展,要遵循几条黄金法则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同代码也需要每次执行,那么我会考虑将此段代码合并到函数里。如果某些语句与一个函数放在一起更像一个整体,那我也会将语句搬移到函数里。

做法

  1. 如果重复代码段离调用目标函数的地方还有些距离,则先用移动语句将它们弄懂到紧邻目标函数的位置。
  2. 如果目标函数只被一个源函数调用,只需将源函数的重复代码剪切粘贴到目标函数中即可。
  3. 如果函数不止一个调用点,则先将其中一个调用点应用提炼函数将待搬移的语句和目标函数一起提炼成一个新函数。给新函数取个临时的易于搜索的名字。
  4. 调整函数的其他调用点,令它们调用新提炼的函数。每次调整之后运行测试。
  5. 完成所有引用点的替换后,应用内联函数将目标函数内联到新函数中,并移除原目标函数。
  6. 对新函数应用函数改名,将其改名为目标函数的名字。

范例

以下代码会生成一些关于相片的 HTML

function renderPerson(outStream, person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(renderPhoto(person.photo));
  result.push(`<p>title: ${person.photo.title}</p>`);
  result.push(emitPhotoData(person.photo));
  return result.join("/n");
}

function photoDiv(p) {
  return ["<div>", `<p>title: ${p.title}</p>`, emitPhotoData(p), "</div>"];
}

function emitPhotoData(aPhoto) {
  const result = [];
  result.push(`<p>location: ${aPhoto.location}</p>`);
  result.push(`<p>date: ${aPhoto.data.toDateString()}</p>`);
  return result.join("/n");
}

这个例子中 emitPhotoData 函数中有两个调用点,每个调用点的前面都有一行类似的重复代码,用于打印与标题有关的信息。我会选择一个调用点,对其应用提炼函数,除了要搬移的语句,我还把 emitPhotoData 函数一起提炼到新函数中。

function photoDiv(p) {
  return ["<div>", zznew(p), "</div>"].join("/n");
}

function zznew(p) {
  return ["<p>title: ${p.title}</p>", emitPhotoData(p)].join("/n");
}

// 之后将其它调用点一并替换成对新函数的调用
function renderPerson(outStream, person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(renderPhoto(person.photo));
  result.push(zznew(person.photo));
  return result.join("/n");
}

替换完之后,用内联函数将 emitPhotoData 函数内联到新函数中

function zznew(p) {
  return [
    `<p>title: ${p.title}</p>`,
    `<p>location: ${aPhoto.location}</p>`,
    `<p>date: ${aPhoto.data.toDateString()}</p>`,
  ].join("/n");
}

最后将新提炼的函数 zznew 改为 emitPhotoDate 即可

搬移语句到调用者

动机

程序抽象能力的源泉来自于函数。但随着系统能力发生演进,原先设定的抽象边界总会悄无声息地发生偏移,对于函数来说这样的边界偏移意味着曾经一个整体,一个单元的行为,如今可能已经分化出两个甚至是多个不同的关注点。

函数边界发生偏移的一个征兆是,以往多个地方共用的欣慰,如今需要在某些调用点面前表现出不同的行为。于是我们得把表现不同的行为从函数里挪出,并搬移到其调用处。

做法

  1. 最简单的情况下,原函数非常简单,其调用者只有一两个,此时只要复制粘贴即可。
  2. 如果调用点不止一两个,则需要先用提炼函数将你不想搬移的代码提炼成一个新函数,函数名可以临时起一个,只要后续容易搜索即可。
  3. 对原函数应用内联函数
  4. 对提炼出的函数应用改变函数声明,令其与原函数使用同一个名字,如果有更好的名字也可以用。

范例 emitPhotoData 是一个函数,在两处地方被调用。

function renderPerson(outStream, person) {
  outStream.write(`<p>${person.name}</p>\n`);
  renderPhoto(outStream, person.photo);
  emitPhotoData(outStream, person.photo);
}

function listRecentPhotos(outStream, photos) {
  photos
    .filter((p) => p.date > recentDateCutoff())
    .forEach((p) => {
      outStream.write("<div>\n");
      emitPhotoData(outStream, p);
      outStream.write("</div>\n");
    });
}

function emitPhotoData(outStream, photo) {
  outStream.write(`<p>title: ${person.title}</p>\n`);
  outStream.write(`<p>date: ${person.date.toDateString()}</p>\n`);
  outStream.write(`<p>location: ${person.location}</p>\n`);
}

此时我想修改支持 listRecentPhotos 函数以不同方式渲染相片的 location 信息,而 renderPerson 的行为则保持不变。

// 首先提炼函数,把最总希望留在 emitPhotoData 的函数语句先提炼出去
function emitPhotoData(outStream, photo) {
  zztmp(outStream, photo);
  outStream.write(`<p>location: ${person.location}</p>\n`);
}

function zztmp(outStream, photo) {
  outStream.write(`<p>title: ${person.title}</p>\n`);
  outStream.write(`<p>date: ${person.date.toDateString()}</p>\n`);
}

之后将所有 emitPhotoData 函数的调用点使用内联函数

function renderPerson(outStream, person) {
  outStream.write(`<p>${person.name}</p>\n`);
  renderPhoto(outStream, person.photo);
  zztmp(outStream, photo);
  outStream.write(`<p>location: ${person.location}</p>\n`);
}

function listRecentPhotos(outStream, photos) {
  photos
    .filter((p) => p.date > recentDateCutoff())
    .forEach((p) => {
      outStream.write("<div>\n");
      zztmp(outStream, photo);
      outStream.write(`<p>location: ${person.location}</p>\n`);
      outStream.write("</div>\n");
    });
}

之后将 zztmp 改名为原函数名字 emitPhotoData ,完成本次重构。

移动语句

动机

让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让它们一起出现,而不是夹杂在取用其他数据结构的代码中间。

做法

  1. 确定带移动的代码片段应该搬往何处。仔细检查带移动片段与目的地之间的语句,看看搬移后是否会影响这些代码的正常工作,如果会,则放弃这项重构。
  2. 剪切源代码片段,粘贴到上一步选定的位置上。
  3. 测试。

范例

移动代码片段时,通常需要想清楚两件事:本次调整的目标是什么,以及该目标能否达到。有以下代码片段:

let result;
if (availableResources.length === 0) {
  result = createResource();
  allocatedResources.push(result);
} else {
  result = availableResources.pop();
  allocatedResources.push(result);
}
return result;

变为

let result;
if (availableResources.length === 0) {
  result = createResource();
} else {
  result = availableResources.pop();
}
allocatedResources.push(result);
return result;

拆分循环

动机

你常常能见到一些身兼多职的循环,它们一次做了两三件事情,就为了只循环一次。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时只理解要修改的那部分代码。

拆分循环还能让每个循环更容易使用,如果循环做了太多件事,那就只得返回结构型数据或通过局部变量传值了。

这项手法可能慧染很多程序员感到不安,因为它迫使你执行多次循环。对此我的建议还是:先进行重构,然后在进行性能优化,如果重构之后该循环确实成为了性能瓶颈,届时再把拆开的循环合到一起也很容易。但实际情况是,循环本身很少成为性能瓶颈。

做法

  1. 复制一遍循环代码。
  2. 识别并移除循环中的重复代码,使每个循环只做一件事。
  3. 测试。

范例 下面一段循环会计算需要支付给所有员工的总薪水,并计算出最年轻员工的年龄:

let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {
  if (p.age < youngest) youngest = p.age;
  totalSalary += p.salary;
}

return `youngestAge: ${youngest}, totalSalary: ${tatalSalary}`;

复制一边循环并删除重复的计算逻辑

let youngest = people[0] ? people[0].age : Infinity;
let totalSalary = 0;
for (const p of people) {
  totalSalary += p.salary;
}

for (const p of people) {
  if (p.age < youngest) youngest = p.age;
}

return `youngestAge: ${youngest}, totalSalary: ${tatalSalary}`;

拆分循环这个手法本身的内容就结束了,但本手法的意义不仅在于此,它还为进一步优化提供了良好的起点。

let youngest = people[0] ? people[0].age : Infinity;
return `youngestAge: ${youngestAge()}, totalSalary: ${totalSalary()}`;

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

function youngestAge() {
  let youngest = prople[0] ? people[0].age : Infinity;
  for (const p of people) {
    if (p.age < youngest) youngest = p.age;
  }
  return youngest;
}

进一步优化

function totalSalary() {
  return people.reduce((total, p) => total + p.salary, 0);
}

function youngestAge() {
  return Math.min(...people.map((p) => p.age));
}

以管道取代循环

动机

时代在发展,有一种比循环更好的语言结构来处理迭代过程,这种结构就叫做集合管道。它有时会让代码段可读性更强。

做法

  1. 创建一个新变量,用以存放参与循环过程的集合。
  2. 从循环顶部开始,将循环里的每一块行为一次搬移出来,在上一步创建的集合变量上用一种管道替换替代之。每次修改后运行测试。
  3. 搬移完循环里的全部行为后,将循环整个删除。

范例

我们有一个 CSV 文件,里面存放着各个办公室的一些数据

office, country, teletphohe
Chicago, USA, +1 312 373 1000
Beiging, China, +86 4008 900 505
Bangalore, India, +91 80 4064 9570
Porto Alegre, Brazil, +55 51 3079 3550
Chennai, India, +91 44 660 44766

// 下面这个函数用于从数据中筛选出印度的所有办公室,并返回办公室所在城市信息和联系电话
function acquireData(input) {
    const lines = input.split('\n')
    let firstLine = true
    const result = []
    for (const line of lines) {
        if (firstLine) {
            firstLine = false
            continue
        }
        if (line.trim() === '') continue
        const record = line.split(',')
        if (record[1].trim() === 'India') {
            result.push({
                city: record[0].trim(),
                phone: record[2].trim()
            })
        }
    }
    return result
}

之后改为

function acquireData(input) {
  const lines = input.split("/n");
  return lines
    .slice(1)
    .filter((line) => line.trim() !== "")
    .map((line) => line.split(","))
    .filter((fields) => fields[1].trim() === "India")
    .map((fields) => ({
      city: record[0].trim(),
      phone: record[2].trim(),
    }));
}

移除死代码

动机

当你尝试阅读代码时,无用代码会带来很多额外的思维负担,所以一旦代码不再被使用,我们就应该删除它,可能以后又会需要这段代码,我也可以从版本控制系统里再次将它翻找出来。

做法

  1. 如果四代码可以从外部直接引用,比如它是一个独立的函数,先查找一下有无调用点。
  2. 将死代码移除。
  3. 测试。