论如何为垃圾代码擦屁股(持续更新中。。。)
前言
我曾经多次接手到垃圾项目的垃圾代码,替前人擦屁股是一件非常痛苦的事情,垃圾代码的产生有很多因素,说多了都是泪,这篇文章我就不讨论了,本篇文章主要想总结出一套接手他人代码和重构的指导方法,希望对苦难中的小伙伴有帮助。
调整心态
最重要的一点是调整心态,为什么这么说呢?在真实经历中,往往遇到前人把肉(利益)都吃了,只留给擦屁股的人烫喝,很多时候接手他人烂摊子会带着很重的负面情绪,调整好心态去接手这个烂摊子就顺理成章的成为了最重要的一点,职业素养这四个字往往体现在这个时候。
重构的原则
这里,我们再进一步讨论一下重构的原则。
何谓重构(What)
重构(Refactoring)
的常见定义是:不改变软件系统外部行为的前提下,改善它的内部结构。可以理解为:重构是给代码治病的行为。而代码有病是指代码的质量(可靠性、安全性、可复用性、可维护性)和性能有问题。
重构的目的是为了提高代码的质量和性能。
注:功能不全或者不正确,那是残疾代码。就像治病治不了残疾,重构也解决不了功能问题。
为何重构(Why)
翻翻书,上网搜一下,谈到重构的理由大体相同:
- 重构改进软件设计
- 重构使软件更容易理解
- 重构帮助找到 bug
- 重构提高编程速度
总之就是,重构可以提高代码质量。
何时重构(When)
对于一个高速发展的公司来说,停止业务开发,专门来做重构项目,从来就不是一个可接受的选项,“边开飞机边换引擎”才是这种公司想要的,不妨来衡量一下重构的成本和收益。
- 重构的成本
重构是有成本的,费时费力(时间、人力)不说,还有可能会使本来正常运行的程序出错。所以,很多人都抱着“不求有功,但求无过”的心理得过且过。
还有一种成本:重构使用较新且较为复杂的技术,学习曲线不平滑,团队成员技术切换困难,短期内开发效率可能不升反降。
但是,如果一直放任代码腐朽下去,技术债务会越来越沉重。当代码最终快要跑不动时,架构师们往往还是不得不使用激进的手段来治疗代码的顽疾。但是,这个过程通常都是非常痛苦的,而且有着很高的失败风险。
- 重构的收益
重构的收益是提高代码的质量和性能,并提高未来的开发效率。但是,应当看到,重构往往并不能在短期内带来实际的效益,或者很难直观看出效益。而对于一个企业来说,没有什么比效益更重要。换句话说,没有实际效益的事,通常也没有价值。很多领导,尤其是非技术方向的领导,并不关心你应用了什么新技术,让代码变得多么优雅等等。
较能凸显重构价值的场景是:代码规模较大、生命周期还较长、承担了较多责任、有一个较大(且较不稳定,人员流动频繁)团队在其上工作的单一代码库。
- 重构的合适时机
重构本应该是个渐进式的过程,不是只有伤筋动骨的改造才叫重构。如果非要等到代码已经烂到病入膏肓,再使用激进方式来重构,那必然是困难重重,风险极高。《重构》书中也提到的重构时机应该在添加功能、修复功能、审查代码时,不建议专门抽出时间专门做重构项目。
- 重构的不恰当时机
下面这些场景重构价值很小不建议重构:
- 代码库生命周期快要走到尾声,开发逐渐减少,以维护为主。
- 代码库当前版本马上要发布了,这时重构无疑是给自己找麻烦。
- 重构代价过于沉重:重构后功能的正确性、稳定性难以保障;技术过于超前,团队成员技术迁移难度太大。
如何重构(How)
重构行为是可以分层级的。由高到低,越高层级难度越大:
-
服务、数据库
现代软件往往业务复杂、庞大。使用微服务、数据迁移来拆分业务,降低业务复杂度成为了主流。但是,这些技术的测试、部署复杂,技术难度很高。 -
组件、模块、框架
组件、模块、框架的重构,主要是针对代码的设计问题。解决的是代码的整体结构问题。需要对框架、设计模式、分布式、并发等等有足够的了解。 -
类、接口、函数、字段
《重构》一书提到了“代码的坏味道”以及相关的重构方法。这些都是对类、接口、函数、字段级别代码的重构手段。由于这一级别的重构方法较为简单,所以可操作性较强。具体细节可以阅读《代码的坏味道》篇章。
前两种层级的重构已经涉及到架构层面,影响较大,难度较高,如果功力不够不要轻易变动。由于这两个层级涉及领域较广,这里不做论述。
代码的坏味道
《重构:改善既有代码的设计》文章中介绍了 22 种代码的坏味道以及重构手法。这些坏味道可以进一步归类。我总觉得将事物分类有助于理解和记忆。所以本系列将坏味道按照特性分类,然后逐一讲解。
代码坏味道之代码臃肿
代码臃肿(Bloated) 意味着:代码中的类、函数、字段没有经过合理的组织,只是简单的堆砌起来。这一类型的问题通常在代码的初期并不明显,但是随着代码规模的增长而逐渐积累(特别是当没有人努力去根除它们时)。
过长函数(Long Method)
一个函数含有太多行代码。一般来说,任何函数超过 10 行时,你就可以考虑是不是过长了。 函数中的代码行数原则上不要超过 100 行。
原因:
通常情况下,创建一个新函数的难度要大于添加功能到一个已存在的函数。大部分人都觉得:“我就添加这么两行代码,为此新建一个函数实在是小题大做了。”于是,张三加两行,李四加两行,王五加两行。。。函数日益庞大,最终烂的像一锅浆糊,再也没人能完全看懂了。于是大家就更不敢轻易动这个函数了,只能恶性循环的往其中添加代码。所以,如果你看到一个超过 200 行的函数,通常都是多个程序员东拼西凑出来的。
解决方法:
- 提炼函数(Extract Method)
- 以查询取代临时变量(Replace Temp with Query)
- 引入参数对象(Introduce Parameter Object)
- 保持对象完整(Preserve Whole Object)
- 以函数对象取代函数(Replace Method with Method Object)
- 分解条件表达式(Decompose Conditional)
过大的类(Large Class)
一个类含有过多字段、函数、代码行。
原因:
类通常一开始很小,但是随着程序的增长而逐渐膨胀。类似于过长函数,程序员通常觉得在一个现存类中添加新特性比创建一个新的类要容易。
解决方法:
设计模式中有一条重要原则:职责单一原则。一个类应该只赋予它一个职责。如果它所承担的职责太多,就该考虑为它减减负。
- 如果过大类中的部分行为可以提炼到一个独立的组件中,可以使用提炼类(Extract Class)。
- 如果过大类中的部分行为可以用不同方式实现或使用于特殊场景,可以使用提炼子类(Extract Subclass)。
- 如果有必要为客户端提供一组操作和行为,可以使用提炼接口(Extract Interface)。
- 复制被监视数据(Duplicate Observed Data)
基本类型偏执(Primitive Obsession)
使用基本类型而不是小对象来实现简单任务(例如货币、范围、电话号码字符串等)。
使用常量编码信息(例如一个用于引用管理员权限的常量USER_ADMIN_ROLE = 1 )。
使用字符串常量作为字段名在数组中使用 。
原因:
一开始,可能只是不多的字段,随着表示的特性越来越多,基本数据类型字段也越来越多。基本类型常常被用于表示模型的类型。你有一组数字或字符串用来表示某个实体。
解决方法:
大多数编程语言都支持基本数据类型和结构类型(类、结构体等)。结构类型允许程序员将基本数据类型组织起来,以代表某一事物的模型。
基本数据类型可以看成是机构类型的积木块。当基本数据类型数量成规模后,将它们有组织地结合起来,可以更方便的管理这些数据。
- 如果你有大量的基本数据类型字段,就有可能将其中部分存在逻辑联系的字段组织起来,形成一个类。更进一步的是,将与这些数据有关联的方法也一并移入类中。为了实现这个目标,可以尝试以类取代类型码(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) 。
过长参数列(Long Parameter List)
一个函数有超过 3、4 个入参。
原因:
过长参数列可能是将多个算法并到一个函数中时发生的。函数中的入参可以用来控制最终选用哪个算法去执行。
过长参数列也可能是解耦类之间依赖关系时的副产品。例如,用于创建函数中所需的特定对象的代码已从函数移动到调用函数的代码处,但创建的对象是作为参数传递到函数中。因此,原始类不再知道对象之间的关系,并且依赖性也已经减少。但是如果创建的这些对象,每一个都将需要它自己的参数,这意味着过长参数列。
太长的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦需要更多数据,就不得不修改它。
解决方法:
- 如果向已有的对象发出一条请求就可以取代一个参数,那么你应该使用以函数取代参数(Replace Parameter with Methods) 。
- 还可以运用保持对象完整(Preserve Whole Object) 将来自同一对象的一堆数据收集起来,并以该对象替换它们。
- 如果某些数据缺乏合理的对象归属,可使用引入参数对象(Introduce Parameter Object) 为它们制造出一个“参数对象”。
数据泥团(Data Clumps)
有时,代码的不同部分包含相同的变量组(例如用于连接到数据库的参数)。这些绑在一起出现的数据应该拥有自己的对象。
原因:
通常,数据泥团的出现时因为糟糕的编程结构或“复制-粘贴式编程”。
有一个判断是否是数据泥团的好办法:删掉众多数据中的一项。这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是个明确的信号:你应该为它们产生一个新的对象。
解决方法:
- 提炼类(Extract Class)
- 引入参数对象(Introduce Parameter Object)
- 保持对象完整(Preserve Whole Object)
常用技巧
1. 以类取代类型码(Replace Type Code with Class)

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

