阅读 17

代码坏味道系列之膨胀剂

膨胀剂

代码中的类、函数、字段没有经过合理的组织,只是简单的堆砌起来。这一类型的问题通常在代码的初期并不明显,但是随着代码规模的增长而逐渐积累(特别是当没有人努力去根除它们时)。

bloaters

过长方法

  • 一个函数含有太多行代码。一般来说,任何函数超过 10 行时,你就可以考虑是不是过长了。
  • 函数中的代码行数原则上不要超过 100 行。

long-method-01

问题原因

通常情况下,创建一个新函数的难度要大于添加功能到一个已存在的函数。大部分人都觉得:“我就添加这么两行代码,为此新建一个函数实在是小题大做了。 ”于是,张三加两行,李四加两行,王五加两行。。。函数日益庞大,最终烂的像一锅浆糊,再也没人能完全看懂了。 于是大家就更不敢轻易动这个函数了,只能恶性循环的往其中添加代码。所以,如果你看到一个超过 200 行的函数,通常都是多个程序员东拼西凑出来的。

解决方案

一个很好的技巧是:寻找注释。添加注释,一般有这么几个原因:代码逻辑较为晦涩或复杂;这段代码功能相对独立;特殊处理。 如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。 如果函数有一个描述恰当的名字,就不需要去看内部代码究竟是如何实现的。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中。

long-method-02

  • 为了给一个函数瘦身,可以使用 提炼函数(Extract Method)
  • 如果局部变量和参数干扰提炼函数,可以使用 以查询取代临时变量(Replace Temp with Query)引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果前面两条没有帮助,可以通过 以函数对象取代函数(Replace Method with Method Object) 尝试移动整个函数到一个独立的对象中。
  • 条件表达式和循环常常也是提炼的信号。对于条件表达式,可以使用 分解条件表达式(Decompose Conditional) 。至于循环,应该使用 提炼函数(Extract Method) 将循环和其内的代码提炼到独立函数中。

收益

  • 在所有类型的面向对象代码中,函数比较短小精悍的类往往生命周期较长。一个函数越长,就越不容易理解和维护。
  • 此外,过长函数中往往含有难以发现的重复代码。

long-method-03

性能

是否像许多人说的那样,增加函数的数量会影响性能?在几乎绝大多数情况下,这种影响是可以忽略不计,所以不用担心。 此外,现在有了清晰和易读的代码,在需要的时候,你将更容易找到真正有效的函数来重组代码和提高性能。

重构方法说明

提炼函数(Extract Method)

问题

你有一段代码可以组织在一起。

void printOwing() {
  printBanner();

  //print details
  System.out.println("name: " + name);
  System.out.println("amount: " + getOutstanding());
}
复制代码

解决

移动这段代码到一个新的函数中,使用函数的调用来替代老代码。

void printOwing() {
  printBanner();
  printDetails(getOutstanding());
}

void printDetails(double outstanding) {
  System.out.println("name: " + name);
  System.out.println("amount: " + outstanding);
}
复制代码

以查询取代临时变量(Replace Temp with Query)

问题

将表达式的结果放在局部变量中,然后在代码中使用。

double calculateTotal() {
  double basePrice = quantity * itemPrice;
  if (basePrice > 1000) {
    return basePrice * 0.95;
  }
  else {
    return basePrice * 0.98;
  }
}
复制代码

解决

将整个表达式移动到一个独立的函数中并返回结果。使用查询函数来替代使用变量。如果需要,可以在其他函数中合并新函数。

double calculateTotal() {
  if (basePrice() > 1000) {
    return basePrice() * 0.95;
  }
  else {
    return basePrice() * 0.98;
  }
}
double basePrice() {
  return quantity * itemPrice;
}
复制代码

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

introduce-parameter-object-before

解决

以一个对象来取代这些参数。

introduce-parameter-object-after

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);
复制代码

解决

改为传递整个对象。

boolean withinPlan = plan.withinRange(daysTempRange);
复制代码

以函数对象取代函数(Replace Method with Method Object)

问题

