《重构 改善既有代码的设计》(第八章)【搬移特性】

在不同的上下文之间搬移元素:

  • 通过搬移函数手法在类与其他模块之间搬移函数
  • 搬移字段

对语句搬移,调整顺序:

  • 搬移语句到函数搬移语句到调用者可用于将语句搬入函数或从函数中搬出
  • 移动语句可以在函数内部调整语句的顺序
  • 以函数调用取代内联代码可以消除重复代码,用函数代替

循环调整:

  • 拆分循环可以确保每个循环只做一件事
  • 以管道取代循环可以直接消灭整个循环

移除死代码可以将勿用的代码全部删除

搬移函数(Move Function)

搬移字段(Move Field)

搬移语句到函数(Move Statements into Function)

搬移语句到调用者(Move Statements into Callers)

以函数调用取代内联代码(Replace Inline Code with Function Call)

移动语句(Slide Statements)

拆分循环(Split Loop)

以管道取代循环(Replace Loop with Pipeline)

移除死代码(Remove Dead Code)

搬移函数

为了设计出高度模块化的程序,需要保证互相关联的软件要素都集中到一块,并确保块与块之间的联系易于查找、直观易懂。

任何函数都需要具备上下文环境才能存活。对一个面向对象的程序而言,类作为最主要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。

搬移函数最直接的动因是:频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。

例一,搬移内嵌函数至顶层

// 重构前
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 = 0; i < points.length; i++) {
      result += distance(points[i - 1], points[i]);
    }
    return result;
  }
  
  function distance(p1, p2) { ... }
  function radians(degrees) { ... }
  function calculateTime() { ... }
}

希望把calculateDistance函数搬移到顶层,单独计算轨迹的距离,不必算出汇总报告(summary)中的其他部分。

先复制一份到顶层函数,并更名为top_calculateDistance,该名字只是临时的名字,后面要认真思考名字替换。

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

移出后,静态分析会报错,里面有两个未定义的符号,distance和points。对于distance将其一并搬移出来会是更好地做法,points当做参数传入。注意在distance函数内部调用了radians函数,也要将它一并搬移。

// 移到顶层
function top_calculateDistance(points) {
  let result = 0;
  for (let i = 0; i < points.length; i++) {
    result += distance(points[i - 1], points[i]);
  }
  return result;
  
  function distance(p1, p2) {
    ...
    const dLat = radians(p2.lat) - radians(p1.lat);
    const dLon = radians(p2.lon) - radians(p1.lon);
    ...
  }
  
  function radians(degrees) {
    return degrees * Math.PI / 180;
  }
}

迁移后,就要让原calculateDistance函数体内调用top_calculateDistance函数

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

测试通过后,就可以移除原函数

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

更换top_calculateDistance函数名,命名为totalDistance,并内联变量

// 重构完成后最终版
function trackSummary(points) {
  const totalTime = calculateTime();
  const pace = totalTime / 60 / totalDistance(points);
  return {
    time: totalTime,
    distance: totalDistance(points),
    pace: pace,
  };
  
  function calculateTime() { ... }
}
  
function totalDistance(points) {
  ...
}

例二,在类之间搬移函数

// 重构前
// 重构原因:存在两个类,Account和AccountType,在计算透支金额计费时,需要根据account type的不同来计算不同的金额,可以尝试将overdraftCharge函数搬移到AccountType类中
class Account {
  get bankCharge() {
    let result = 4.5;
    if (this._daysOverdrawn > 0) result += this.overdraftCharge;
    return result;
  }
  
  get overdraftCharge() {
    if (this.type.isPermium) {
      const baseCharge = 10;
      if (this.daysOverdrawn <= 7) {
        return baseCharge;
      } else {
        return baseCharge + (this.daysOverdrawn - 7) * 0.85;
      }
    } else {
      return this.daysOverdrawn * 1.75;
    }
  }
}

首先观察被overdraftCharge使用的每一项特征:考虑是否值得将它们与overdraftCharge函数一起移动。daysOverdrawn字段留在Account类中,因为会随Account的不同而变化。先将overdraftCharge函数主体复制到AccountType类中,并做相应的调整。

class AccountType {
	overdraftCharge(daysOverdrawn) {
     if (this.isPermium) {
      const baseCharge = 10;
      if (daysOverdrawn <= 7) {
        return baseCharge;
      } else {
        return baseCharge + (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);
  }
}

最后需要判断决定是否要保留overdraftCharge委托函数,还是直接内联,如果内联可以改变为下面这样

// 重构最终结果-内联方式
class Account {
  get bankCharge() {
    let result = 4.5;
    if (this._daysOverdrawn > 0) result += this.type.overdraftCharge(this.daysOverdrawn);
    return result;
  }
}

搬移字段

搬移数据,原因包括发现每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数。总是一同出现、一同作为函数参数传递的数据,最好是调整到同一记录中,以体现它们之间的联系。修改的难度也是一个原因,如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。或者如果更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆。

例子

// 重构前
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;
  }
  applyDiscount(amount) {
    return amount.subtract(amount.multiply(this._discountRate));
  }
}

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

