24 大技巧 - 成为编程高手,保持软件健康长青

最近,小李感受公司女生们看他的眼神不太对劲了,那种笑容好像是充满慈爱的、姨母般的笑容。javascript

image

做为一名老实本分的程序员,小李不太习惯这种被人过分关注的感受,他不知道发生了什么。java

······git

小李和小王的关系彷佛过于亲密,还常常挤在一个工位上办公,一直到半夜。程序员

这个流言直到某天他们的聊天内容被某个运营小姐姐听到,他们之间的秘密才被你们发现。github

小李和小王的故事

“这串代码看起来不太好。” 小李指着屏幕,眉头紧锁。编程

image

“咋了,这段代码是我写的,由于这里要实现客户的一个定制化需求,因此看起来有点复杂。” 小王凑了过来。后端

“我也说不出来哪里不对劲,我如今要添加一个新特性,不知道从哪里下手。” 小李挠了挠头。设计模式

小王把凳子搬了过来:“来来,我给你讲讲,这段代码,这段调用.....”api

·····数组

中午 12 点,办公室的空气中弥漫着饭菜的香气,还有三五成群约饭的小伙伴,休闲区也成了干饭的主战场。

“噢?原来小李和小王这种叫作结对编程?” 运营小姐姐目光扫向还在座位上的小李小王。

image

“嗯...偶尔的结对编程是正常的,可是长期的结对编程说明出现了一些坏味道。”老工程师说完,端起饭盆,干完了最后一口饭。

“oh...是那种...味道吗?”运营小姐姐试探道。

在座一块儿吃饭的 HR、UI 小姐姐们被这句话又点燃了八卦之魂,发出了一阵愉快的笑声。

“NoNoNo...老耿的意思,代码里出现了坏味道。”高级工程师大宝扶了扶眼镜。

“什么叫代码里出现了坏味道呀?”

“就是软件架构要开始分崩离析的前兆,代码里面充斥着坏味道,这些味道都散发着恶心、腐烂的信号,提示你该重构了。”

小姐姐们眉头一皱,不太满意大宝在吃饭的时候讲了个这么倒胃口的例子。

老耿喝了口水,放下水杯:“阿宝说的没错。很显然,小李和小王在写程序的时候,并无发现代码中存在的坏味道,致使他们如今欠下了一大批的技术债务,正在分期还债。”

“看来是时候给他们作个培训了,教他们如何识别代码中的坏味道。”

代码中的坏味道

小李和小王顶着两个大黑眼圈来到会议室,老耿早已经在会议室等着了。

小李看了一眼投影仪的内容 “24 种常见的坏味道及重构手法 —— 告别加班,可持续发展”。

image

“呐,给大家俩准备的咖啡,打起精神来哈。”大宝用身子推门进来,手上端着两杯热腾腾的咖啡,这是他刚从休息区亲手煮的新鲜咖啡。

“谢谢宝哥” 小李和小王齐声道。

“据说大家俩最近总是一块儿加班到半夜。” 老耿把手从键盘上拿开,把视线从电脑屏幕上移到小李小王的身上。

“嘿嘿...” 小李很差意思的笑了笑 “最近需求比较难,加一些新功能比较花时间。”

“嗯,看来大家也发现了一些问题,为何如今加新功能花的时间会比之前多得多呢?”老耿把凳子日后挪了挪,看着两人。

“由于产品愈来愈复杂了,加新功能也愈来愈花时间了。”小王接话道。

“对,这算是一个缘由。还有吗?”老耿接着问。

“因...由于咱们以前写的代码有点乱,可能互相理解起来比较花时间...” 小李挠了挠头。

“但其实我都写了注释的,我以为仍是由于产品太复杂了。”小王对小李这个说法有点不承认。

“好,其实小李说到了一个关键问题。”老耿以为是时候了,接着说道:“Martin Fowler 曾经说过,当代码库看起来就像补丁摞补丁,须要细致的考古工做才能弄明白整个系统是如何工做的。那这份负担会不断拖慢新增功能的速度,到最后程序员巴不得从头开始重写整个系统。”

image

“我想,大家应该有不少次想重写系统的冲动吧?”老耿笑了笑,继续说道:“而内部质量良好的软件可让我在添加新功能时,很容易找到在哪里修改、如何修改。”

老耿顿了顿,“因此,小王说的也对。产品复杂带来了软件的复杂,而复杂的软件一不当心就容易变成了补丁摞补丁。软件代码中充斥着 “坏味道”,这些 “坏味道” 最终会让软件腐烂,而后失去对它的掌控。”

“对了,小王刚才提到的注释,也是一种坏味道。”老耿笑了笑,看着小王,“因此,坏味道无处不在,有的坏味道比较好察觉,有的坏味道须要通过特殊训练,才能识别。”

老耿又指了指投影仪大屏幕:“因此,咱们今天是一期特殊训练课,教会大家识别 24 种常见的坏味道及重构手法,这门课至少可让大家成为一个有着一些特别好的习惯的还不错的程序员。”

“咳咳,大家俩赚大了”大宝补充道:“老耿这级别的大牛,在外边上课但是要收费的哟~”

“等等,我去拿笔记本。”小李举手示意,而后打开门走出去,小王见状也跟着小李出去拿笔记本了。

24 种常见的坏味道及重构手法

老耿见你们已经都作好了学习的准备,站起身走到屏幕旁边,对着你们说道:“那咱们开始吧!”

”坏味道咱们刚才已经说过了,我再讲个小故事吧。我这么多年以来,看过不少不少代码,它们所属的项目有大获成功的,也有奄奄一息的。观察这些代码时,我和个人老搭档学会了从中找出某些特定结构,这些结构指出了 重构 的可能性,这些结构也就是我刚才提到的 坏味道。”

“噢对,我刚才还提到了一个词 —— 重构。这听着像是个很可怕的词,可是我能够和大家说,我口中的 重构 并非大家想的那种推翻重来,有本书给了这个词一个全新的定义 —— 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提升其可理解性,下降其修改为本。,大家也能够理解为是在 使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

image

“若是有人说他们的代码在重构过程当中有一两天时间不可用,基本上能够肯定,他们在作的事不是重构。”老耿开了个玩笑:“他们可能在对代码施展某种治疗魔法,这种魔法带来的反作用就是会让软件短暂性休克。”

“在这里,还有一点值得一提,那就是如何作到 不改变软件可观察行为,这须要一些外力的协助。这里我不建议大家再把加班的人群延伸到测试人员,我给出的方案是准备一套完备的、运行速度很快的测试套件。在绝大多数状况下,若是想要重构,就得先有一套能够自测试的代码。”

“接下来,我会先审查大家商城系统的代码,发现代码中存在的坏味道,而后添加单元测试,进行重构后,最后经过测试完成重构。”

“我会在这个过程当中,给大家演示并讲解 24 种常见的坏味道及重构手法。”

“咱们开始吧!”老耿从新回到座位。

神秘命名(Mysterious Name)

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

const orderPrice = countOrder(order);

“上面的这段 countOrder 函数是作什么的?看到函数名的第一感受不太清晰,统计订单?订单商品数量吗?仍是统计什么?可是,看到函数内部实现后,我明白了这是个统计订单总价格的函数。这就是其中一个坏味道 —— 神秘命名,当代码中这样的坏味道多了以后,花在猜谜上的时间就会愈来愈多了。”

“咱们如今来对这段代码进行重构,咱们须要先添加单元测试代码。这里我只作演示,写两个测试用例。”

老耿很快针对这段代码,使用著名的 jest 框架写出了下面两个测试用例。

describe('test price', () => {
  test('countOrder should return normal price when input correct order quantity < 500', () => {
    const input = {
      quantity: 20,
      itemPrice: 10
    };

    const result = countOrder(input);

    expect(result).toBe(220);
  });

  test('countOrder should return discount price when input correct order quantity > 500', () => {
    const input = {
      quantity: 1000,
      itemPrice: 10
    };

    const result = countOrder(input);

    expect(result).toBe(9850);
  });
});

老耿 运行了一下测试用例,显示测试经过后,说:“咱们有了单元测试后,就能够开始准备重构工做了。”

image

“咱们先把 countOrder 内部的实现提炼成新函数,命名为 getPrice,这个名字不必定是最合适的名字,可是会比以前的要好。”老耿使用 Ide 很容易就把这一步完成了。

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

function countOrder(order) {
    return getPrice(order);
}

const orderPrice = countOrder(order);

“这一步看起来没什么问题,可是咱们仍是先 运行一下测试用例。”老耿按下了执行用例快捷键,用例跑了起来,而且很快就经过了测试。

image

“这一步说明咱们的修改没有问题,下一步咱们修改测试用例,将测试用例调用的 countOrder 方法都修改成调用 getPrice 方法,再次 运行修改后的测试用例。”

image

老耿指着修改后的测试用例:“再次运行后,getPrice 也经过了测试,那接下来,咱们就能够把调用 countOrder 方法的地方,都修改成调用 getPrice 方法,就像这样。”

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

function countOrder(order) {
    return getPrice(order);
}

const orderPrice = getPrice(order);

“这时候咱们能够看到,编辑器已经提示咱们,原来的 countOrder 方法没有被使用到,咱们能够借助 Ide 直接把这个函数删除掉。”

image

“删除后,咱们的重构就完成了,新的代码看起来像这样。”

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

const orderPrice = getPrice(order);

“嗯,这个命名看起来更合适了,一眼看过去就能够知道这是个获取订单价格的函数。若是哪天又想到了更好的名字,用一样的步骤把它替换掉就行了,有单元测试就能够保证你在操做的过程当中并不会出错,引起其余的问题。”

“对了,还有最重要的一步。”老耿加剧了语气:“记得提交代码!”

老耿用快捷键提交了一个 Commit 记录,继续说道:“每一次重构完成都应该提交代码,这样就能够在下一次重构出现问题的时候,迅速回退到上一次正常工做时的状态,这一点颇有用!“

大宝补充到:“这样的重构还有个好处,那就是能够保证代码随时都是可发布的状态,由于并无影响到总体功能的运行。”

“不过想一个合适的好名字确实不容易,耿哥的意思是让咱们要持续的小步的进行重构吧。”小王摸着下巴思考说道。

“阿宝和小王说的都对,在不改变软件可观察行为的前提下,持续小步的重构,保证软件随时都处于可发布的状态。这意味着咱们随时均可以进行重构,最简单的重构,好比我刚才演示的那种用不了几分钟,而最长的重构也不应超过几小时。”