你有一个过长函数,它的局部变量交织在一起,以致于你无法应用提炼函数(Extract Method) 。

class Order {
  //...
  public double price() {
    double primaryBasePrice;
    double secondaryBasePrice;
    double tertiaryBasePrice;
    // long computation.
    //...
  }
}
复制代码

解决

将函数移到一个独立的类中,使得局部变量成了这个类的字段。然后,你可以将函数分割成这个类中的多个函数。

class Order {
  //...
  public double price() {
    return new PriceCalculator(this).compute();
  }
}

class PriceCalculator {
  private double primaryBasePrice;
  private double secondaryBasePrice;
  private double tertiaryBasePrice;

  public PriceCalculator(Order order) {
    // copy relevant information from order object.
    //...
  }

  public double compute() {
    // long computation.
    //...
  }
}
复制代码

分解条件表达式(Decompose Conditional)

问题

你有复杂的条件表达式。

if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
  charge = quantity * winterRate + winterServiceCharge;
}
else {
  charge = quantity * summerRate;
}
复制代码

解决

根据条件分支将整个条件表达式分解成几个函数。

if (notSummer(date)) {
  charge = winterCharge(quantity);
}
else {
  charge = summerCharge(quantity);
}
复制代码
  • 程序愈长就愈难理解
  • 函数过长阅读起来也不方便
  • 小函数的价值:解释能力、共享能力、选择能力

Tips:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立的函数中。记着,起个好名字!

过大的类

一个类含有过多字段、函数、代码行。

large-class-01

问题原因

类通常一开始很小,但是随着程序的增长而逐渐膨胀。

类似于过长函数,程序员通常觉得在一个现存类中添加新特性比创建一个新的类要容易。

解决方案

设计模式中有一条重要原则:职责单一原则。一个类应该只赋予它一个职责。如果它所承担的职责太多,就该考虑为它减减负。

large-class-02

  • 如果过大类中的部分行为可以提炼到一个独立的组件中,可以使用 提炼类(Extract Class)
  • 如果过大类中的部分行为可以用不同方式实现或使用于特殊场景,可以使用 提炼子类(Extract Subclass)
  • 如果有必要为客户端提供一组操作和行为,可以使用 提炼接口(Extract Interface)
  • 如果你的过大类是个 GUI 类,可能需要把数据和行为移到一个独立的领域对象去。你可能需要两边各保留一些重复数据,并保持两边同步。 复制被监视数据(Duplicate Observed Data) 可以告诉你怎么做。

收益

  • 重构过大的类可以使程序员不必记住一个类中大量的属性。
  • 在大多数情况下,分割过大的类可以避免代码和功能的重复。

large-class-03

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

extract-class-before

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

extract-class-after

提炼子类(Extract Subclass)

问题

一个类中有些特性仅用于特定场景。

extract-subclass-before

解决

创建一个子类,并将用于特殊场景的特性置入其中。

extract-subclass-after

提炼接口(Extract Interface)

问题

多个客户端使用一个类部分相同的函数。另一个场景是两个类中的部分函数相同。

extract-interface-before

解决

移动相同的部分函数到接口中。

extract-interface-before

复制被监视数据(Duplicate Observed Data)

问题

如果存储在类中的数据是负责 GUI 的。

duplicate-observed-data-before

解决

一个比较好的方法是将负责 GUI 的数据放入一个独立的类,以确保 GUI 数据与域类之间的连接和同步。

duplicate-observed-data-after

如果想利用单一类做太多事情,其内往往就会出现太多的成员变量。

  • 提取完成同一任务的相关变量到一个新的类。
  • 干太多事情的类,可以考虑把责任委托给其他类。

Tips:一个类如果拥有太多的代码,也是代码重复、混乱、死亡的绝佳滋生地点。

基本类型偏执

  • 使用基本类型而不是小对象来实现简单任务(例如货币、范围、电话号码字符串等)。
  • 使用常量编码信息(例如一个用于引用管理员权限的常量 USER_ADMIN_ROLE = 1 )。
  • 使用字符串常量作为字段名在数组中使用。