2. 保持对象完整(Preserve Whole Object)
int low = daysTempRange.getLow();
int high = daysTempRange.getHigh();
boolean withinPlan = plan.withinRange(low, high);
从某个对象中取出若干值,将它们作为某一次函数调用时的参数, 改为传递整个对象。
boolean withinPlan = plan.withinRange(daysTempRange);
3. 以子类取代类型码(Replace Type Code with Subclass)

有一个不可变的类型码,它会影响类的行为,以子类取代这个类型码。

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

5.以对象取代数组(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");
6. 以函数取代参数(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);
7. 提炼函数(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);
}
8. 以查询取代临时变量(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;
}
9. 引入参数对象(Introduce Parameter Object)


10. 以函数对象取代函数(Replace Method with Method Object)
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.
//...
}
}
11. 分解条件表达式(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);
}
12. 提炼类(Extract Class)

某个类做了不止一件事, 建立一个新类,将相关的字段和函数从旧类搬移到新类。

13. 提炼子类(Extract Subclass)

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

14. 提炼接口(Extract Interface)

多个客户端使用一个类部分相同的函数, 另一个场景是两个类中的部分函数相同。移动相同的部分函数到接口中。

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

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

代码坏味道之滥用面向对象TODO
滥用面向对象(Object-Orientation Abusers) 意味着:代码部分或完全地违背了面向对象编程原则。
代码坏味道之变革的障碍TODO
变革的障碍(Change Preventers) 意味着:当你需要改变一处代码时,却发现不得不改变其他的地方。这使得程序开发变得复杂、代价高昂。
代码坏味道之非必要的TODO
非必要的(Dispensables) 意味着:这样的代码可有可无,它的存在反而影响整体代码的整洁和可读性。
代码坏味道之耦合TODO
耦合(Couplers)意味着:不同类之间过度耦合。