代码重构

530 阅读19分钟

前言

重构是每个程序员都会遇到的事情,小到变量改名,大到服务拆分。我们几乎每天都进行着“重构-新代码-重构”这样一个闭环,那么重构的目标是什么?我们应该如何精炼自己的代码?

基于《重构-改善既有代码的设计(第2版)》这本书,结合一些项目中出现的问题和思考,整理出这篇文章,其中书中是以js代码作为示例,我这里改成了java版本。

一、为什么要重构

当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构,程序的内部设计(或者叫架构)会逐渐腐败变质。

程序员越来越难通过阅读源码来理解原来的设计:越难看出代码就越难保护其设计,设计就腐败得越快。

二、重构的目的

重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

  • 重构是为了让代码“更容易理解,更易于修改”。
  • 改进软件设计、软件更容易理解、帮助找到bug、提高开发效率。

一个常见的误区认为重构就是让代码变得简洁,好看,其实不然,如果一段代码根本不会更改,即使写的很乱也没有必要重构。除了上面的两点的重构都是过分重构。

三、何时重构

3.1 新增代码时

添加新功能时,如果对代码结构做一点微调,我的工作会容易得多,此时就可以进行预备性重构。

场景:也许已经有个函数提供了我需要的大部分功能,但有几个字面量的值与我的需要略有冲突。如果不做重构,我可能会把整个函数复制过来,修改这几个值,但这就会导致重复代码——如果将来我需要做修改,就必须同时修改两处。

3.2 阅读代码时

我们大量的时间是在阅读别人的代码,如果发现写的有问题,也可以重构。

  • 帮助理解重构 一旦我需要思考“这段代码到底在做什么”,即使我会在脑海里形成一些理解,但我的记性不好,记不住那么多细节。通过重构,我就把脑子里的理解转移到了代码本身,这份知识会保存得更久,并且我的同事也能看到。
  • 捡垃圾式重构 我已经理解代码在做什么,但发现它做得不好。

这里有一个取舍:我不想从眼下正要完成的任务上跑题太多,但我也不想把垃圾留在原地,给将来的修改增加麻烦。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。

3.3 review代码时

代码review阶段,大家一起对代码进行理解,拆分。审有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。

3.4 何时不应该重构

  • 如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。
  • 如果丑陋的代码能被隐藏在一个API之下,我就可以容忍它继续保持丑陋。
  • 如果重写比重构还容易,就别重构了。

四、重构手法

4.1 提炼函数

private void printOwing() { 
OutStand outstanding = calculateOutstanding(); 
system.out.print(outstanding.name); 
system.out.print(outstanding.customer); 
}

重构后

private void printOwing() { 
OutStand outstanding = calculateOutstanding(); 
printDetail(outstanding); 
}
private void printDetail(OutStand outstanding) { 
system.out.print(outstanding.name); 
system.out.print(outstanding.customer); 
}

提炼函数时最常用的一种重构方式,上例看上去很简单,实际过程中难的是找到提出的边界,即哪些代码应该 被抽出。

这里我们可以遵循一个原则,“将意图和实现分开”,如果你需要花时间浏览一段代码才能弄清它到底在干什 么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。以后再读到这段代码时,你一眼就能看到函数的用途,大多数时候根本不需要关心函数如何达成其用途(这是函数体内干的事)。

提炼函数的另一个难点是如何命名,如果名称表示不清那么提炼就是失败的。这里有个简单的命名原则,名称表示的是 做什么,而不是怎样做,先写一段注释描述下函数用途,再将其翻译成名字。

4.2 内联函数

private boolean getRating(driver) { 
return moreThanFiveLateDeliveries(driver) ? 2 : 1; 
} 
private boolean moreThanFiveLateDeliveries(driver) { 
return driver.numberOfLateDeliveries > 5; 
}

重构后

private boolean getRating(driver) { 
return (driver.numberOfLateDeliveries > 5) ? 2 : 1; 
}

内联函数是提炼函数的反重构,当方法本身逻辑和名字一样清晰时,就没必要多调用一步,去除无用的中间层,直接整合到一起就好了。

还有一个常见的场景是将原有的结构先合并,再重新拆分,合并这步就是内联。

4.3 提炼变量

return order.quantity * order.itemPrice - Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 + Math.min(order.quantity * order.itemPrice * 0.1, 100);

重构后

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

表达式有可能非常复杂而难以阅读。局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。这样的变量在调试时也很方便,它们给调试器和打印语句提供了便利的抓手。