“我再补充一点。”大宝说道:“咱们绝对不能忽视自动化测试,只有自动化测试才能保证在重构的过程当中不改变软件可观察行为,这一点看似不起眼,倒是最最重要的关键之处。”

“阿宝说的没错,咱们至少要保证咱们重构的地方有单元测试,且能经过单元测试,才能算做是重构完成。”

老耿稍做停顿后,等待你们理解本身刚才的那段话后,接着说:“看来你们都开始感觉到了重构的魅力,咱们最后看看这段代码重构先后的对比。”

image

“ok,那咱们接着说剩下的坏味道吧。”

重复代码(Repeat Code)

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

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

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

“嗯,这段代乍一看是没有什么问题的,应该是用来渲染我的资料界面的。可是咱们仔细看的话,会发现 renderPerson 方法和 photoDiv 中有一个一样的实现,那就是渲染 photo.title 的部分。这一部分的逻辑老是在执行 emitPhotoData 函数的前面,这是一段重复代码。”

“虽然这是一段看似无伤大雅的重复代码,可是要记住,一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差别。若是要修改重复代码,你必须找出全部的副原本修改,这一点让人在阅读和修改代码时都很容易出现纰漏。”

“因此,咱们就挑这一段代码来进行重构。按照惯例,我先写两个单元测试用例。”老耿开始写用例。

describe('test render', () => {
  test('renderPerson should return correct struct when input correct struct', () => {
    const input = {
      name: 'jack',
      photo: {
        title: 'travel',
        location: 'tokyo',
        date: '2021-06-08'
      }
    };

    const result = renderPerson(input);

    expect(result).toBe(`<p>jack</p>\n<p>title: travel</p>\n<p>location: tokyo</p>\n<p>date: 2021-06-08</p>`);
  });

  test('photoDiv should return correct struct when input correct struct', () => {
    const input = {
      title: 'adventure',
      location: 'india',
      date: '2021-01-08'
    };

    const result = photoDiv(input);

    expect(result).toBe(`<div>\n<p>title: adventure</p>\n<p>location: india</p>\n<p>date: 2021-01-08</p>\n</div>`);
  });
});

“咱们先运行测试一下咱们的测试用例是否能经过吧。“老耿按下了执行快捷键。

image

“ok,测试经过,记得提交一个 Commit,保存咱们的测试代码。接下来,咱们准备开始重构,这个函数比较简单,咱们能够直接把那一行重复的代码移动到 emitPhotoData 函数中。可是此次咱们仍是要演示一下风险较低的一种重构手法,防止出错。“老耿说完,把 emitPhotoDataNew ctrl c + ctrl v,在复制的函数体内稍做修改,完成了组装。

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

“而后,咱们把 renderPersonphotoDiv 内部调用的方法,都换成 emitPhotoDataNew 新方法,若是再稳妥一点的话,最好是换一个函数执行一次测试用例。”

function renderPerson(person) {
  const result = [];
  result.push(`<p>${person.name}</p>`);
  result.push(emitPhotoDataNew(person.photo));
  return result.join('\n');
}

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

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

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

“替换完成后,执行测试用例,看看效果。”

image

“ok,测试经过,说明重构并无产生什么问题,接下来把原来的 emitPhotoData 安全删除,而后把 emitPhotoDataNew 重命名为 emitPhotoData,重构就完成了!”

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

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

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

“修改完后,别忘了运行测试用例。”老耿每次修改完成后运行测试用例的动做,彷佛已经造成了肌肉记忆。

image

“ok,测试经过。此次重构完成了,提交一个 Commit,再看一下修改先后的对比。”

image

“咱们继续看下一个坏味道。”

过长函数(Long Function)

function printOwing(invoice) {
  let outstanding = 0;
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
  // calculate outstanding
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
  //print details
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

“嗯,这个函数看起来是用来打印用户白条信息的。这个函数实现细节和命名方面却是没有太多问题,可是这里有一个坏味道,那就是 —— 过长的函数。”

“函数越长,就越难理解。而更好的阐释力、更易于分享、更多的选择——都是由小函数来支持的。”

“对于识别这种坏味道,有一个技巧。那就是,若是你须要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所作的事为其命名。”

“像这个函数就是那种须要花必定时间浏览才能弄清楚在作什么的,不过好在这个函数还有些注释。”

“仍是老方法,咱们先写两个测试用例。”老耿开始敲代码。

describe('test printOwing', () => {
  let collections = [];
  console.log = message => {
    collections.push(message);
  };

  afterEach(() => {
    collections = [];
  });

  test('printOwing should return correct struct when input correct struct', () => {
    const input = {
      customer: 'jack',
      orders: [{ amount: 102 }, { amount: 82 }, { amount: 87 }, { amount: 128 }]
    };

    printOwing(input);

    expect(collections).toStrictEqual([
      '***********************',
      '**** Customer Owes ****',
      '***********************',
      'name: jack',
      'amount: 399',
      'due: 7/8/2021'
    ]);
  });

  test('printOwing should return correct struct when input correct struct 2', () => {
    const input = {
      customer: 'dove',
      orders: [{ amount: 63 }, { amount: 234 }, { amount: 12 }, { amount: 1351 }]
    };

    printOwing(input);

    expect(collections).toStrictEqual([
      '***********************',
      '**** Customer Owes ****',
      '***********************',
      'name: dove',
      'amount: 1660',
      'due: 7/8/2021'
    ]);
  });
});

“测试用例写完之后运行一下。“

image

“接下来的提取步骤就很简单了,由于代码自己是有注释的,咱们只须要参考注释的节奏来进行提取就行了,依旧是小步慢跑,首先调整函数执行的顺序,将函数分层。”

function printOwing(invoice) {
  // print banner
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');

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

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

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

“进行函数分层后,先运行一遍测试用例,防止调整顺序的过程当中,影响了函数功能... ok,测试经过了。”

“被调整顺序后的函数就变得很是简单了,接下来咱们分四步提取就能够了”

“第一步,提炼 printBanner 函数,而后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function printOwing(invoice) {
  printBanner();

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

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

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

“咱们在提取的过程当中,把注释也去掉了,由于确实不须要了,函数名和注释的内容同样。”

“第二步,提炼 calOutstanding 函数 ,而后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function printOwing(invoice) {
  printBanner();

  let outstanding = calOutstanding(invoice);

  // record due date
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);

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

“第三步,提炼 recordDueDate 函数,而后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function recordDueDate(invoice) {
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}

function printOwing(invoice) {
  printBanner();

  let outstanding = calOutstanding(invoice);

  recordDueDate(invoice);

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

“第四步,提炼 printDetails 函数,而后运行测试用例。”

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calOutstanding(invoice) {
  let outstanding = 0;
  for (const o of invoice.orders) {
    outstanding += o.amount;
  }
  return outstanding;
}

function recordDueDate(invoice) {
  const today = new Date(Date.now());
  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
}

function printDetails(invoice, outstanding) {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
  console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}

function printOwing(invoice) {
  printBanner();
  let outstanding = calOutstanding(invoice);
  recordDueDate(invoice);
  printDetails(invoice, outstanding);
}

image

“测试用例经过后,别忘了提交代码。”

“而后咱们再来审视一下这个重构后的 printOwing 函数,简单的四行代码,清晰的描述了函数所作的事情,这就是小函数的魅力!”

“说到底,让小函数易于理解的关键仍是在于良好的命名。若是你能给函数起个好名字,阅读代码的人就能够经过名字了解函数的做用,根本没必要去看其中写了些什么。这能够节约大量的时间,也能减小大家结对编程的时间。”老耿面带微笑看着小李和小王。

小李小王相视一笑,以为有点很差意思。

“咱们来看看重构先后的对比。”

image

“咱们继续。”老耿快马加鞭。

过长参数列表(Long Parameter List)

// range.js
function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products
      .filter(r => r.price < min || r.price > max);
  } else {
    return products
      .filter(r => r.price > min && r.price < max);
  }
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceRange(
  [ /* ... */ ],
  range.min,
  range.max,
  true
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceRange(
  [ /* ... */ ],
  range.min,
  range.max,
  false
)

“第一眼看过去,priceRange 是过滤商品的函数,仔细看的话会发现,主要比对的是 product.price 字段和传入的参数 minmax 之间的大小对比关系。若是 isOutSidetrue 的话,则过滤出价格区间以外的商品,不然过滤出价格区间以内的商品。”

“第一眼看过去,这个函数的参数实在是太多了,这会让客户端调用方感到很疑惑。还好参数 isOutSide 的命名还算不错,否则这个函数会看起来更深奥。”

小李忍不住插了句:“我每次调用 priceRange 函数的时候都要去看一眼这个函数的实现,我总是忘记最后一个参数的规则。”

“我和小李的见解是同样的。”老耿点了点头:“我也不喜欢标记参数,由于它们让人难以理解到底有哪些函数能够调用、应该怎么调用。使用这样的函数,我还得弄清标记参数有哪些可用的值。”

“既然小李也已经发现这个问题了,那咱们就从这个 isOutSide 参数下手,进行优化。老规矩,咱们先针对现有的代码写两个测试用例。” 老耿开始写代码。

describe('test priceRange', () => {
  test('priceRange should return correct result when input correct outside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 1, max: 10 };
    const isOutSide = true;

    const result = priceRange(products, range.min, range.max, isOutSide);

    expect(result).toStrictEqual([
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ]);
  });

  test('priceRange should return correct result when input correct inside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 5, max: 8 };
    const isOutSide = false;

    const result = priceRange(products, range.min, range.max, isOutSide);

    expect(result).toStrictEqual([
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 }
    ]);
  });
});

“运行一下单元测试...嗯,是能够经过的。那接下来就能够进行参数精简了,咱们先把刚才小李提的那个问题解决,就是标记参数,咱们针对 priceRange 再提炼两个函数。”

“咱们先修改咱们的单元测试代码,按咱们指望调用的方式修改。”

const priceRange = require('./long_parameter_list');

describe('test priceRange', () => {
  test('priceOutSideRange should return correct result when input correct outside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 1, max: 10 };

    const result = priceOutSideRange(products, range.min, range.max);

    expect(result).toStrictEqual([
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ]);
  });

  test('priceInsideRange should return correct result when input correct inside conditional', () => {
    const products = [
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 },
      { name: 'orange', price: 15 },
      { name: 'cookie', price: 0.5 }
    ];
    const range = { min: 5, max: 8 };

    const result = priceInsideRange(products, range.min, range.max);

    expect(result).toStrictEqual([
      { name: 'apple', price: 6 },
      { name: 'banana', price: 7 }
    ]);
  });
});