primitive-obsession-01

问题原因

类似其他大部分坏味道,基本类型偏执诞生于类初建的时候。一开始,可能只是不多的字段,随着表示的特性越来越多,基本数据类型字段也越来越多。

基本类型常常被用于表示模型的类型。你有一组数字或字符串用来表示某个实体。

还有一个场景:在模拟场景,大量的字符串常量被用于数组的索引。

解决方案

primitive-obsession-02

大多数编程语言都支持基本数据类型和结构类型(类、结构体等)。结构类型允许程序员将基本数据类型组织起来,以代表某一事物的模型。

基本数据类型可以看成是机构类型的积木块。当基本数据类型数量成规模后,将它们有组织地结合起来,可以更方便的管理这些数据。

  • 如果你有大量的基本数据类型字段,就有可能将其中部分存在逻辑联系的字段组织起来,形成一个类。更进一步的是,将与这些数据有关联的方法也一并移入类中。为了实现这个目标,可以尝试 以类取代类型码(Replace Type Code with Class)
  • 如果基本数据类型字段的值是用于方法的参数,可以使用 引入参数对象(Introduce Parameter Object)保持对象完整(Preserve Whole Object)
  • 如果想要替换的数据值是类型码,而它并不影响行为,则可以运用 以类取代类型码(Replace Type Code with Class) 将它替换掉。如果你有与类型码相关的条件表达式,可运用 以子类取代类型码(Replace Type Code with Subclass)以状态/策略模式取代类型码(Replace Type Code with State/Strategy) 加以处理。
  • 如果你发现自己正从数组中挑选数据,可运用 以对象取代数组(Replace Array with Object)

收益

  • 多亏了使用对象替代基本数据类型,使得代码变得更加灵活。
  • 代码变得更加易读和更加有组织。特殊数据可以集中进行操作,而不像之前那样分散。不用再猜测这些陌生的常量的意义以及它们为什么在数组中。
  • 更容易发现重复代码。

primitive-obsession-03

重构方法说明

以类取代类型码(Replace Type Code with Class)

问题

类之中有一个数值类型码,但它并不影响类的行为。

replace-type-code-with-class-before

解决

以一个新的类替换该数值类型码。

replace-type-code-with-class-after

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

introduce-parameter-object-before

解决

以一个对象来取代这些参数。

introduce-parameter-object-after

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);
复制代码

解决

改为传递整个对象。

boolean withinPlan = plan.withinRange(daysTempRange);
复制代码

以子类取代类型码(Replace Type Code with Subclass)

问题

你有一个不可变的类型码,它会影响类的行为。

replace-type-code-with-subclasses-before

解决

以子类取代这个类型码。

replace-type-code-with-subclasses-after

以状态/策略模式取代类型码(Replace Type Code with State/Strategy)

问题

你有一个类型码,它会影响类的行为,但你无法通过继承消除它。

replace-type-code-with-state-strategy-before

解决

以状态对象取代类型码。

replace-type-code-with-state-strategy-after

以对象取代数组(Replace Array with Object)

问题

你有一个数组,其中的元素各自代表不同的东西。

String[] row = new String[3];
row[0] = "Liverpool";
row[1] = "15";
复制代码

解决

以对象替换数组。对于数组中的每个元素,以一个字段来表示。

Performance row = new Performance();
row.setName("Liverpool");
row.setWins("15");
复制代码

过长参数列

一个函数有超过 3、4 个入参。

long-parameter-list-01

问题原因

过长参数列可能是将多个算法并到一个函数中时发生的。函数中的入参可以用来控制最终选用哪个算法去执行。

过长参数列也可能是解耦类之间依赖关系时的副产品。例如,用于创建函数中所需的特定对象的代码已从函数移动到调用函数的代码处,但创建的对象是作为参数传递到函数中。因此,原始类不再知道对象之间的关系,并且依赖性也已经减少。但是如果创建的这些对象,每一个都将需要它自己的参数,这意味着过长参数列。

太长的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦需要更多数据,就不得不修改它。

解决方案

