代码中常见的 24 种坏味道及重构手法(下篇)

书接上文。

代码中常见的 24 种坏味道及重构手法(上篇)

基本类型偏执(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 种代码里的坏味道,而不是直接介绍重构手法。因为只有识别了代码中的坏味道,才能尽量避免写出坏味道的代码,真正做到尽善尽美,保持软件健康长青。

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

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

我以为,重构也需要勇气,开始尝试的勇气。

配套练习

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

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

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

下面是对应的链接:

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

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

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享