“我把 priceRangeisOutSide 标记参数移除了,而且使用 priceOutsideRangepriceInsideRange 两个方法来实现原有的功能。这时候还不能运行测试用例,由于咱们的代码还没改呢。一样的,把代码调整成符合用例调用的方式。”

function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products.filter(r => r.price < min || r.price > max);
  } else {
    return products.filter(r => r.price > min && r.price < max);
  }
}

function priceOutSideRange(products, min, max) {
  return priceRange(products, min, max, true);
}

function priceInsideRange(products, min, max) {
  return priceRange(products, min, max, false);
}

“代码调整完成后,咱们来运行一下测试用例。好的,经过了!”

image

“嗯,我想到这里之后,能够更进一步,把 priceRange 的函数进一步抽离,就像这样。”

function priceRange(products, min, max, isOutSide) {
  if (isOutSide) {
    return products.filter(r => r.price < min || r.price > max);
  } else {
    return products.filter(r => r.price > min && r.price < max);
  }
}

function priceOutSideRange(products, min, max) {
  return products.filter(r => r.price < min || r.price > max);
}

function priceInsideRange(products, min, max) {
  return products.filter(r => r.price > min && r.price < max);
}

“拆解完成后,记得运行一下测试用例... ok,经过了”

“在测试用例经过后,就能够开始准备迁移工做了。把原来调用 priceRange 的地方替换成新的调用,而后再把 priceRange 函数安全删除,就像这样。”

// range.js
function priceOutSideRange(products, min, max) {
  return products.filter(r => r.price < min || r.price > max);
}

function priceInsideRange(products, min, max) {
  return products.filter(r => r.price > min && r.price < max);
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  range.min,
  range.max
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  range.min,
  range.max
)

“这么作之后,原来让人疑惑的标记参数就被移除了,取而代之的是两个语义更加清晰的函数。”

“接下来,咱们要继续作一件有价值的重构,那就是将数据组织成结构,由于这样让数据项之间的关系变得明晰。好比 rangeminmax 老是在调用中被一块儿使用,那这两个参数就能够组织成结构。我先修改个人测试用例以适应最新的改动,就像这样。”

//...
const range = { min: 1, max: 10 };

const result = priceOutSideRange(products, range);

expect(result).toStrictEqual([
  { name: 'orange', price: 15 },
  { name: 'cookie', price: 0.5 }
]);

//...
const range = { min: 5, max: 8 };

const result = priceInsideRange(products, range);

expect(result).toStrictEqual([
  { name: 'apple', price: 6 },
  { name: 'banana', price: 7 }
]);

“测试用例修改完成后,来修改一下咱们的函数。”

// range.js
function priceOutSideRange(products, range) {
  return products.filter(r => r.price < range.min || r.price > range.max);
}

function priceInsideRange(products, range) {
  return products.filter(r => r.price > range.min && r.price < range.max);
}

// a.js
const range = { min: 1, max: 10 }
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  range
)

// b.js
const range = { min: 5, max: 8 }
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  range
)

“修改完成后,运行咱们的测试用例,顺利经过,别忘了提交代码。”说完,老耿打了个 Commit

image

“这一步重构又精简了一个参数,这是这项重构最直接的价值。而这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,我就能够重组程序的行为来使用这些结构。这句话实际应用起来是什么意思呢?我仍是拿这个案例来举例。”

“咱们会发现 priceOutSideRangepriceInsideRange 的函数命名已经足够清晰,可是内部对 range 范围的断定仍是须要花费必定时间理解,而 range 做为咱们刚识别出来的一种结构,能够继续进行重构,就像这样。”

// range.js
class Range {
  constructor(min, max) {
    this._min = min;
    this._max = max;
  }

  outside(num) {
    return num < this._min || num > this._max;
  }

  inside(num) {
    return num > this._min && num < this._max;
  }
}

function priceOutSideRange(products, range) {
  return products.filter(r => range.outside(r.price));
}

function priceInsideRange(products, range) {
  return products.filter(r => range.inside(r.price));
}

// a.js
const outSidePriceProducts = priceOutSideRange(
  [ /* ... */ ],
  new Range(1, 10)
)

// b.js
const insidePriceProducts = priceInsideRange(
  [ /* ... */ ],
  new Range(5, 8)
)

“修改测试用例也传入 Range 对象,而后运行测试用例...ok,经过了。测试经过后再提交代码。”

“这样一来,让 priceOutSideRangepriceInsideRange 函数内部也更加清晰了。同时,range 被组织成了一种新的数据结构,这种结构能够在任何计算区间的地方使用。”

“咱们来看看重构先后的对比。”

image

“咱们继续。”

全局数据(Global Data)

// global.js
// ...
let userAuthInfo = {
  platform: 'pc',
  token: ''
}

export {
  userAuthInfo
};

// main.js
userAuthInfo.token = localStorage.token;

// request.js
const reply = await login();
userAuthInfo.token = reply.data.token;

// business.js
await request({ authInfo: userAuthInfo });

“这个 global.js 彷佛是用来提供全局数据的,这是最刺鼻的坏味道之一了。”

“这个 platform 被全局都使用到了,我能够把它修改成别的值吗?会引起什么问题吗?”老耿问道

小李连忙说:“这个 platform 不能改,后端要靠这个字段来选择识别 token 的方式,改了就会出问题。”

“可是我如今能够在代码库的任何一个角落均可以修改 platformtoken,并且没有任何机制能够探测出到底哪段代码作出了修改,这就是全局数据的问题。”

“每当咱们看到可能被各处的代码污染的数据,咱们仍是须要全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问,这里我作个简单的封装,而后再写两个测试用例。”

let userAuthInfo = {
  platform: 'pc',
  token: ''
};

function getUserAuthInfo() {
  return { ...userAuthInfo };
}

function setToken(token) {
  userAuthInfo.token = token;
}

export {
  getUserAuthInfo,
  setToken
}

// main.js
setToken(localStorage.token);

// request.js
const reply = await login();
setToken(reply.data.token);

// business.js
await request({ authInfo: getUserAuthInfo() });

“接下来运行一下测试用例。”

describe("test global data", () => {
    test('getUserAuthInfo.platform should return pc when modify reference', () => {
        const userAuthInfo = getUserAuthInfo();
        userAuthInfo.platform = 'app';

        const result = getUserAuthInfo().platform;

        expect(result).toBe('pc');
    });

    test('getUserInfo.token should return test-token when setToken test-token', () => {
       setToken('test-token');

       const result = getUserAuthInfo().token;

       expect(result).toBe('test-token');
    });
});

image

“这样一来,经过对象引用就没法修改源对象了,而且我这里控制了对 platform 属性的修改,只开放对 token 修改的接口。即使如此,咱们仍是要尽量的避免全局数据,由于全局数据是最刺鼻的坏味道之一!”老耿语气加剧。

小李小王疯狂点头。

“咱们来看一下重构先后的对比。”

image

“那咱们继续。”

可变数据(Mutable Data)

function merge(target, source) {
  for (const key in source) {
    target[key] = source[key];
  }
  return target;
}

“这个函数好像有点古老。”老耿有些疑惑。

“这个是我从以前的仓库 copy 过来的一个工具函数,用来合成对象的,一直没改过。”小王补充道。

“嗯,这个函数的问题是对 merge 对象的源对象 target 进行了修改,对数据的修改常常致使出乎意料的结果和难以发现的 bug。如今来看程序并无由于这个函数出现问题,但若是故障只在很罕见的状况下发生,要找出故障缘由就会更加困难。”

“先写两个测试用例来进行验证吧。”老耿开始写代码。

describe('test merge', () => {
  test('test merge should return correct struct when merge', () => {
    const baseConfig = {
      url: 'https://api.com',
      code: 'mall'
    };

    const testSpecialConfig = {
      url: 'https://test-api.com',
      code: 'test-mall'
    };

    const result = merge(baseConfig, testSpecialConfig);

    expect(result).toStrictEqual({
      url: 'https://test-api.com',
      code: 'test-mall'
    });
  });

  test('test merge should return original struct when merge', () => {
    const baseConfig = {
      url: 'https://api.com',
      code: 'mall'
    };

    const testSpecialConfig = {
      url: 'https://test-api.com',
      code: 'test-mall'
    };

    merge(baseConfig, testSpecialConfig);

    expect(baseConfig).toStrictEqual({
      url: 'https://api.com',
      code: 'mall'
    });
  });
});

“运行一下... 第二个用例报错了。”

image

“报错的缘由就是由于对源对象进行了修改调整,从而影响了 baseConfig 的值。接下来咱们调整一下 merge 函数就好了,如今 javascript 有很简单的方法能够修改这个函数。”

function merge(target, source) {
  return {
    ...target,
    ...source
  }
}

“修改完成后,再次运行用例,就能够看到用例运行经过了。”

image

“我刚才的重构手法其实有一整个软件开发流派 —— 函数式编程,就是彻底创建在“数据永不改变”的概念基础上:若是要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变,这样能够避免不少因数据变化而引起的问题。”

“在刚才介绍全局数据时用到的封装变量的方法,也是对可变数据这种坏味道常见的一种解决方案。还有,若是可变数据的值能在其余地方计算出来,这就是一个特别刺鼻的坏味道。它不只会形成困扰、bug和加班,并且毫无必要。”

“这里我就不作展开了,若是大家俩感兴趣的话,能够去看看《重构:改善既有代码的设计(第2版)》这本书,我刚才提到的坏味道,书里面都有。”

小李小王奋笔疾书,把书名记了下来。

“咱们来看一下重构先后的对比。”

image

“那咱们继续。”

发散式变化(Divergent Change)

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

const orderPrice = getPrice(order);

“这个函数是咱们最先重构的函数,它的职责就是计算基础价格 - 数量折扣 + 运费。咱们再来看看这个函数,若是基础价格计算规则改变,须要修改这个函数,若是折扣规则发生改变也须要修改这个函数,同理,运费计算规则也会引起它的改变。”

“若是某个模块常常由于不一样的缘由在不一样的方向上发生变化,发散式变化就出现了。”