重构的目标是将discountRate字段从Customer类中搬移到CustomerContract里

可以先用封装变量将_discountRate字段的访问封装起来,之后搬移会更容易修改

class Customer {
  constructor(name, discountRate) {
    this._name = name;
    this._setDiscountRate(discountRate);
    this._contract = new CustomerContract(dateToday());
  }
  get discountRate() { return this._discountRate; }
  _setDiscountRate(aNumber) { this._discountRate = aNumber; }
  becomePreferred() {
    this._setDiscountRate(this.discountRate + 0.03);
  }
  applyDiscount(amount) {
    return amount.subtract(amount.multiply(this.discountRate));
  }
}

在CustomerContract类中添加discountRate字段和访问函数

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

接下来在Customer类中新添对discountRate字段的引用

// 重构后
class Customer {
  constructor(name, discountRate) {
    this._name = name;
    this._contract = new CustomerContract(dateToday(), discountRate);
    this._setDiscountRate(discountRate);
  }
  get discountRate() { return this._contract.discountRate; }
  _setDiscountRate(aNumber) { this._contract.discountRate = aNumber; }
  becomePreferred() {
    this._setDiscountRate(this.discountRate + 0.03);
  }
  applyDiscount(amount) {
    return amount.subtract(amount.multiply(this.discountRate));
  }
}

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

搬移语句到函数

反向重构是搬移语句到调用者。

"消除重复"是维护代码库的最重要的准则。如果发现调用某个函数时,总有一些相同的代码也需要每次执行,那么会将此段代码合并到函数里。如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者将它搬移出来也很简单。

例子

// 重构前
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>',
  ].join('\n');
}

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

emitPhotoData函数有两个调用点,每个调用点的前面都有一行类似的重复代码,重构的目的是将这个重复代码消除了。

先使用提炼函数,将待搬移的语句提炼到新函数中

function photoDIV(p) {
	return [
    '<div>',
    zznew(p),
    '</div>',
  ].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');
}

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

接着将emitPhotoData函数的所有调用点内联到新函数中

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

最后改函数名,done

// 重构后
function renderPerson(outStream, person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(renderPhoto(person.photo));
  result.push(emitPhotoData(person.photo));
  return result.join('\n');
}

function photoDiv(p) {
  return [
    '<div>',
		emitPhotoData(p),
    '</div>',
  ].join('\n');
}

function emitPhotoData(photo) {
  return [
    '<p>title: ${photo.title}</p>',
		`<p>location: ${photo.location}</p>`,
  	`<p>date: ${photo.date.toDateString()}</p>`,
  ].join('\n');
}

搬移语句到调用者

反向重构:搬移语句到函数

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

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

有时调用点和调用者之间的边界已经相去甚远,此时便只能重新进行设计了。若果真如此,最好的办法就是先用内联函数合并双方的内容。调整语句的顺序,再提炼出新的函数,以形成更合适的边界。

例子

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

function listRecentPhoto(outStream, photos) {
  photos
    .filter(p => p.data > recentDateCutoff())
  	.forEach(p => {
    outStream.write('<div>\n');
    emitPhotoData(outStream, p);
    outStream.write('</div>\n');
  });
}

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

现在需要修改软件,让listRecentPhoto函数以不同方式渲染相片的location信息,而renderPerson行为保持不变。

首先,提炼函数,将希望保留的emitPhotoData函数的语句提炼出来

function emitPhotoData(outStream, photo) {
  zztmp(ouStream, photo);
  outStream.write(`<p>location: ${photo.location}</p>`);
}

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

接下来内联emitPhotoData函数

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

function listRecentPhoto(outStream, photos) {
  photos
    .filter(p => p.data > recentDateCutoff())
  	.forEach(p => {
    outStream.write('<div>\n');
    zztmp(ouStream, p.photo);
  outStream.write(`<p>location: ${p.photo.location}</p>`);
    outStream.write('</div>\n');
  });
}

最后,将zztmp函数改为原函数的名字,end

// 重构后
function renderPerson(outStream, person) {
  outStream.write(`<p>${person.name}</p>`);
  renderPhoto(person.photo);
  emitPhotoData(ouStream, person.photo);
  outStream.write(`<p>location: ${person.photo.location}</p>`);
}

function listRecentPhoto(outStream, photos) {
  photos
    .filter(p => p.data > recentDateCutoff())
  	.forEach(p => {
    outStream.write('<div>\n');
    emitPhotoData(ouStream, p.photo);
  outStream.write(`<p>location: ${p.photo.location}</p>`);
    outStream.write('</div>\n');
  });
}

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

以函数调用取代内联代码

善用函数可以帮助将相关的行为打包起来,这对于提升代码的表达力大有裨益。函数有助于消除重复,因为同一段代码不需要编写两次,每次调用以下函数极客。此外,当需要修改函数内部的实现时,也不需要四处寻找有没有漏改的相似代码。