long-parameter-list-02

  • 如果向已有的对象发出一条请求就可以取代一个参数,那么你应该使用 以函数取代参数(Replace Parameter with Methods) 。在这里,,“已有的对象”可能是函数所属类里的一个字段,也可能是另一个参数。
  • 你还可以运用 保持对象完整(Preserve Whole Object) 将来自同一对象的一堆数据收集起来,并以该对象替换它们。
  • 如果某些数据缺乏合理的对象归属,可使用 引入参数对象(Introduce Parameter Object) 为它们制造出一个“参数对象”。

收益

  • 更易读,更简短的代码。
  • 重构可能会暴露出之前未注意到的重复代码。

何时忽略

  • 这里有一个重要的例外:有时候你明显不想造成"被调用对象"与"较大对象"间的某种依赖关系。这时候将数据从对象中拆解出来单独作为参数,也很合情理。但是请注意其所引发的代价。如果参数列太长或变化太频繁,就需要重新考虑自己的依赖结构了。

重构方法说明

以函数取代参数(Replace Parameter with Methods)

问题

对象调用某个函数,并将所得结果作为参数,传递给另一个函数。而接受该参数的函数本身也能够调用前一个函数。

int basePrice = quantity * itemPrice;
double seasonDiscount = this.getSeasonalDiscount();
double fees = this.getFees();
double finalPrice = discountedPrice(basePrice, seasonDiscount, fees);
复制代码

解决

让参数接受者去除该项参数,并直接调用前一个函数。

int basePrice = quantity * itemPrice;
double finalPrice = discountedPrice(basePrice);
复制代码

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);
复制代码

解决

改为传递整个对象。

boolean withinPlan = plan.withinRange(daysTempRange);
复制代码

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

introduce-parameter-object-before

解决

以一个对象来取代这些参数。

introduce-parameter-object-after

代码中有很多基本数据类型的数据。

Tips:如果看到一些基本类型数据,尝试定义一种新的数据类型,符合它当前所代表的对象类型。

数据泥团

有时,代码的不同部分包含相同的变量组(例如用于连接到数据库的参数)。这些绑在一起出现的数据应该拥有自己的对象。

data-clumps-01

问题原因

通常,数据泥团的出现时因为糟糕的编程结构或“复制-粘贴式编程”。

有一个判断是否是数据泥团的好办法:删掉众多数据中的一项。这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是个明确的信号:你应该为它们产生一个新的对象。

解决方案

  • 首先找出这些数据以字段形式出现的地方,运用 提炼类(Extract Class) 将它们提炼到一个独立对象中。
  • 如果数据泥团在函数的参数列中出现,运用 引入参数对象(Introduce Parameter Object) 将它们组织成一个类。
  • 如果数据泥团的部分数据出现在其他函数中,考虑运用 保持对象完整(Preserve Whole Object) 将整个数据对象传入到函数中。
  • 检视一下使用这些字段的代码,也许,将它们移入一个数据类是个不错的主意。

data-clumps-02

收益

  • 提高代码易读性和组织性。对于特殊数据的操作,可以集中进行处理,而不像以前那样分散。
  • 减少代码量。

data-clumps-03

何时忽略

  • 有时为了对象中的部分数据而将整个对象作为参数传递给函数,可能会产生让两个类之间不收欢迎的依赖关系,这中情况下可以不传递整个对象。

重构方法说明

提炼类(Extract Class)

问题

某个类做了不止一件事。

extract-class-before

解决

建立一个新类,将相关的字段和函数从旧类搬移到新类。

extract-class-after

引入参数对象(Introduce Parameter Object)

问题

某些参数总是很自然地同时出现。

introduce-parameter-object-before

解决

以一个对象来取代这些参数。

introduce-parameter-object-after

保持对象完整(Preserve Whole Object)

问题

你从某个对象中取出若干值,将它们作为某一次函数调用时的参数。

int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);
复制代码

解决

改为传递整个对象。

boolean withinPlan = plan.withinRange(daysTempRange);
复制代码

扩展阅读

参考资料