“测试用例已经有了,因此咱们能够直接对函数进行重构。”老耿开始写代码。

function calBasePrice(order) {
    return order.quantity * order.itemPrice;
}

function calDiscount(order) {
    return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
}

function calShipping(basePrice) {
    return Math.min(basePrice * 0.1, 100);
}

function getPrice(order) {
    return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order));
}

const orderPrice = getPrice(order);

“修改完成后,咱们运行以前写的测试用例... 测试经过了。”

image

“修改以后的三个函数只须要关心本身职责方向的变化就能够了,而不是一个函数关注多个方向的变化。而且,单元测试粒度还能够写的更细一点,这样对排查问题的效率也有很大的提高。”

大宝适时补充了一句:“其实这就是面向对象设计原则中的 单一职责原则。”

“阿宝说的没错,keep simple,每次只关心一个上下文 这一点一直很重要。”

“咱们来看一下重构先后的对比。”

image

“咱们继续。”

霰弹式修改(Shotgun Surgery)

// File Reading.js
const reading = {customer: "ivan", quantity: 10, month: 5, year: 2017};
function acquireReading() { return reading };
function baseRate(month, year) {
    /* */
}

// File 1
const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

// File 2
const aReading = acquireReading();
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity);
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
function taxThreshold(year) { /* */ }

// File 3
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

“接下来要说的就是 发散式变化 的反例 —— 霰弹式修改。这个要找一个软件里的案例须要花点时间,这里我直接拿《重构》原书的一个例子来做讲解。”

“其实这个问题和重复代码有点像,重复代码常常会引发霰弹式修改的问题。”

“像上面的演示代码,若是 reading 的部分逻辑发生了改变,对这部分逻辑的修改须要跨越好几个文件调整。”

“若是每遇到某种变化,你都必须在许多不一样的类或文件内作出许多小修改,你所面临的坏味道就是霰弹式修改。若是须要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。”

“这里我来对这段代码进行重构,因为源代码也不是很完整,这里我只把修改的思路提一下,就不写测试代码了。”老耿开始写代码。

// File Reading.js
class Reading {
  constructor(data) {
    this._customer = data.customer;
    this._quantity = data.quantity;
    this._month = data.month;
    this._year = data.year;
  }

  get customer() {
    return this._customer;
  }

  get quantity() {
    return this._quantity;
  }

  get month() {
    return this._month;
  }

  get year() {
    return this._year;
  }

  get baseRate() {
    /* ... */
  }

  get baseCharge() {
    return baseRate(this.month, this.year) * this.quantity;
  }

  get taxableCharge() {
    return Math.max(0, base - taxThreshold());
  }

  get taxThreshold() {
    /* ... */
  }
}

const reading = new Reading({ customer: 'ivan', quantity: 10, month: 5, year: 2017 });

“在修改完成后,全部和 reading 相关的逻辑都放在一块儿管理了,而且我把它组合成一个类之后还有一个好处。那就是类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数能够少传许多参数,从而简化函数调用,而且这样一个对象也能够更方便地传递给系统的其余部分。”

“若是大家在编码过程当中,有遇到我刚才提到的那些问题,那就是一种坏味道。下次就能够用相似的重构手法进行重构了,固然,别忘了写测试用例。”老耿对着小李二人说道。

小李小王疯狂点头。

“咱们来看一下重构先后的对比。”

image

“那咱们继续。”

依恋情节(Feature Envy)

class Account {
  constructor(data) {
    this._name = data.name;
    this._type = data.type;
  }

  get loanAmount() {
    if (this._type.type === 'vip') {
      return 20000;
    } else {
      return 10000;
    }
  }
}

class AccountType {
  constructor(type) {
    this._type = type;
  }

  get type() {
    return this._type;
  }
}

“这段代码是帐户 Account 和帐户类型 AccountType,若是帐户的类型是 vip,贷款额度 loanAmount 就有 20000,不然就只有 10000。”

“在获取贷款额度时,Account 内部的 loanAmount 方法和另外一个类 AccountType 的内部数据交流格外频繁,远胜于在本身所处模块内部的交流,这就是依恋情结的典型状况。”

“咱们先写两个测试用例吧。”老耿开始写代码。

describe('test Account', () => {
  test('Account should return 20000 when input vip type', () => {
    const input = {
      name: 'jack',
      type: new AccountType('vip')
    };

    const result = new Account(input).loanAmount;

    expect(result).toBe(20000);
  });

  test('Account should return 20000 when input normal type', () => {
    const input = {
      name: 'dove',
      type: new AccountType('normal')
    };

    const result = new Account(input).loanAmount;

    expect(result).toBe(10000);
  });
});

“测试用例能够直接运行... ok,经过了。”

“接下来,咱们把 loanAmount 搬移到真正属于它的地方。”

class Account {
  constructor(data) {
    this._name = data.name;
    this._type = data.type;
  }

  get loanAmount() {
    return this._type.loanAmount;
  }
}

class AccountType {
  constructor(type) {
    this._type = type;
  }

  get type() {
    return this._type;
  }

  get loanAmount() {
    if (this.type === 'vip') {
      return 20000;
    } else {
      return 10000;
    }
  }
}

“在搬移完成后,loanAmount 访问的都是自身模块的数据,再也不依恋其余模块。咱们运行一下测试用例。”

image

“ok,测试经过了,别忘了提交代码。”老耿提交了一个 commit。

“咱们来看一下重构先后的对比。”

image

“咱们继续下一个。”

数据泥团(Data Clumps)

class Person {
  constructor(name) {
    this._name = name;
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get telephoneNumber() {
    return `(${this.officeAreaCode}) ${this.officeNumber}`;
  }

  get officeAreaCode() {
    return this._officeAreaCode;
  }

  set officeAreaCode(arg) {
    this._officeAreaCode = arg;
  }

  get officeNumber() {
    return this._officeNumber;
  }

  set officeNumber(arg) {
    this._officeNumber = arg;
  }
}

const person = new Person('jack');
person.officeAreaCode = '+86';
person.officeNumber = 18726182811;
console.log(`person's name is ${person.name}, telephoneNumber is ${person.telephoneNumber}`);
// person's name is jack, telephoneNumber is (+86) 18726182811

“这个 Person 类记录了用户的名字(name),电话区号(officeAreaCode)和电话号码(officeNumber),这里有一个不是很刺鼻的坏味道。”

“若是我把 officeNumber 字段删除,那 officeAreaCode 就失去了意义。这说明这两个字段老是一块儿出现的,除了 Person 类,其余用到电话号码的地方也是会出现这两个字段的组合。”

“这个坏味道叫作数据泥团,主要体如今数据项喜欢三五成群地待在一起。你经常能够在不少地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数,这些老是绑在一块儿出现的数据真应该拥有属于它们本身的对象。”

“老规矩,咱们先写两个测试用例。”老耿开始写代码

describe('test Person', () => {
  test('person.telephoneNumber should return (+86) 18726182811 when input correct struct', () => {
    const person = new Person('jack');
    person.officeAreaCode = '+86';
    person.officeNumber = 18726182811;

    const result = person.telephoneNumber;

    expect(person.officeAreaCode).toBe('+86');
    expect(person.officeNumber).toBe(18726182811);
    expect(result).toBe('(+86) 18726182811');
  });

  test('person.telephoneNumber should return (+51) 15471727172 when input correct struct', () => {
    const person = new Person('jack');
    person.officeAreaCode = '+51';
    person.officeNumber = 15471727172;

    const result = person.telephoneNumber;

    expect(person.officeAreaCode).toBe('+51');
    expect(person.officeNumber).toBe(15471727172);
    expect(result).toBe('(+51) 15471727172');
  });
});

“运行一下测试用例... ok,测试经过了,准备开始重构了。”

“咱们先新建一个 TelephoneNumber 类,用于分解 Person 类所承担的责任。”

class TelephoneNumber {
  constructor(areaCode, number) {
    this._areaCode = areaCode;
    this._number = number;
  }

  get areaCode() {
    return this._areaCode;
  }

  get number() {
    return this._number;
  }

  toString() {
    return `(${this._areaCode}) ${this._number}`;
  }
}

“这时候,咱们再调整一下咱们的 Person 类,使用新的数据结构。”

class Person {
  constructor(name) {
    this._name = name;
    this._telephoneNumber = new TelephoneNumber();
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get telephoneNumber() {
    return this._telephoneNumber.toString();
  }

  get officeAreaCode() {
    return this._telephoneNumber.areaCode;
  }

  set officeAreaCode(arg) {
    this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
  }

  get officeNumber() {
    return this._telephoneNumber.number;
  }

  set officeNumber(arg) {
    this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg);
  }
}

“重构完成,咱们运行测试代码。”

image

“测试用例运行经过了,别忘了提交代码。”

“在这里我选择新建一个类,而不是简单的记录结构,是由于一旦拥有新的类,你就有机会让程序散发出一种芳香。获得新的类之后,你就能够着手寻找其余坏味道,例如“依恋情节”,这能够帮你指出可以移至新类中的种种行为。这是一种强大的动力:有用的类被建立出来,大量的重复被消除,后续开发得以加速,原来的数据泥团终于在它们的小社会中充分发挥价值。”

“好比这里,TelephoneNumber 类被提炼出来后,就能够去消灭那些使用到 telephoneNumber 的重复代码,而且根据使用状况进一步优化,我就不作展开了。”

“咱们来看一下重构先后的对比。”

image

“咱们继续讲下一个坏味道。”

基本类型偏执(Primitive Obsession)

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = data.price;
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return `${this.priceCount} ${this.priceSuffix}`;
  }

  get priceCount() {
    return parseFloat(this._price.slice(1));
  }

  get priceUnit() {
    switch (this._price.slice(0, 1)) {
      case '¥':
        return 'cny';
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get priceCnyCount() {
    switch (this.priceUnit) {
      case 'cny':
        return this.priceCount;
      case 'usd':
        return this.priceCount * 7;
      case 'hkd':
        return this.priceCount * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get priceSuffix() {
    switch (this.priceUnit) {
      case 'cny':
        return '元';
      case 'usd':
        return '美圆';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}

“咱们来看看这个 Product(产品)类,你们应该也看出来了这个类的一些坏味道,price 字段做为一个基本类型,在 Product 类中被各类转换计算,而后输出不一样的格式,Product 类须要关心 price 的每个细节。”

“在这里,price 很是值得咱们为它建立一个属于它本身的基本类型 - Price。”

“在重构以前,先把测试用例覆盖完整。”老耿开始写代码。

describe('test Product price', () => {
  const products = [
    { name: 'apple', price: '$6' },
    { name: 'banana', price: '¥7' },
    { name: 'orange', price: 'k15' },
    { name: 'cookie', price: '$0.5' }
  ];

  test('Product.price should return correct price when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price);

    expect(result).toStrictEqual(['6 美圆', '7 元', '15 港币', '0.5 美圆']);
  });

  test('Product.price should return correct priceCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceCount);

    expect(result).toStrictEqual([6, 7, 15, 0.5]);
  });

  test('Product.price should return correct priceUnit when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceUnit);

    expect(result).toStrictEqual(['usd', 'cny', 'hkd', 'usd']);
  });

  test('Product.price should return correct priceCnyCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceCnyCount);

    expect(result).toStrictEqual([42, 7, 12, 3.5]);
  });

  test('Product.price should return correct priceSuffix when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).priceSuffix);

    expect(result).toStrictEqual(['美圆', '元', '港币', '美圆']);
  });
});

