作为程序员,我们每天都在写代码,也都在维护别人的代码。很多时候,我们会遇到一些烂代码,逻辑混乱,命名不规范,重复代码很多,没有注释,方法很长,嵌套很深,很难理解,也很难维护,改一个Bug,可能会引入新的Bug,很痛苦。
这时候,就需要代码重构,改善代码的结构,提高代码的可读性和可维护性,而不改变代码的功能。代码重构,是程序员的基本功,也是提高代码质量的重要手段,但是,很多人对重构不够重视,或者不知道怎么重构,要么不敢重构,怕改出问题,要么重构的时候,把功能改坏了,引入新的Bug。
我工作这么多年,重构过很多代码,有自己写的,也有别人写的,有小的重构,也有大的重构,踩过很多坑,也总结了一些经验。今天就来分享一下,代码重构的技巧和最佳实践,希望能给大家一些参考。
一、什么是代码重构
首先,先明确一下,什么是代码重构。
代码重构(Code Refactoring),是指在不改变代码外部功能的前提下,改善代码的内部结构,提高代码的可读性、可维护性、可扩展性,降低代码的复杂度,减少技术债务的过程。
简单来说,重构就是"代码写得不好,重新写得更好,但是功能不变"。
重构的目的,不是改变功能,而是改善代码的结构,让代码更容易理解,更容易维护,更容易扩展。重构之后,代码的功能,应该和重构之前完全一样,用户感知不到任何变化。
重构,不是重写,重写是把代码全部扔掉,重新写一遍,而重构是在现有代码的基础上,逐步改善,小步快跑,每一步都不改变功能,都能运行,都能测试。重写的风险很大,很容易把原来的功能改坏,而重构的风险小很多,因为是逐步改善,每一步都有测试保障。
二、为什么要重构
为什么要重构?重构有什么好处?
- 提高代码的可读性: 好的代码,应该是容易理解的,看一眼就知道是做什么的,而烂代码,看半天都看不懂,要花很多时间去理解。重构之后,代码的命名更规范,结构更清晰,注释更完善,可读性大大提高,别人看你的代码,或者你自己过一段时间看自己的代码,都能很快理解。
- 提高代码的可维护性: 代码是需要长期维护的,改Bug,加新功能,都需要维护代码,烂代码,维护起来很痛苦,改一个Bug,可能会引入新的Bug,加一个新功能,要改很多地方,很容易出错。重构之后,代码的结构更清晰,耦合度更低,内聚度更高,维护起来更容易,改Bug,加新功能,都更简单,更不容易出错。
- 提高代码的可扩展性: 业务是不断发展的,代码需要不断扩展,加新功能,烂代码,扩展起来很困难,加一个新功能,要改很多地方,甚至要推翻重写。重构之后,代码的设计更合理,耦合度更低,扩展性更好,加新功能,只需要加新的代码,不需要改太多现有的代码,符合开闭原则。
- 减少技术债务: 写代码的时候,有时候为了赶进度,会写一些烂代码,先实现功能再说,这就是技术债务,技术债务是要还的,越晚还,利息越高,维护成本越高。重构,就是还技术债务,把烂代码改成好代码,减少技术债务,降低长期的维护成本。
- 提高开发效率: 烂代码,理解起来慢,改起来慢,测试起来慢,开发效率很低,重构之后,代码更容易理解,更容易修改,更容易测试,开发效率大大提高,虽然重构的时候,要花一些时间,但是长期来看,能节省更多的时间。
- 提升程序员的能力: 重构,需要程序员有良好的代码感觉,有设计能力,有抽象能力,能识别代码的坏味道,能把烂代码改成好代码,这个过程,能大大提升程序员的能力,让程序员写出更好的代码。
所以,重构是很有必要的,不是浪费时间,而是为了长期的效率和质量。
三、什么时候重构
什么时候应该重构?不是所有的时候都适合重构,要选择合适的时机。
- 新增功能的时候: 新增功能的时候,如果发现现有的代码结构不好,很难加新功能,就应该先重构,把代码结构改好,再加新功能,这样,加新功能会更容易,也更不容易出错。这就是所谓的"三次法则":第一次做某件事,直接做;第二次做类似的事,复制粘贴;第三次做类似的事,就该重构了。
- 改Bug的时候: 改Bug的时候,如果发现代码很难理解,很难定位Bug,或者改了这个Bug,可能会引入新的Bug,就应该先重构,把代码结构改清晰,再改Bug,这样,更容易定位Bug,也更不容易引入新的Bug。
- 代码评审的时候: 代码评审的时候,如果发现代码有坏味道,结构不好,就应该提出重构的建议,让作者重构,或者在评审之后,安排时间重构,不要让烂代码进入代码库。
- 定期重构: 可以安排定期的重构时间,比如每个迭代,留一部分时间,专门用来重构,还技术债务,不要等技术债务积累太多了,才重构,那时候重构的成本和风险都很大。
- 不要在发布前重构: 发布前,是代码冻结期,要尽量稳定,不要做大的重构,避免引入新的Bug,影响发布,重构应该在发布之后,或者迭代的早期进行。
- 不要为了重构而重构: 重构是为了改善代码的结构,提高代码的质量,不是为了重构而重构,如果代码已经很好了,就不需要重构,不要过度设计,不要把简单的事情搞复杂。
四、重构的原则
重构的时候,要遵循一些原则,保证重构的质量和安全。
- 不改变功能: 重构的第一原则,就是不改变代码的外部功能,重构之后,代码的功能,应该和重构之前完全一样,用户感知不到任何变化。如果重构的时候,改变了功能,那就不是重构,而是重写或者修改功能了,风险很大。
- 小步快跑: 重构要小步快跑,每次只做一个小的重构,每一步都能运行,都能测试,不要一下子做很大的重构,改很多代码,那样很容易出问题,出了问题也很难定位。小步快跑,每一步都验证,即使出了问题,也很容易回滚。
- 测试保障: 重构之前,一定要有测试,单元测试、集成测试,保证重构之后,功能不变。如果没有测试,重构就是盲人摸象,很容易把功能改坏。所以,重构之前,先补测试,有了测试保障,再重构。
- 保持代码可运行: 重构的过程中,代码要始终保持可运行的状态,每做一个小的重构,就运行一下,测试一下,确保没有问题,再做下一个重构,不要改了很多代码,最后运行不起来,不知道哪里出了问题。
- 不要同时重构和加功能: 重构和加功能,不要同时做,要么只重构,要么只加功能,同时做的话,出了问题,不知道是重构导致的,还是加功能导致的,很难定位。重构完了,测试通过了,再加功能。
- 循序渐进: 重构要循序渐进,先做简单的、风险小的重构,再做复杂的、风险大的重构,不要一开始就做很大的重构,那样风险很大。
- 团队共识: 重构,特别是大的重构,要和团队成员沟通,达成共识,不要一个人偷偷摸摸地重构,改了很多代码,别人不知道,合并代码的时候,冲突很多,也容易出问题。大的重构,要团队一起讨论,制定计划,分工合作。
五、重构的准备工作
重构之前,要做一些准备工作,保证重构的顺利进行。
- 理解代码: 重构之前,先要理解代码,知道代码是做什么的,逻辑是怎样的,输入输出是什么,有哪些边界条件,有哪些副作用,不要还没理解代码,就开始重构,那样很容易把功能改坏。
- 补测试: 重构之前,一定要有测试,如果没有测试,先补测试,单元测试、集成测试,覆盖主要的功能和边界条件,保证重构之后,功能不变。测试是重构的安全网,没有测试,不要重构。
- 制定计划: 大的重构,要制定计划,分步骤,分阶段,每一步做什么,怎么做,谁来做,什么时候完成,都要规划好,不要盲目重构,想到哪改到哪。
- 备份代码: 重构之前,要备份代码,或者用版本控制,提交一个稳定的版本,这样,重构出了问题,可以随时回滚,不会丢失代码。
- 选择合适的时机: 重构要选择合适的时机,不要在发布前,不要在项目很忙的时候,要在迭代的早期,或者项目相对空闲的时候,有足够的时间,慢慢重构,测试。
六、常用的重构技巧
下面,介绍一些常用的重构技巧,这些都是很实用的,日常开发中经常用到。
1. 提取方法(Extract Method)
提取方法,是最常用的重构技巧,就是把一段代码,从一个长方法里,提取出来,变成一个独立的方法,用一个有意义的名字命名。
适用场景:
- 方法太长,超过一屏,很难理解。
- 方法里有一段代码,做了一件独立的事情,可以单独提取出来。
- 有重复的代码,可以提取成一个公共方法,复用。
好处:
- 方法更短,更容易理解。
- 方法的职责更单一,符合单一职责原则。
- 代码可以复用,减少重复代码。
- 方法名就是注释,不需要写注释,看方法名就知道是做什么的。
示例: 重构前:
public void printOwing(double amount) {
printBanner();
// 打印详情
System.out.println("name: " + name);
System.out.println("amount: " + amount);
}重构后:
public void printOwing(double amount) {
printBanner();
printDetails(amount);
}
private void printDetails(double amount) {
System.out.println("name: " + name);
System.out.println("amount: " + amount);
}2. 内联方法(Inline Method)
内联方法,和提取方法相反,就是把一个方法的内容,直接内联到调用的地方,去掉这个方法。
适用场景:
- 方法的内容很简单,只有一两行代码,方法名和方法内容一样清楚,甚至方法内容比方法名更清楚。
- 方法被调用的次数很少,只有一两次。
- 方法的间接层太多,调用链很深,很难理解。
好处:
- 减少不必要的间接层,代码更直接,更容易理解。
- 减少方法的数量,代码更简洁。
示例: 重构前:
public int getRating() {
return moreThanFiveLateDeliveries() ? 2 : 1;
}
private boolean moreThanFiveLateDeliveries() {
return numberOfLateDeliveries > 5;
}重构后:
public int getRating() {
return numberOfLateDeliveries > 5 ? 2 : 1;
}3. 提取变量(Extract Variable)
提取变量,就是把一个复杂的表达式,提取成一个有意义的变量,用变量名解释表达式的含义。
适用场景:
- 表达式很复杂,很难理解,比如很长的条件判断,很长的计算表达式。
- 表达式中有重复的部分,可以提取成变量,复用。
好处:
- 变量名就是注释,解释了表达式的含义,代码更容易理解。
- 减少重复的计算,提高性能。
- 调试的时候,更容易观察中间结果。
示例: 重构前:
if (platform.toUpperCase().indexOf("MAC") > -1 &&
browser.toUpperCase().indexOf("IE") > -1 &&
wasInitialized() && resize > 0) {
// do something
}重构后:
boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
// do something
}4. 重命名(Rename)
重命名,就是给变量、方法、类、参数等,改一个更有意义的名字,让名字能准确地表达它的含义。
适用场景:
- 名字没有意义,比如a、b、c、temp、data、info。
- 名字有歧义,不能准确表达含义。
- 名字和实际内容不符,比如变量叫list,实际是个map。
- 命名不规范,不符合团队的命名规范。
好处:
- 名字就是最好的注释,好的名字,不需要写注释,看名字就知道是做什么的。
- 代码更容易理解,维护成本更低。
重命名的原则:
- 名字要准确,能表达真实的含义。
- 名字要有意义,不要用无意义的名字。
- 名字要一致,同一类东西,用同样的命名方式。
- 名字不要太长,也不要太短,在表达清楚的前提下,尽量简洁。
示例: 重构前:
public int getInt(int[] a, int d) {
int t = 0;
for (int i = 0; i < a.length; i++) {
t += a[i];
}
return t / d;
}重构后:
public int getAverage(int[] scores, int count) {
int sum = 0;
for (int i = 0; i < scores.length; i++) {
sum += scores[i];
}
return sum / count;
}5. 分解条件表达式(Decompose Conditional)
分解条件表达式,就是把复杂的条件表达式,提取成方法,用方法名解释条件的含义。
适用场景:
- 条件表达式很复杂,很长,很难理解,比如if后面跟着很长的条件判断。
- 条件表达式中有重复的部分。
好处:
- 方法名解释了条件的含义,代码更容易理解。
- 条件逻辑可以复用,减少重复代码。
示例: 重构前:
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}重构后:
if (isWinter(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}
private boolean isWinter(Date date) {
return date.before(SUMMER_START) || date.after(SUMMER_END);
}
private int winterCharge(int quantity) {
return quantity * winterRate + winterServiceCharge;
}
private int summerCharge(int quantity) {
return quantity * summerRate;
}6. 合并条件表达式(Consolidate Conditional Expression)
合并条件表达式,就是把多个相同的条件判断,合并成一个,或者把多个条件,用逻辑运算符连接起来,提取成一个方法。
适用场景:
- 有多个条件判断,结果都是一样的,可以合并。
- 条件判断有重复的部分,可以合并。
好处:
- 代码更简洁,减少重复代码。
- 条件的含义更清楚。
示例: 重构前:
double disabilityAmount() {
if (seniority < 2) return 0;
if (monthsDisabled > 12) return 0;
if (isPartTime) return 0;
// compute the disability amount
}重构后:
double disabilityAmount() {
if (isNotEligableForDisability()) return 0;
// compute the disability amount
}
private boolean isNotEligableForDisability() {
return seniority < 2 || monthsDisabled > 12 || isPartTime;
}7. 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
以卫语句取代嵌套条件表达式,就是把嵌套很深的if-else,改成卫语句,先处理异常情况,提前返回,减少嵌套。
适用场景:
- if-else嵌套很深,很难理解。
- 条件中有异常情况,比如参数错误,状态错误,可以提前返回。
好处:
- 减少嵌套,代码更扁平,更容易理解。
- 正常逻辑放在最后,异常逻辑提前处理,更清晰。
示例: 重构前:
public double getPayAmount() {
double result;
if (isDead) {
result = deadAmount();
} else {
if (isSeparated) {
result = separatedAmount();
} else {
if (isRetired) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
}
return result;
}重构后:
public double getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}8. 函数对象化(Replace Method with Method Object)
函数对象化,就是把一个很长、很复杂的方法,变成一个对象,把方法的参数和局部变量,变成对象的字段,然后把方法拆成对象的多个小方法。
适用场景:
- 方法很长,很复杂,局部变量很多,很难提取方法,因为局部变量太多,提取方法要传很多参数。
- 方法的逻辑很复杂,需要拆成多个小方法,但是局部变量的作用域问题,很难拆。
好处:
- 把长方法拆成多个小方法,每个小方法职责单一,更容易理解和维护。
- 局部变量变成对象的字段,不需要在方法之间传递参数,更方便。
示例: 重构前:
public class Order {
public double price() {
double primaryBasePrice;
double secondaryBasePrice;
double tertiaryBasePrice;
// long computation involving many local variables
// ...
}
}重构后:
public class Order {
public double price() {
return new PriceCalculator(this).compute();
}
}
public class PriceCalculator {
private double primaryBasePrice;
private double secondaryBasePrice;
private double tertiaryBasePrice;
private Order order;
public PriceCalculator(Order order) {
this.order = order;
}
public double compute() {
// long computation, split into multiple small methods
computePrimaryBasePrice();
computeSecondaryBasePrice();
computeTertiaryBasePrice();
return primaryBasePrice + secondaryBasePrice + tertiaryBasePrice;
}
private void computePrimaryBasePrice() {
// ...
}
private void computeSecondaryBasePrice() {
// ...
}
private void computeTertiaryBasePrice() {
// ...
}
}9. 数据重构
数据重构,就是改善数据的结构,比如把基本类型提升成对象,把数据值变成对象,把数组变成对象等。
常用的数据重构:
- 以对象取代基本类型: 如果一个基本类型,有特殊的行为,或者需要校验,就把它提升成对象,比如电话号码、邮编、金额、日期等,不要都用String或者int。
- 以数据类取代记录: 如果用数组或者Map表示一组数据,就把它变成一个类,用字段表示数据,这样,类型更安全,也更容易理解。
- 封装字段: 把public的字段,变成private,提供getter/setter,封装数据,避免直接访问字段,提高可维护性。
- 以对象取代数组: 如果用数组表示一组不同含义的数据,比如array[0]是姓名,array[1]是年龄,就把它变成一个对象,用字段表示,这样,代码更容易理解,也更类型安全。
10. 类和接口的重构
类和接口的重构,就是改善类和接口的结构,提高代码的可维护性和可扩展性。
常用的类和接口的重构:
- 提取类: 如果一个类太大,职责太多,就把它拆成多个类,每个类职责单一,符合单一职责原则。
- 内联类: 如果一个类太小,没有什么职责,就把它内联到调用它的类里,减少类的数量。
- 隐藏委托关系: 如果客户端需要通过一个对象,调用另一个对象的方法,就把委托关系隐藏起来,在中间对象里提供方法,直接调用,减少客户端的耦合。
- 移除中间人: 如果一个类,只是简单地委托给另一个类,没有什么自己的逻辑,就把这个中间类去掉,让客户端直接调用真正的对象,减少不必要的间接层。
- 提取接口: 如果多个类,有相同的方法,就把这些方法提取成一个接口,让这些类实现这个接口,面向接口编程,提高可扩展性。
- 提取超类: 如果多个类,有相同的字段和方法,就把这些相同的部分,提取到一个超类里,让这些类继承这个超类,减少重复代码,符合DRY原则。
七、重构的测试保障
重构,最重要的就是测试保障,没有测试,不要重构,测试是重构的安全网,保证重构之后,功能不变。
- 单元测试: 重构之前,先写单元测试,覆盖主要的功能和边界条件,测试要独立,可重复,运行快。重构之后,运行单元测试,确保所有测试都通过,功能不变。
- 集成测试: 对于涉及多个模块、多个服务的功能,要有集成测试,保证模块之间的交互正确,重构之后,集成测试也要通过。
- 回归测试: 重构之后,要做回归测试,确保原来的功能都正常,没有因为重构,引入新的Bug。
- 测试驱动重构: 可以用测试驱动的方式重构,先写测试,再重构,重构之后,测试通过,这样,能保证重构的质量。
- 持续集成: 用持续集成,每次提交代码,都自动运行测试,确保重构没有破坏功能,如果测试失败,及时修复,或者回滚。
八、重构的注意事项
- 不要在重构的时候改功能: 重构和改功能,不要同时做,要么只重构,要么只改功能,同时做,出了问题,很难定位是重构导致的,还是改功能导致的。
- 小步快跑,每一步都测试: 重构要小步快跑,每次只做一个小的重构,每一步都运行测试,确保没有问题,再做下一个,不要一下子改很多代码。
- 不要过度重构: 重构是为了改善代码的结构,不是为了重构而重构,不要过度设计,不要把简单的事情搞复杂,代码已经很好了,就不需要重构。
- 注意性能: 重构的时候,要注意性能,不要因为重构,导致性能下降,比如,提取方法,增加了方法调用的开销,虽然一般影响不大,但是在性能敏感的地方,要注意。
- 注意兼容性: 如果是公共API,或者被其他模块、其他服务调用的代码,重构的时候,要注意兼容性,不要随便改方法签名,不要随便删方法,否则会影响调用方,导致编译失败或者运行错误。
- 及时提交: 重构的时候,每做一个小的重构,测试通过了,就及时提交到版本控制,这样,出了问题,可以随时回滚,不要改了很多代码,才提交,那样出了问题,很难回滚。
- 和团队沟通: 大的重构,要和团队成员沟通,达成共识,制定计划,分工合作,不要一个人偷偷摸摸地重构,改了很多代码,别人不知道,合并的时候冲突很多。
九、重构的误区
- 重构就是重写: 很多人以为,重构就是把代码全部扔掉,重新写一遍,其实不是,重构是在现有代码的基础上,逐步改善,小步快跑,每一步都不改变功能,都能运行,都能测试,而重写是全部扔掉,重新写,风险很大。
- 重构是浪费时间: 很多人觉得,重构是浪费时间,不如多做几个功能,其实不是,重构是为了长期的效率和质量,虽然重构的时候,要花一些时间,但是重构之后,代码更容易理解,更容易维护,更容易扩展,长期来看,能节省更多的时间,减少技术债务。
- 重构是新人的事: 很多人觉得,重构是新人的事,或者是代码写得不好的人的事,其实不是,每个人写的代码,都需要重构,包括大牛,因为没有人能一次就写出完美的代码,都是不断重构,不断优化的,而且,随着业务的发展,原来的代码,可能不再适合新的需求,也需要重构。
- 重构一次就够了: 很多人觉得,重构一次,代码就完美了,以后就不需要重构了,其实不是,代码是不断演化的,业务在变,需求在变,技术在变,代码也需要不断重构,不断优化,重构是一个持续的过程,不是一次就够了。
- 大重构才是重构: 很多人觉得,只有大的重构,比如拆分类,拆模块,才叫重构,小的重构,比如改个名字,提取个方法,不算重构,其实不是,小的重构,也是重构,而且是最常用的重构,日常开发中,大部分都是小的重构,积少成多,小的重构做多了,代码质量就提高了,就不需要大的重构了。
十、我的感悟和建议
代码重构,是程序员的基本功,也是提高代码质量的重要手段,我工作这么多年,越来越觉得,重构的重要性,写代码只是第一步,重构,才能让代码真正变得优雅、易维护。
我的建议:
- 把重构当成日常习惯: 不要等到代码烂得不行了,才重构,要把重构当成日常习惯,写代码的时候,随时重构,看到烂代码,就顺手改了,小步快跑,积少成多,这样,代码质量会一直保持在一个比较高的水平,不需要大的重构。
- 先补测试,再重构: 没有测试,不要重构,测试是重构的安全网,重构之前,先补测试,有了测试保障,再重构,这样,才能放心地改,不怕把功能改坏。
- 小步快跑,不要贪多: 重构要小步快跑,每次只做一个小的重构,每一步都测试,不要一下子做很大的重构,改很多代码,那样风险很大,出了问题也很难定位。
- 不要为了重构而重构: 重构是为了改善代码的结构,提高代码的质量,不是为了重构而重构,不要过度设计,不要把简单的事情搞复杂,代码已经很好了,就不需要重构。
- 团队一起做: 重构,不是一个人的事,是整个团队的事,要团队一起,制定规范,一起执行,代码评审的时候,关注代码质量,提出重构建议,大家一起,把代码质量搞上去。
- 持续学习: 重构的技巧和最佳实践,有很多,要持续学习,看经典的书,比如《重构:改善既有代码的设计》,看优秀的开源代码,学习别人是怎么写代码的,怎么重构的,不断提升自己的代码能力。
写在最后
代码重构技巧最佳实践:我总结了这些经验。
代码重构,是程序员的基本功,也是提高代码质量的重要手段,掌握重构的技巧和最佳实践,能让我们写出更优雅、更易维护的代码,也能更好地维护别人的代码。
重构不是重写,不是浪费时间,不是一次就够了,而是在现有代码的基础上,小步快跑,逐步改善,不改变功能,提高代码的可读性、可维护性、可扩展性,是一个持续的过程。
希望我的经验总结,能给大家一些参考,也希望大家都能重视重构,把重构当成日常习惯,写出更优雅、更易维护的代码,减少技术债务,提高开发效率。
最后,用一句话结尾:
"代码是写给人看的,只是顺便能在机器上运行。好的代码,应该是容易理解、容易维护、容易扩展的,而重构,就是让代码变得更好的重要手段。"
祝大家都能写出优雅的代码,工作顺利,少加班!
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录