提炼变量时需要注意:如果这个变量名在更宽的作用域中也有意义,考虑将其暴露出来,通常以函数的形式。‘

4.4 改变函数声明

private String getPhoneOfPerson(Person person) { 
check(person.getPhone()); 
return person.getPhone(); 
}

重构后

private String getPhone(String phone) { 
check(phone); return phone; 
}

函数声明包括:函数名,参数,返回值等等。

函数名的定义之前已经说过就不赘述了,这里主要是针对函数的参数进行重构,修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。

之前的函数只能处理“人”的电话号,如果想要处理“公司”的电话号怎么办?而且方法里仅用到了phone,为什么我要关心电话是人的还是公司的呢?

因此我们将函数参数拆分开,使功能更加独立,更加符合函数表达的语义。

4.5 修改变量名

int a = height * width;

重构后

int area = height * width;

除了语法自带的约束以外,变量的命名还应当遵循简明,见名之意,不互相混淆等规范。

只在一行的lambda表达式中使用的变量,跟踪起来很容易——像这样的变量,我经常只用一个字母命名,因为变量的

用途在这个上下文中很清晰,调用链路越长的变量命名就越重要。

需要注意:如果命名改动涉及到两个库之间,发出去的包我们认为变量已经“发布”了,不建议立即修改,因为这样会导致两方定义不一致。

4.6 函数组合成类

private void base(aReading) {...} 
private void taxableCharge(aReading) {...} 
private void calculateBaseCharge(aReading) {...}

重构后

class Reading { 
private void base(aReading) {...} 
private void taxableCharge(aReading) {...} 
private void calculateBaseCharge(aReading) {...} 
}

如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),是时候组建一个类了。

类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。

这种组合关系也能防止一个类代码庞大的情况,比如用spring构造的一些服务类,和实体一一对应,但某个业务需要的方法并非跟某一个实体强,而是代表一个流程节点,此时如果保持服务结构不变,则某个类中会堆积很多相关代码。

4.7 拆分阶段

String[] orderData = orderString.split("/\s+/"); 
Integer productPrice = parseInt(priceList[orderData[0].split("-")[1]]); 
Integer orderPrice = parseInt(orderData[1]) * productPrice;

重构后

SaleOrder orderRecord = parseOrder(order); 
Integer orderPrice = price(orderRecord, priceList); 
private SaleOrder parseOrder(String orderStr) { 
  String[] values = orderStr.split(/\s+/); 
  return new SaleOrder().setProductId(values[0].split("-")[1]) .setQuantity(values[1]); 
} 
private int price(SaleOrder order, int[] priceList) { 
  return order.getQuantity() * priceList[order.getProductId()]; 
}

每当看见一段代码在同时处理两件不同的事,就应该把它拆分成各自独立的模块,因为这样到了需要修改的时候,就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。

最简洁的拆分方法就是分成顺序执行的两个阶段,通过参数连接一起,每个步骤负责的任务全然不同。

本例重构前数据拆分和计算混在一起,一旦想要修改拆分逻辑,就要考虑对计算的影响,重构后,将拆分单独提出来,封装到对象中返回。

编译器和执行器就是很常见的拆分,编译器负责将多种格式转换成一种统一的可执行格式,执行器负责执行就好了。

4.8 搬移函数

class MaterialService { 
void saveMaterialOpeRecord(MaterialOpeRecord opeRecord); 
}

重构后

class MaterialOpeRecordService { 
void save(MaterialOpeRecord opeRecord); 
}

搬移函数最主要目的是解耦,保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。

任何方法都需要具备上下文环境才能存活,这个上下文基本上就是模块域。

如果一个方法频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,就可以减少对当前模块的依赖。

有时你在函数内部定义了一个帮助函数,而该帮助函数可能在别的地方也有用处,此时就可以将它搬移到某些更通用的地方。同理,定义在一个类上的函数,可能挪到另一个类中去更方便我们调用。

如果搬移时发现需要为一整组方法创建一个新的上下文,就可以将方法组成类。

4.9 拆分循环

int averageAge = 0; 
int totalSalary = 0; 
for (People p : peoples) { 
  averageAge += p.age; 
  totalSalary += p.salary; 
} 
averageAge = averageAge / people.length;

重构后

int totalSalary = 0; 
for (People p : peoples) { 
  totalSalary += p.salary; 
} 
int averageAge = 0; 
for (const p of people) { 
  averageAge += p.age; 
}
averageAge = averageAge / people.length;