“测试用例写完之后运行一下,看看效果。”

image

“这个重构手法也比较简单,先新建一个 Price 类,先把 price 和相关的行为搬移到 Price 类中,而后再委托给 Product 类便可。咱们先来实现 Price 类。”

class Price {
  constructor(value) {
    this._value = value;
  }

  toString() {
    return `${this.count} ${this.suffix}`;
  }

  get count() {
    return parseFloat(this._value.slice(1));
  }

  get unit() {
    switch (this._value.slice(0, 1)) {
      case '¥':
        return 'cny';
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get cnyCount() {
    switch (this.unit) {
      case 'cny':
        return this.count;
      case 'usd':
        return this.count * 7;
      case 'hkd':
        return this.count * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get suffix() {
    switch (this.unit) {
      case 'cny':
        return '元';
      case 'usd':
        return '美圆';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}

“此时,Product 类我尚未修改,可是若是你以为你搬移函数的过程当中容易手抖不放心的话,能够运行一下测试用例。”

“接下来是重构 Product 类,将原有跟 price 相关的逻辑,使用中间人委托来调用。”

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = new Price(data.price);
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return this._price.toString();
  }

  get priceCount() {
    return this._price.count;
  }

  get priceUnit() {
    return this._price.unit;
  }

  get priceCnyCount() {
    return this._price.cnyCount;
  }

  get priceSuffix() {
    return this._price.suffix;
  }
}

“重构完成后,运行测试用例。” 老耿按下运行键。

image

“测试用例运行经过了,别忘了提交代码。”

“不少人对基本类型都有一种偏心,他们广泛以为基本类型要比类简洁,可是,别让这种偏心演变成了 偏执。有些时候,咱们须要走出传统的洞窟,进入煊赫一时的对象世界。”

“这个案例演示了一种很常见的场景,相信大家之后也能够识别基本类型偏执这种坏味道了。”

小李小王疯狂点头。

“咱们来看一下重构先后的对比。”

image

“那咱们继续吧。”

重复的 switch(Repeated switch)

class Price {
  constructor(value) {
    this._value = value;
  }

  toString() {
    return `${this.count} ${this.suffix}`;
  }

  get count() {
    return parseFloat(this._value.slice(1));
  }

  get unit() {
    switch (this._value.slice(0, 1)) {
      case '¥':
        return 'cny';
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get cnyCount() {
    switch (this.unit) {
      case 'cny':
        return this.count;
      case 'usd':
        return this.count * 7;
      case 'hkd':
        return this.count * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get suffix() {
    switch (this.unit) {
      case 'cny':
        return '元';
      case 'usd':
        return '美圆';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}

“刚才咱们提炼了 Price 类后,如今发现 Price 类有个问题,大家看出来了吗?” 老耿看着小李小王。

小李摇了摇头,小王也没说话。

“重复的 switch 语句,每当看到代码里有 switch 语句时,就要提升警戒了。当看到重复的 switch 语句时,这种坏味道就冒出来了。” 老耿接着说道。

“重复的 switch 的问题在于:每当你想增长一个选择分支时,必须找到全部的 switch,并逐一更新。”

“而且这种 switch 结构是很是脆弱的,频繁的修改 switch 语句可能还可能会引起别的问题,相信大家也遇到过这种状况。”

小李此时彷佛想起了什么,补充道:“这里的 switch 语句还好,有些地方的 switch 语句写的太长了,每次理解起来也很困难,因此容易改出问题。”

“小李说的不错,那咱们如今来重构这个 Price。这里我偷个懒,测试用例接着用以前 Product 的测试用例,大家能够在实际项目中针对 Price 写用例,测试用例的粒度越小,越容易定位问题。”

“咱们先建立一个工厂函数,同时将 Product 类的实例方法也使用工厂函数建立。”老耿开始写代码。

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = createPrice(data.price);
    /* ... */
  }

  /* ... */
}

function createPrice(value) {
  return new Price(value);
}

“运行一下测试用例... ok,经过了。那咱们下一步,把 Price 做为超类,建立一个子类 CnyPrice,继承于 Price,同时修改工厂函数,在货币类型为 时,建立并返回 CnyPrice 类。”

class CnyPrice extends Price {
  constructor(props) {
    super(props);
  }
}

function createPrice(value) {
  switch (value.slice(0, 1)) {
    case '¥':
      return new CnyPrice(value);
    default:
      return new Price(value);
  }
}

“运行一下测试用例... ok,经过了。那咱们下一步,把 Price 超类中,全部关于 cny 的条件逻辑的函数,在 CnyPrice 中进行重写。”

class CnyPrice extends Price {
  constructor(props) {
    super(props);
  }

  get unit() {
    return 'cny';
  }

  get cnyCount() {
    return this.count;
  }

  get suffix() {
    return '元';
  }
}

“重写完成后,运行一下测试用例... ok,经过了,下一步再把 Price 类中,全部关于 cny 的条件分支都移除。”

class Price {
  constructor(value) {
    this._value = value;
  }

  toString() {
    return `${this.count} ${this.suffix}`;
  }

  get count() {
    return parseFloat(this._value.slice(1));
  }

  get unit() {
    switch (this._value.slice(0, 1)) {
      case '$':
        return 'usd';
      case 'k':
        return 'hkd';
      default:
        throw new Error('un support unit');
    }
  }

  get cnyCount() {
    switch (this.unit) {
      case 'usd':
        return this.count * 7;
      case 'hkd':
        return this.count * 0.8;
      default:
        throw new Error('un support unit');
    }
  }

  get suffix() {
    switch (this.unit) {
      case 'usd':
        return '美圆';
      case 'hkd':
        return '港币';
      default:
        throw new Error('un support unit');
    }
  }
}

“移除完成后,运行一下测试用例。”

image

“运行经过,接下来咱们如法炮制,把 UsdPriceHkdPrice 也建立好,最后再将超类中的条件分支逻辑相关代码都移除。” 老耿继续写代码。

class Price {
  constructor(value) {
    this._value = value;
  }

  toString() {
    return `${this.count} ${this.suffix}`;
  }

  get count() {
    return parseFloat(this._value.slice(1));
  }

  get suffix() {
    throw new Error('un support unit');
  }
}

class CnyPrice extends Price {
  constructor(props) {
    super(props);
  }

  get unit() {
    return 'cny';
  }

  get cnyCount() {
    return this.count;
  }

  get suffix() {
    return '元';
  }
}

class UsdPrice extends Price {
  constructor(props) {
    super(props);
  }

  get unit() {
    return 'usd';
  }

  get cnyCount() {
    return this.count * 7;
  }

  get suffix() {
    return '美圆';
  }
}

class HkdPrice extends Price {
  constructor(props) {
    super(props);
  }

  get unit() {
    return 'hkd';
  }

  get cnyCount() {
    return this.count * 0.8;
  }

  get suffix() {
    return '港币';
  }
}

function createPrice(value) {
  switch (value.slice(0, 1)) {
    case '¥':
      return new CnyPrice(value);
    case '$':
      return new UsdPrice(value);
    case 'k':
      return new HkdPrice(value);
    default:
      throw new Error('un support unit');
  }
}

“重构完成后,运行测试用例。”

image

“ok,运行经过,别忘了提交代码。”

“这样一来,修改对应的货币逻辑并不影响其余的货币逻辑,而且添加一种新的货币规则也不会影响到其余货币逻辑,修改和添加特性都变得简单了。”

“复杂的条件逻辑是编程中最难理解的东西之一,最好能够将条件逻辑拆分到不一样的场景,从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑自己的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。”

“就像我刚才演示的那样。”

“咱们来看一下重构先后的对比。“

image

“那咱们继续吧。”

循环语句(Loop)

function acquireCityAreaCodeData(input, country) {
  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() === country) {
      result.push({ city: record[0].trim(), phone: record[2].trim() });
    }
  }
  return result;
}

“嗯,让我看看这个函数,看名字彷佛是获取城市区号信息,我想了解一下这个函数的内部实现。嗯,它的实现,先是忽略了第一行,而后忽略了为空的字符串,而后将字符串以逗号切割,而后...”

“虽然有点绕,但花些时间仍是能看出来实现逻辑的。”

“从最先的编程语言开始,循环就一直是程序设计的核心要素。但我感受现在循环已经有点儿过期。”

“随着时代在发展,现在愈来愈多的编程语言都提供了更好的语言结构来处理迭代过程,例如 Javascript 的数组就有不少管道方法。”

“是啊,ES 都已经出到 ES12 了。”小王感慨,有点学不动了。

“哈哈,有些新特性仍是给咱们的重构工做提供了不少帮助的,我来演示一下这个案例。演示以前,仍是先补充两个测试用例。”老耿开始写代码。

describe('test acquireCityData', () => {
  test('acquireCityData should return India city when input India', () => {
    const input =
      ',,+00\nMumbai,India,+91 22\n , , \nTianjing,China,+022\n , , \nKolkata,India,+91 33\nBeijing,China,+010\nHyderabad,India,+91 40';

    const result = acquireCityData(input, 'India');

    expect(result).toStrictEqual([
      {
        city: 'Mumbai',
        phone: '+91 22'
      },
      {
        city: 'Kolkata',
        phone: '+91 33'
      },
      {
        city: 'Hyderabad',
        phone: '+91 40'
      }
    ]);
  });

  test('acquireCityData should return China city when input China', () => {
    const input =
      ',,+00\nMumbai,India,+91 22\n , , \nTianjing,China,+022\n , , \nKolkata,India,+91 33\nBeijing,China,+010\nHyderabad,India,+91 40';

    const result = acquireCityData(input, 'China');

    expect(result).toStrictEqual([
      {
        city: 'Tianjing',
        phone: '+022'
      },
      {
        city: 'Beijing',
        phone: '+010'
      }
    ]);
  });
});

“写完测试用例后,运行一下... ok,经过了。” 接下来准备重构工做。

“像这样比较复杂的函数,咱们选择一步一步拆解。首先,把忽略第一行,直接用 slice 代替。”

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

“修改完成后,运行测试用例... ok,下一步过滤为空的 line,这里能够用到 filter。”

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

“修改完成后,运行测试用例... ok,下一步是将 linesplit 切割,能够使用 map。”

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

“修改完成后,运行测试用例... ok,下一步是判断国家,能够用 filter。”

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

“修改完成后,运行测试用例... ok,最后一步是数据组装,能够使用 map。”

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

“重构完成,运行测试用例。”

image

“测试经过,重构完成了,别忘了提交代码。”

“重构完成后,再看这个函数,咱们就能够发现,管道操做能够帮助咱们更快地看清楚被处理的元素以及处理它们的动做。”

“但是。”小王举手:“在性能上,循环要比管道的性能要好吧?”

“这是个好问题,但这个问题要从三个方面来解释。”

“首先,这一部分时间会被用在两个地方,一是用来作性能优化让程序运行的更快,二是由于缺少对程序的清楚认识而花费时间。”

“那我先说一下性能优化,若是你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。若是你一视同仁地优化全部代码,90 %的优化工做都是白费劲的,由于被你优化的代码大多不多被执行。”

“第二个方面来讲,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易,由于重构后的代码让人对程序能有更清楚的认识。”

“第三个方面来讲,随着现代电脑硬件发展和浏览器技术发展,不少之前会影响性能的重构手法,例如小函数,如今都不会形成性能的影响。之前所认知的性能影响观点也须要与时俱进。”

“这里须要引入一个更高的概念,那就是使用合适的性能度量工具,真正对系统进行性能分析。哪怕你彻底了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。”

“因此,我给出的建议是:除了对性能有严格要求的实时系统,其余任何状况下“编写快速软件”的秘密就是:先写出可调优的软件,而后调优它以求得到足够的速度。短时间看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终仍是会获得好的效果。”

“咱们来看一下重构先后的对比。”

image

“那咱们继续下一个。”

冗赘的元素(Lazy Element)

function reportLines(aCustomer) {
  const lines = [];
  gatherCustomerData(lines, aCustomer);
  return lines;
}

function gatherCustomerData(out, aCustomer) {
  out.push(["name", aCustomer.name]);
  out.push(["location", aCustomer.location]);
}

“有些函数不能明确的说存在什么问题,可是能够优化。好比这个函数,能给代码增长结构,设计之初多是为了支持变化、促进复用或者哪怕只是提供更好的名字,但在这里看来真的不须要这层额外的结构。由于,它的名字就跟实现代码看起来如出一辙。”

“有些时候也并不彻底是由于过分设计,也多是由于随着重构的进行越变越小,最后只剩了一个函数。”

“这里我直接用内联函数把它优化掉。先写两个测试用例。”老耿开始写代码。

describe('test reportLines', () => {
  test('reportLines should return correct array struct when input aCustomer', () => {
    const input = {
      name: 'jack',
      location: 'tokyo'
    };

    const result = reportLines(input);

    expect(result).toStrictEqual([
      ['name', 'jack'],
      ['location', 'tokyo']
    ]);
  });

  test('reportLines should return correct array struct when input aCustomer', () => {
    const input = {
      name: 'jackli',
      location: 'us'
    };

    const result = reportLines(input);

    expect(result).toStrictEqual([
      ['name', 'jackli'],
      ['location', 'us']
    ]);
  });
});

“运行一下测试用例... ok,没有问题,那咱们开始重构吧。” 老耿开始写代码。

function reportLines(aCustomer) {
  const lines = [];
  lines.push(["name", aCustomer.name]);
  lines.push(["location", aCustomer.location]);
  return lines;
}

“ok,很简单,重构完成了,咱们运行测试用例。”

image

“用例测试经过了。若是你想再精简一点,能够再修改一下。”

function reportLines(aCustomer) {
  return [
    ['name', aCustomer.name],
    ['location', aCustomer.location]
  ];
}

“运行测试用例... 经过了,提交代码。”

“在重构的过程当中会发现愈来愈多能够重构的新结构,就像我刚才演示的那样。”

“像这类的冗赘的元素存在并无太多的帮助,因此,让它们慷慨赴义去吧。”

“咱们来看看重构先后的对比。”

image

“咱们继续。”

夸夸其谈通用性(Speculative Generality)

class TrackingInformation {
  get shippingCompany() {return this._shippingCompany;}
  set shippingCompany(arg) {this._shippingCompany = arg;}
  get trackingNumber() {return this._trackingNumber;}
  set trackingNumber(arg) {this._trackingNumber = arg;}
  get display() {
    return `${this.shippingCompany}: ${this.trackingNumber}`;
  }
}

class Shipment {
  get trackingInfo() {
    return this._trackingInformation.display;
  }
  get trackingInformation() { return this._trackingInformation; }
  set trackingInformation(aTrackingInformation) {
    this._trackingInformation = aTrackingInformation;
  }
}

“嗯... 来看看这个关于这两个物流的类,而 TrackingInformation 记录物流公司和物流单号,而 Shipment 只是使用 TrackingInformation 管理物流信息,并无其余任何额外的工做。为何用一个额外的 TrackingInformation 来管理物流信息,而不是直接用 Shipment 来管理呢?”

“由于 Shipment 可能还会有其余的职责。” 小王表示这是本身写的代码。 “因此,我使用了一个额外的类来追踪物流信息。”

“很好,单一职责原则。”

“那这个 Shipment 存在多久了,我看看代码提交记录...” 老耿看着 git 信息说道:“嗯,已经存在两年了,目前看来它尚未出现其余的职责,我要再等它几年吗?”

“这个坏味道是十分敏感的。”老耿顿了顿,接着说道:“系统里存在一些 夸夸其谈通用性 的设计,常见语句就是 咱们总有一天会用上的,并所以企图以各式各样的钩子和特殊状况来处理一些非必要的事情,这么作的结果每每形成系统更难理解和维护。“

“在重构以前,咱们先写两个测试用例吧。”老耿开始写代码。

describe('test Shipment', () => {
    test('Shipment should return correct trackingInfo when input trackingInfo', () => {
        const input = {
            shippingCompany: '顺丰',
            trackingNumber: '87349189841231'
        };

        const result = new Shipment(input.shippingCompany, input.trackingNumber).trackingInfo;

        expect(result).toBe('顺丰: 87349189841231');
    });

    test('Shipment should return correct trackingInfo when input trackingInfo', () => {
        const input = {
            shippingCompany: '中通',
            trackingNumber: '1281987291873'
        };

        const result = new Shipment(input.shippingCompany, input.trackingNumber).trackingInfo;

        expect(result).toBe('中通: 1281987291873');
    });
});

“如今还不能运行测试用例,为何呀?” 老耿自问自答:“由于这个用例运行是确定会报错的,Shipment 目前的结构根本不支持这么调用的,因此确定会出错。”

“这里我要引入一个新的概念,那就是 TDD - 测试驱动开发。”

“测试驱动开发是戴两顶帽子思考的开发方式:先戴上实现功能的帽子,在测试的辅助下,快速实现其功能;再戴上重构的帽子,在测试的保护下,经过去除冗余的代码,提升代码质量。测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。”

“这里,咱们就是先写出咱们但愿程序运行的方式,再经过测试用例去反推程序设计,在经过测试用例后,功能也算是开发完成了。”

“下面咱们进行代码重构。”老耿开始写代码。

class Shipment {
  constructor(shippingCompany, trackingNumber) {
    this._shippingCompany = shippingCompany;
    this._trackingNumber = trackingNumber;
  }

  get shippingCompany() {
    return this._shippingCompany;
  }

  set shippingCompany(arg) {
    this._shippingCompany = arg;
  }

  get trackingNumber() {
    return this._trackingNumber;
  }

  set trackingNumber(arg) {
    this._trackingNumber = arg;
  }

  get trackingInfo() {
    return `${this.shippingCompany}: ${this.trackingNumber}`;
  }
}

“我把 TrackingInformation 类彻底移除了,使用 Shipment 直接对物流信息进行管理。在重构完成后,运行测试用例。”

image

“用例运行经过了,这时候再把以前应用到 Shipment 的地方进行调整。固然,更稳妥的办法是先使用 ShipmentNew 类进行替换后,再删除原来的类。这里我仍是回退一下代码,大家俩去评估一下影响点,再本身来重构吧。” 老耿回退了代码。

小李小王疯狂点头。

“关于代码通用性设计,若是全部装置都会被用到,就值得那么作;若是用不到,就不值得。用不上的装置只会挡你的路,因此,把它搬开吧。”

“咱们来看看重构先后的对比。”

image

“咱们继续吧。”

临时字段(Temporary Field)

class Site {
  constructor(customer) {
    this._customer = customer;
  }

  get customer() {
    return this._customer;
  }
}

class Customer {
  constructor(data) {
    this._name = data.name;
    this._billingPlan = data.billingPlan;
    this._paymentHistory = data.paymentHistory;
  }

  get name() {
    return this._name;
  }
  get billingPlan() {
    return this._billingPlan;
  }
  set billingPlan(arg) {
    this._billingPlan = arg;
  }
  get paymentHistory() {
    return this._paymentHistory;
  }
}

// Client 1
{
  const aCustomer = site.customer;
  // ... lots of intervening code ...
  let customerName;
  if (aCustomer === 'unknown') customerName = 'occupant';
  else customerName = aCustomer.name;
}

// Client 2
{
  const plan = aCustomer === 'unknown' ? registry.billingPlans.basic : aCustomer.billingPlan;
}

// Client 3
{
  if (aCustomer !== 'unknown') aCustomer.billingPlan = newPlan;
}

// Client 4
{
  const weeksDelinquent = aCustomer === 'unknown' ? 0 : aCustomer.paymentHistory.weeksDelinquentInLastYear;
}

“这一段代码是,咱们的线下商城服务点,在老客户搬走新客户还没搬进来的时候,会出现暂时没有客户的状况。在每一个查询客户信息的地方,都须要判断这个服务点有没有客户,而后再根据判断来获取有效信息。”

aCustomer === 'unknown' 这是个特例状况,在这个特例状况下,就会使用到不少临时字段,或者说是特殊值字段。这种重复的判断不只会来重复代码的问题,也会很是影响核心逻辑的代码可读性,形成理解的困难。”

“这里,我要把全部的重复判断逻辑都移除掉,保持核心逻辑代码的纯粹性。而后,我要把这些临时字段收拢到一个地方,进行统一管理。咱们先写两个测试用例。”

describe('test Site', () => {
  test('Site should return correct data when input Customer', () => {
    const input = {
      name: 'jack',
      billingPlan: { num: 100, offer: 50 },
      paymentHistory: { weeksDelinquentInLastYear: 28 }
    };

    const result = new Site(new Customer(input)).customer;

    expect({
      name: result.name,
      billingPlan: result.billingPlan,
      paymentHistory: result.paymentHistory
    }).toStrictEqual(input);
  });

  test('Site should return empty data when input NullCustomer', () => {
    const input = {
      name: 'jack',
      billingPlan: { num: 100, offer: 50 },
      paymentHistory: { weeksDelinquentInLastYear: 28 }
    };

    const result = new Site(new NullCustomer(input)).customer;

    expect({
      name: result.name,
      billingPlan: result.billingPlan,
      paymentHistory: result.paymentHistory
    }).toStrictEqual({
      name: 'occupant',
      billingPlan: { num: 0, offer: 0 },
      paymentHistory: { weeksDelinquentInLastYear: 0 }
    });
  });
});

“嗯,此次又是 TDD,第一个用例是能够运行的,运行是能够经过的。”

“接下来,我按这个思路去实现 NullCustomer,这个实现起来其实很简单。”

class NullCustomer extends Customer {
  constructor(data) {
    super(data);
    this._name = 'occupant';
    this._billingPlan = { num: 0, offer: 0 };
    this._paymentHistory = {
      weeksDelinquentInLastYear: 0
    };
  }
}

“实现完成后,运行一下测试用例。”

image

“我引入了这个特例对象后,我只须要在初始化 Site 的时候判断老客户搬出新客户尚未搬进来的状况,决定初始化哪个 Customer,而不用在每一个调用的地方都判断一次,还引入那么多临时字段了。”

“若是写出来的话,就像是这样一段伪代码。”

// initial.js
const site = customer === 'unknown' ? new Site(new NullCustomer()) : new Site(new Customer(customer));

// Client 1
{
  const aCustomer = site.customer;
  // ... lots of intervening code ...
  const customerName = aCustomer.name;
}

// Client 2
{
  const plan = aCustomer.billingPlan;
}

// Client 3
{
}

// Client 4
{
  const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear;
}

“在这里我就不对大家的代码作实际修改了,大家下去之后本身调整一下吧。”

小李小王疯狂点头。

“咱们来看一下重构先后的对比。”

image

“咱们继续下一个。”

过长的消息链(Message Chains)

const result = a(b(c(1, d(f()))));

“这种坏味道我手写代码演示一下,好比向一个对象请求另外一个对象,而后再向后者请求另外一个对象,而后再请求另外一个对象……这就是消息链。在实际代码中,看到的可能就是一长串取值函数或一长串临时变量。”

“这种一长串的取值函数,能够使用的重构手法就是 提炼函数,就像这样。”

const result = goodNameFunc();

function goodNameFunc() {
  return a(b(c(1, d(f()))));
}

“再给提炼出来的函数,取一个好名字就好了。”

“还有一种状况,就是委托关系,须要隐藏委托关系。我就不作展开了,大家有兴趣的话去看一看重构那本书吧。“

“咱们来看一下重构先后的对比。”

image

咱们继续下一个。”

中间人(Middle Man)

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = createPrice(data.price);
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return this._price.toString();
  }

  get priceCount() {
    return this._price.count;
  }

  get priceUnit() {
    return this._price.unit;
  }

  get priceCnyCount() {
    return this._price.cnyCount;
  }

  get priceSuffix() {
    return this._price.suffix;
  }
}

“嗯,这个 Product + Price 又被我翻出来了,由于通过了两次重构后,它仍是存在一些坏味道。”

“如今我要访问 Product 价格相关的信息,都是直接经过 Product 访问,而 Product 负责提供 price 的不少接口。随着 Price 类的新特性愈来愈多,更多的转发函数就会令人烦躁,而如今已经有点让人烦躁了。”

“这个 Product 类已经快彻底变成一个中间人了,那我如今但愿调用方应该直接使用 Price 类。咱们先来写两个测试用例。”老耿开始写代码。

describe('test Product price', () => {
  const products = [
    { name: 'apple', price: '$6' },
    { name: 'banana', price: '¥7' },
    { name: 'orange', price: 'k15' },
    { name: 'cookie', price: '$0.5' }
  ];

  test('Product.price should return correct price when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price.toString());

    expect(result).toStrictEqual(['6 美圆', '7 元', '15 港币', '0.5 美圆']);
  });

  test('Product.price should return correct priceCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price.count);

    expect(result).toStrictEqual([6, 7, 15, 0.5]);
  });

  test('Product.price should return correct priceUnit when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price.unit);

    expect(result).toStrictEqual(['usd', 'cny', 'hkd', 'usd']);
  });

  test('Product.price should return correct priceCnyCount when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price.cnyCount);

    expect(result).toStrictEqual([42, 7, 12, 3.5]);
  });

  test('Product.price should return correct priceSuffix when input products', () => {
    const input = [...products];

    const result = input.map(item => new Product(item).price.suffix);

    expect(result).toStrictEqual(['美圆', '元', '港币', '美圆']);
  });
});