例子

// 重构前
let appliesToMass = false;
for (const s of states) {
  if (s === 'MA') appliesToMass = true;
}
// 重构后
appliesToMass = states.includes('MA');

移动语句

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

把相关代码搜集到一处,往往是另一项重构(通常是提炼函数)开始之前的准备工作。

拆分循环

经常会碰到一个循环内做两三件事情,原因是这样做可以将循环减小为一次。但带来的问题是,在一次循环中做两件不同的事情,那么每当需要修改循环时,就得同时理解这两件事情。

拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可。接着将拆分得到的循环应用提炼函数。

可能拆分成多个循环会觉得性能受到影响。但作者建议:先进行 重构,然后再进行性能优化。如果重构之后该循环确实成了性能的瓶颈,再把拆开的循环合到一起也很容易。但实际情况是,即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈,更何况拆分出循环来通常还使一些更强大的性能优化成为可能。

例子

// 重构前
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: ${totalSalary}`;

for循环内虽然代码很简洁,但实际做了两件事,第一步先复制for循环,然后删除重复的计算逻辑,并用移动语句微调顺序,将与循环相关的变量先搬移到一起

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

接下来,寻求将每个循环提炼到独立的函数中。

// 重构后
return `youngestAge: ${youngestAge()}, totalSalary: ${totalSalary()}`;
function youngestAge() {
  let youngest = people[0] ? people[0].age : Infinity;
  for (const p of people) {
    if (p.age < youngest) youngest = p.age;
  }
  return youngest;
}
function totalSalary() {
  let totalSalary = 0;
  for (const p of people) {
    totalSalary += p.salary;
  }
  return totalSalary;
}

对于以上的重构虽然完成了,但实际上可以使用以管道取代循环和替换算法的重构方式让算法变得更好

// 重构后
return `youngestAge: ${youngestAge()}, totalSalary: ${totalSalary()}`;
function youngestAge() {
  return Math.min(...people.map(p => p.age));
}
function totalSalary() {
  return people.reduce((total, p) => total + p.salary, 0);
}

以管道取代循环

早年迭代一组集合时需要需要循环。现在有更好地方式处理迭代,成为集合管道(collection pipeline),集合管道允许使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。

例子

// 重构前
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() === '') return 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');
  let firstLine = true;
  const result = [];
  const loopItems = lines;
  for (const line of loopItems) {
    if (firstLine) {
      firstLine = false;
      continue;
    }
    if (line.trim() === '') return continue;
    const record = line.split(',');
    if (record[1].trim() === 'India') {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

for循环的第一个if判断是用于去除第一行数据,可以直接用slice操作

function acquireData(input) {
  const lines = input.split('\n');
  const result = [];
  const loopItems = lines.slice(1);
  for (const line of loopItems) {
    if (line.trim() === '') return continue;
    const record = line.split(',');
    if (record[1].trim() === 'India') {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

接下来过滤掉数据中的所有空行,可以用filter替代

function acquireData(input) {
  const lines = input.split('\n');
  const result = [];
  const loopItems = lines
  	.slice(1)
  	.filter(line => line.trim() !== '');
  for (const line of loopItems) {
    const record = line.split(',');
    if (record[1].trim() === 'India') {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

接下来讲每一行转换成数组,使用map运算替代

function acquireData(input) {
  const lines = input.split('\n');
  const result = [];
  const loopItems = lines
  	.slice(1)
  	.filter(line => line.trim() !== '')
  	.map(line => line.split(','));
  for (const line of loopItems) {
    const record = line;
    if (record[1].trim() === 'India') {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

接着过滤India记录,用filter操作

function acquireData(input) {
  const lines = input.split('\n');
  const result = [];
  const loopItems = lines
  	.slice(1)
  	.filter(line => line.trim() !== '')
  	.map(line => line.split(','))
  	.filter(record => record[1].trim() === 'India');
  for (const line of loopItems) {
    const record = line;
    result.push({ city: record[0].trim(), phone: record[2].trim() });
  }
  return result;
}

接着将结果用map转换为对象格式

function acquireData(input) {
  const lines = input.split('\n');
  const result = [];
  const loopItems = lines
  	.slice(1)
  	.filter(line => line.trim() !== '')
  	.map(line => line.split(','))
  	.filter(record => record[1].trim() === 'India')
  	.map(record => ({ city: record[0].trim(), phone: record[2].trim() }));
  for (const line of loopItems) {
    const record = line;
    result.push(record);
  }
  return result;
}

最后for循环内只剩下对累积变量赋值,实际上直接将loopItems赋值给result,就可以将for循环给全部去除,并内联变量result直接返回结果

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

移除死代码

无用代码可能对性能、内存不会带来影响,但对于尝试阅读、理解软件运作原理时,无用代码确实会带来很多额外的思维负担。

一旦代码不再被使用,就应该立马删除它。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值