如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。

如果一个循环只计算一个值,那么它直接返回该值即可;但如果循环做了太多件事,那就只得返回结构型数据或者通过局部变量传值了。

拆分循环让我们感到不安,因为它会迫使你执行多次循环,可能会让性能变差,这里的建议是,先进行重构,然后再进行性能优化。如果重构之后该循环确实成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。但实际情况是循环本身也很少成为性能瓶颈。

4.10 拆分变量

int temp = 2 * (height + width); 
System.out.println(temp); 
temp = height * width; 
System.out.println(temp);

重构后

int perimeter = 2 * (height + width); 
System.out.println(perimeter); 
int area = height * width; 
System.out.println(area);

代码中的变量应明确如下用途:

  • 循环变量会随循环的每次运行而改变;
  • 结果收集变量负责将“通过整个函数的运算”而构成的某个值收集起来。
  • 很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果它们被赋值超过一次,就意味它们在函数中承担了一个以上的责任。就应该被替换(分解)为多个变量,每个变量只承担一个责任。

4.11 以获取变量方式取代派生变量方式

int getDiscountedTotal() { 
  return this.discountedTotal; 
} 
void setDiscount(int aNumber) { 
  int old = this.discount; 
  this.discount = aNumber; 
  this.discountedTotal += old - aNumber; 
}

优化后

getDiscountedTotal() { 
  return this.baseTotal - this.discount; 
} 
setDiscount(aNumber) { 
  this._discount = aNumber; 
}

变量的作用域溢出容易引发很难排查的bug,本例优化前的代码,对discountedTotal变量的赋值在设置discount方法中偷偷调用,对于获取discountedTotal方法根本无从得知值是怎么生成的。

尽量把可变数据的作用域限制在最小范围,特别是设置值的方法要相对独立。

4.12 移除设置值函数

class Person { 
  String getName() {} 
  setName(String aString) {...} 
}

重构后

class Person { 
  String getName() {...} 
}

如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。

五、发现代码坏味道及其重构方案

5.1 神秘命名

表象:命名不规范,词不达意,概念混淆。

解决方案:建立业务名词对照表,严格控制名称定义。

5.2重复代码

表象:相似的代码多地出现,相似的逻辑多地出现

解决方案:

  • 移动语句:

如果重复代码只是相似而不是完全相同,请首先尝试用移动语句重组代码顺序,把相似的部分放在一起以便提炼。

  • 函数上移: 如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移。

5.3 过长函数

表象:方法体过长

解决方案:提炼函数

对于庞大的switch语句,其中的每个分支都应该通过提炼函数(106)变成独立的函数调用。

如果有多个switch语句基于同一个条件进行分支选择,就应该使用以多态取代条件表达式。

将循环和循环内的代码提炼到一个独立的函数中时发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。如果是这种情况,请勇敢地使用拆分循环将其拆分成各自独立的任务。

5.4 过多的调用参数

表象:方法传入参数过多

解决方案:

  • 查询取代参数:

如果可以向某个参数发起查询而获得另一个参数的值,那么就可以少传一个参数。

  • 保持对象完整:

如果你发现自己正在从现有的数据结构中抽出很多数据项,就可以考虑使用保持原对象。

  • 参数对象:

如果有几项参数总是同时出现,可以合并成一个参数对象。

  • 拆分方法代替参数:

如果某个参数被用作区分函数行为的标记,可以考虑拆分成两个方法。

5.5 数据控制扩散

表象:调用链路过长时,变量作用域扩散,在何时修改无法控制。

解决方案:

  • 封装变量来确保所有数据更新操作都通过很少几个函数来进行。
  • 避免一个变量有多重含义,将其拆分成不同用途的变量。
  • 不要在查询函数中做修改操作,将查询和修改分离。

5.6 逻辑耦合

表象:当修改一个逻辑时需要同步修改其他方法逻辑

解决方案:

  • “每次只关心一个上下文“,把不同类型的功能抽离出来.
  • 如果两个方向有频繁交互,就应该先创建适当的模块,然后用搬移函数把处理逻辑分开.
  • 如果有很多函数都在操作相似的数据,可以使用函数组合成类.

5.7 数据泥团

表象:两个类中相同字段,方法中相同的参数

解决方案:

将类共性抽出,将参数整合成类。

5.8 基本类型偏执