“写完的测试用例也是不能直接运行的,接下来咱们调整 Product 类,把中间人移除。”

class Product {
  constructor(data) {
    this._name = data.name;
    this._price = createPrice(data.price);
    /* ... */
  }

  get name() {
    return this.name;
  }

  /* ... */

  get price() {
    return this._price;
  }
}

“调整完成后,直接运行测试用例。”

image

“测试用例经过了,别忘了把使用到 Product 的地方都检查一遍。”

“很难说什么程度的隐藏才是合适的。可是有隐藏委托关系和删除中间人,就能够在系统运行过程当中不断进行调整。随着代码的变化,“合适的隐藏程度” 这个尺度也相应改变。”

“咱们来看看重构先后的对比。”

image

“咱们继续下一个吧。”

内幕交易(Insider Trading)

class Person {
  constructor(name) {
    this._name = name;
  }
  get name() {
    return this._name;
  }
  get department() {
    return this._department;
  }
  set department(arg) {
    this._department = arg;
  }
}

class Department {
  get code() {
    return this._code;
  }
  set code(arg) {
    this._code = arg;
  }
  get manager() {
    return this._manager;
  }
  set manager(arg) {
    this._manager = arg;
  }
}

“在这个案例里,若是要获取 Person 的部门代码 code 和部门领导 manager 都须要先获取 Person.department。这样一来,调用者须要额外了解 Department 的接口细节,若是 Department 类修改了接口,变化会波及经过 Person 对象使用它的全部客户端。”

“咱们都喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,由于这会增长模块间的耦合。在实际状况里,必定的数据交换不可避免,但我必须尽可能减小这种状况,并把这种交换都放到明面上来。”

“接下来,咱们按照咱们指望程序运行的方式,来编写两个测试用例。”

describe('test Person', () => {
   test('Person should return 88 when input Department code 88', () => {
       const inputName = 'jack'
       const inputDepartment = new Department();
       inputDepartment.code = 88;
       inputDepartment.manager = 'Tom';

       const result = new Person(inputName, inputDepartment).departmentCode;

       expect(result).toBe(88);
   });

   test('Person should return Tom when input Department manager Tom', () => {
       const inputName = 'jack'
       const inputDepartment = new Department();
       inputDepartment.code = 88;
       inputDepartment.manager = 'Tom';

       const result = new Person(inputName, inputDepartment).manager;

       expect(result).toBe('Tom');
   });
});

“在测试用例中,咱们能够直接经过 Person 获得这我的的部门代码 departmentCode 和部门领导 manager 了,那接下来,咱们把 Person 类进行重构。”

class Person {
  constructor(name, department) {
    this._name = name;
    this._department = department;
  }
  get name() {
    return this._name;
  }
  get departmentCode() {
    return this._department.code;
  }
  set departmentCode(arg) {
    this._department.code = arg;
  }
  get manager() {
    return this._department._manager;
  }
  set manager(arg) {
    this._department._manager = arg;
  }
}

“这里我直接将修改一步到位,可是大家练习的时候仍是要一小步一小步进行重构,发现问题就能够直接回退代码。”老耿语重心长的说道。

小李小王疯狂点头。

“咱们回来看代码,在代码里,我把委托关系进行了隐藏,从而客户端对 Department 类的依赖。这么一来,即便未来委托关系发生变化,变化也只会影响服务对象 - Person 类,而不会直接波及全部客户端。”

“咱们运行一下测试代码。”

image

“运行经过了,在全部代码替换完成前,能够先保留对 department 的访问,在全部代码都修改完成后,再彻底移除,提交代码。”

“咱们来看看重构先后的对比。”

image

“咱们继续下一个。”

过大的类(Large Class)

“还有一种坏味道叫作 过大的类,这里我不用举新的例子了,最先的 Product 类其实就存在这样的问题。”

“若是想利用单个类作太多事情,其内每每就会出现太多字段。一旦如此,重复代码也就接踵而至了。二来,过大的类也会形成理解的困难。过大的类和过长的函数都有相似的问题。”

“咱们在 Product 类中就发现了三个坏味道:基本类型偏执、重复的 switch、中间人。在解决这三个坏味道的过程当中,也把 过大的类 这个问题给解决了。”

“重构是持续的小步的,大家能够对 Product 类除了 price 之外的方法再进行屡次提炼,我这里就再也不演示了。”

小李小王疯狂点头。

“那咱们继续讲下一个。”

殊途同归的类(Alternative Classes with Different Interfaces)