表现:忽视问题域,只看数据表现类型来定义基础类型变量,比如钱、坐标、范围、手机号不止是个数字,为了表示意义还需要很多教研,初始化等方法,这些都是基础类型提供不了的。

解决方案:

封装对象来取代基本类型,对该领域的操作封装在对象中。

5.9 重复的switch

表现:多个地方存在相同的switch或者连续的if else

解决方案:

相同的多分支逻辑可以多态策略模式进行,将分支打散,更细粒度的扩展。

5.10 冗赘的元素

表现:出现很多冗余的类、方法。有关联的类分散开来。

解决方案:

重构过程中,发现一些关联类需要整合和解构的立即处理,等到以后就不知道该不该处理了。

将关联类以内联关系变成内部类,保证类定义不会扩散出去。

5.11 临时字段

表现:其内部某个字段仅为某种特定情况而设,在字段未被使用的情况下无法猜测当初设置它的目的。

解决方案:

要么将变量提炼出来到内部类中,或者直接搬运到使用它的地方,通过其他字段计算出来。

5.12 过长的对象链

表现:一个对象请求另一个对象,另一个对象又请求下一个对象

解决方案:

我们将对象链路处理提炼成函数,再推回链路中,本质是把对象件调用改成函数间调用,方便扩展。

5.13 过度委托

表现:某个类中方法全部交由其他类来实现,业务边界不清晰

解决方案:将该业务下的方法统一迁移到一起,对外提供委托方法。

5.14 内部交易

表现:两个对象在不同方法间进行大量数据交换,再调用方上根本不可见

解决方案:可以提出公共数据统一处理,或者将转换已到调用前明面进行。

值得一提的是滥用继承很容易导致这种问题,我们很难判断赋值是给子类本身还是父类,因此推荐hasA的方式。

5.15 过大的类

表现:一个实体类表示的不是业务实体,而是流程实体,根据流程扩展不断的加字段。

一个服务类内代码过大,1000行以上

解决方案:

拆分类:如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个子类内,例如,depositAmount和depositCurrency可能应该隶属同一个类。

六、重构和性能优化的冲突

重构目的是改善代码结构,方便扩展和阅读理解。 性能优化目的是节约资源,提升用户体验。它们俩本质是不同目的的两件事,但实际对代码的改造会有一定冲突。

比如把一个大循环体,拆分成两个小循环体。重构后代码是达到了目的,但性能会不会变慢?

其实并不然:在聪明的编译器、现代的缓存技术面前,我们很多直觉都是不准确的。软件的性能通常只与代码的一小部分相关,改变其他的部分往往对总体性能贡献甚微。

我们可以大胆的先进行重构,即使重构后影响性能,那么就进行性能优化,代码清晰后在优化性能时也会变得轻松一些,最终得到即整洁又高效的代码。

七、重构遇到的挑战及建议

7.1 延缓新功能开发

重构肯定会占用工作量,相应的就会延缓新功能开发进度。

建议:工作量的增加不可避免,那么现在要解决的就是是否重构的问题了。以设计曲线的角度来说,为了以后长久的持续交付,重构还是很有必要的。因此只能协调好进度,把这部分工作量算上。

7.2 代码依赖

实际项目中代码之间的调用链路很庞大,修改一个方法的参数需要调用他所有方法跟着修改,修改方法的实现需要所有调用方都进行测试,这无疑是把成本翻倍了。

建议:如果扩展不可避免,可以新建一个方法进行修改,原方法调用关系先保持不动,然后标记为过时,持续的去更新依赖关系,这样的好处是任何时间你都能保证这个改动不会影响到其他模块。

7.3 分支合并

当我们好不容易重构完成,往master分支合并时,发现由于历史差距太大,一堆冲突根本不知道如何合并,合并后是不是需要重新测试保证没有问题。

建议:CI持续集成,每天都合并下最新代码,保证历史差距不会太大。

7.4 测试

一个方法的重构,即使是小方法,也可能会产生问题,而且,我们重构往往不是一步就到最优,往往需要一次次的优化,如果再最后进行测试,那么问题出现的原因将很难定位(因为不知道是那次重构出问题)。 我们需要一套快速稳定的测试方案。

建议:我们应该预先准备测试用例,每次重构完都要跑一遍,这样调查范围会降低到最小。 测试方案应具备快速有效、标准唯一、扩展方面等特性,一些脚本类的测试用例可以满足我们要求,现在还可以将其集成到CI中,每次分支提交都进行测试。