class Employee {
  constructor(name, id, monthlyCost) {
    this._id = id;
    this._name = name;
    this._monthlyCost = monthlyCost;
  }
  get monthlyCost() {
    return this._monthlyCost;
  }
  get name() {
    return this._name;
  }
  get id() {
    return this._id;
  }
  get annualCost() {
    return this.monthlyCost * 12;
  }
}

class Department {
  constructor(name, staff) {
    this._name = name;
    this._staff = staff;
  }
  get staff() {
    return this._staff.slice();
  }
  get name() {
    return this._name;
  }
  get totalMonthlyCost() {
    return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost);
  }
  get headCount() {
    return this.staff.length;
  }
  get totalAnnualCost() {
    return this.totalMonthlyCost * 12;
  }
}

“这里有一个坏味道,和重复代码有殊途同归之妙,叫作殊途同归的类。这里我以经典的 Employee 案例来说解一下。”

“在这个案例中,Employee 类和 Department 都有 name 字段,也都有月度成本 monthlyCost 和年度成本 annualCost 的概念,能够说这两个类其实在作相似的事情。”

“咱们能够用提炼超类来组织这种殊途同归的类,来消除重复行为。”

“在此以前,根据咱们最后想要实现的效果,咱们先编写两个测试用例。”

describe('test Employee and Department', () => {
  test('Employee annualCost should return 600 when input monthlyCost 50', () => {
    const input = {
      name: 'Jack',
      id: 1,
      monthlyCost: 50
    };

    const result = new Employee(input.name, input.id, input.monthlyCost).annualCost;

    expect(result).toBe(600);
  });

  test('Department annualCost should return 888 when input different staff', () => {
    const input = {
      name: 'Dove',
      staff: [{ monthlyCost: 12 }, { monthlyCost: 41 }, { monthlyCost: 24 }, { monthlyCost: 32 }, { monthlyCost: 19 }]
    };

    const result = new Department(input.name, input.staff).annualCost;

    expect(result).toBe(1536);
  });
});

“这个测试用例如今运行也是失败的,由于咱们尚未把 Department 改造完成。接下来,咱们先把 EmployeeDepartment 相同的字段和行为提炼出来,提炼成一个超类 Party。”

class Party {
  constructor(name) {
    this._name = name;
  }

  get name() {
    return this._name;
  }

  get monthlyCost() {
    return 0;
  }

  get annualCost() {
    return this.monthlyCost * 12;
  }
}

“这两个类相同的字段有 name,还有计算年度成本 annualCost 的方式,由于使用到了 monthlyCost 字段,因此我把这个字段也提炼出来,先返回个默认值 0。”

“接下来对 Employee 类进行精简,将提炼到超类的部分进行继承。”

class Employee extends Party {
  constructor(name, id, monthlyCost) {
    super(name);
    this._id = id;
    this._monthlyCost = monthlyCost;
  }
  get monthlyCost() {
    return this._monthlyCost;
  }
  get id() {
    return this._id;
  }
}

“再接下来对 Department 类进行改造,继承 Party 类,而后进行精简。”

class Department extends Party {
  constructor(name, staff) {
    super(name);
    this._staff = staff;
  }
  get staff() {
    return this._staff.slice();
  }
  get monthlyCost() {
    return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost);
  }
  get headCount() {
    return this.staff.length;
  }
}

“这样就完成了改造,运行一下测试用例。”

image

“测试经过了。记得把其余使用到这两个类的地方改造完成后再提交代码。”

“若是看见两个殊途同归的类在作类似的事,能够利用基本的继承机制把它们的类似之处提炼到超类。”

“有不少时候,合理的继承关系是在程序演化的过程当中才浮现出来的:我发现了一些共同元素,但愿把它们抽取到一处,因而就有了继承关系。因此,先尝试用小而快的重构手法,重构后再发现新的可重构结构。”

“咱们来看一下重构先后的对比。”

image

“咱们继续下一个。”

纯数据类(Data Class)

class Category {
  constructor(data) {
    this._name = data.name;
    this._level = data.level;
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get level() {
    return this._level;
  }

  set level(arg) {
    this._level = arg;
  }
}

class Product {
  constructor(data) {
    this._name = data._name;
    this._category = data.category;
  }

  get category() {
    return `${this._category.level}.${this._category.name}`;
  }
}

Category 是个纯数据类,像这样的纯数据类,直接使用字面量对象彷佛也没什么问题。”

“可是,纯数据类经常意味着行为被放在了错误的地方。好比在 Product 有一个应该属于 Category 的行为,就是转化为字符串,若是把处理数据的行为从其余地方搬移到纯数据类里来,就能使这个纯数据类有存在的意义。”

“咱们先写两个简单的测试用例。”老耿开始写代码。

describe('test Category', () => {
  test('Product.category should return correct data when input category', () => {
    const input = {
      level: 1,
      name: '水果'
    };

    const result = new Product({ name: '苹果', category: new Category(input) }).category;

    expect(result).toBe('1.水果');
  });

  test('Product.category should return correct data when input category', () => {
    const input = {
      level: 2,
      name: '热季水果'
    };

    const result = new Product({ name: '苹果', category: new Category(input) }).category;

    expect(result).toBe('2.热季水果');
  });
});

“测试用例写完之后,运行一下... ok,经过了。接下来,咱们把本应该属于 Category 的行为,挪进来。”

class Category {
  constructor(data) {
    this._name = data.name;
    this._level = data.level;
  }

  get name() {
    return this._name;
  }

  set name(arg) {
    this._name = arg;
  }

  get level() {
    return this._level;
  }

  set level(arg) {
    this._level = arg;
  }

  toString() {
    return `${this._level}.${this._name}`;
  }
}

class Product {
  constructor(data) {
    this._name = data._name;
    this._category = data.category;
  }

  get category() {
    return this._category.toString();
  }
}

“而后咱们运行一下测试用例。”

image

“用例运行成功了,别忘了提交代码。” 老耿打了个 commit。

“咱们须要为纯数据赋予行为,或者使用纯数据类来控制数据的读写。不然的话,纯数据类并无太大存在的意义,应该做为冗赘元素被移除。”

“咱们来看一下重构先后的对比。”

image

“那咱们继续下一个。”

被拒绝的遗赠(Refuse Bequest)

class Party {
  constructor(name, staff) {
    this._name = name;
    this._staff = staff;
  }

  get staff() {
    return this._staff.slice();
  }

  get name() {
    return this._name;
  }

  get monthlyCost() {
    return 0;
  }

  get annualCost() {
    return this.monthlyCost * 12;
  }
}

class Employee extends Party {
  constructor(name, id, monthlyCost) {
    super(name);
    this._id = id;
    this._monthlyCost = monthlyCost;
  }
  get monthlyCost() {
    return this._monthlyCost;
  }
  get id() {
    return this._id;
  }
}

class Department extends Party {
  constructor(name) {
    super(name);
  }
  get monthlyCost() {
    return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost);
  }
  get headCount() {
    return this.staff.length;
  }
}

“关于这个坏味道,我想改造一下以前那个 EmployeeDepartment 的例子来进行讲解。”

“这个例子能够看到,我把 staff 字段从 Department 上移到了 Party 类,但其实 Employee 类并不关心 staff 这个字段。这就是 被拒绝的遗赠 坏味道。”

“重构手法也很简单,就是把 staff 字段下移到真正须要它的子类 Department 中就能够了,就像我刚完成提炼超类那时的样子。”

“若是超类中的某个字段或函数只与一个或少数几个子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。”

“十有八九这种坏味道很淡,须要对业务熟悉程度较高才能发现。”

“咱们来看一下重构先后的对比。”

image

“那咱们继续下一个。”

注释(Comments)

“最后,再提一点,关于 注释 的坏味道。”

“我认为,注释并非坏味道,而且属于一种好味道,可是注释的问题在于不少人是常常把它看成“除臭剂”来使用。”

“你常常会看到,一段代码有着长长的注释,而后发现,这些注释之因此存在乃是由于代码很糟糕,创造它的程序员不想管它了。”

“当你感受须要写注释时,请先尝试重构,试着让全部注释都变得多余。”

“若是你不知道该作什么,这才是注释的良好运用时机。除了用来记述未来的打算以外,注释还能够用来标记你并没有十足把握的区域。你能够在注释里写下本身“为何作某某事”。这类信息能够帮助未来的修改者,尤为是那些健忘的家伙。”

小李小王疯狂点头。

“好了,那咱们此次的特训就到此结束了,大家俩下去之后必定要多多练习,培养识别坏味道的敏感度,而后作到对坏味道的零容忍才行。”

小结

虽然标题是 24 大技巧,可是文章却介绍了 24 种代码里常见的坏味道,还有每一个坏味道对应的重构手法。

这其实有点相似于设计原则和设计模式的关系,设计原则是道,设计模式是术。

有道者术能长久,无道者术必落空,学术先需明道,方能大成,学术若不明道,终是小器。

这也是本文为何要介绍 24 种代码里的坏味道,而不是直接介绍重构手法。由于只有识别了代码中的坏味道,才能尽可能避免写出坏味道的代码,真正作到尽善尽美,保持软件健康长青。

若是发现了代码里的 坏味道,先把这片区域用 测试用例 圈起来,而后再利用 各类重构手法,在不改变软件可观察行为的前提下,调整其结构,在 经过测试 后,第一时间 提交代码,保证你的系统随时都处于 可发布 状态。

文中的老耿原型其实就是《重构:改善既有代码的设计》的做者们,小王小李指的是团队中那些常常容易把代码写的像打补丁,而后过了一段时间总是想推翻重来的编程新人们(也多是老人),而大宝则像是一名手握屠龙术却不敢直面恶龙的高级工程师。

我觉得,重构也须要勇气,开始尝试的勇气。

配套练习

我将文中全部的案例都整理到了 github 上,每一个坏味道都有一个独立的目录,每一个目录的结构看起来就像是这样。

  • xx.before.js:重构前的代码
  • xx.js:重构后的代码
  • xx.test.js:配套的测试代码

强烈建议读者们按照文章教程,自行完成一次重构练习,这样能够更好的识别坏味道和掌握重构手法。

下面是对应的连接:

最后一件事

若是您已经看到这里了,但愿您仍是点个赞再走吧~

您的点赞是对做者的最大鼓励,也可让更多人看到本篇文章!

若是以为本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!