读重构(改善既有代码的设计)一点心得
序言
距离我读重构这本书已经过去了很久,最近需要分享一下读这本书的感悟心得,可能不能详尽的阐述书中提到的所有细节,不过我会尽力而为。虽然这么说,当初的印象已经不深了,所以又快速回顾了一下,由于看的是中译本,不管是因为自身的原因,还是译者与原作者理解上的偏差,如果有纰漏还请见谅(有能力的可以看一下英文原版)
正式重构代码之前
作者在介绍重构代码的技巧或者说方法之前,先是使用一个简单的案例作为整本书的起始,虽然简单,但涵盖了大量的重构方法,有的方法也许看上去太过简单,是我们每天都在用的,不过在看后续分别介绍各种重构方法时,可以回过头来与这个案例结合,思考作者的重构思路,便是不小的收获了。
接着这个案例的后续三个章节,是对正式重构之前,一些概念、注意点的梳理,让我们来看一下:
首先需要给出的一点便是,什么是重构,在重构原则这一章中,作者在这里给出了两个定义,分别是对重构作为名词和动词时的概念。
重构作为名词时,其定义是:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构作为动词时,其定义是:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
这是原作者的定义,不管作为动词或者名词,重构这一概念,其前提都是需要不改变软件可观察行为,而需要改变的是软件内部结构,这也是指导重构方法的核心原则。
作者在介绍重构概念时,单独写个一个部分,以两顶帽子为标题,作为重构的前提即不改变软件可观察行为的引申,也可以看做对这个概念进一步的阐述。两顶帽子比喻在开发过程中,程序员会进行两种类型的工作,重构以及添加新功能。当你在做其中一件事时,就好比戴上了对应的帽子,此时因为专注于这件事上,所以当你在重构时,不应该添加新功能,反之亦然,这也就是作者说的重构需要不改变软件可观察行为。同一时间只应该戴上一顶帽子,并且应该清楚自己现在是戴的那顶帽子。(如果你说同时戴两顶帽子也可以,那就祝你好运)
下面作者阐述了为何需要重构,这里不展开说明。接下来是何时重构,这个也许也是需要提一下的。作者反对专门抽出时间来做重构,他的观点中,重构不应该是目的,而是手段,是可以帮助你做好其他事的手段。
这里作者提到了一个准则,也许可以作为参考:
事不过三,三则重构。这也是对经典的DRY原则(Don't repeat yourself)的一种应用,当出现重复时,便是需要重构的时候。作者在后面还写了其他重构时机,这里不展开。
作者在重构的难题中,提到了一些需要注意的地方。不要过早的发布接口,注意管理自己负责的代码的可见性,也就是说,在团队协作时,如果其他团队成员越多的使用了你负责的代码,对于你修改这部分代码,就会越发麻烦,如果这些代码只有你自己在使用,修改起来就会相对轻松不少。更关键的是,这还是同公司一个开发团队中,如果这些代码需要继续向外开放,那么设计这部分代码的可见性时,需要更加的小心仔细。
关于何时不应该重构,作者的阐述中,可以感受到其对这一点的谨慎态度。如果可能的话,重构应该是一直需要的,也许关于这一点,影响程序员做出此时不应该重构的因素,很有可能不是技术上的。
在代码的坏味道这一章中,作者通过这个比喻,来说明什么样的代码需要进行重构。其中原书中写的非常详细,这里不做更多的说明了。
后面一章是构筑测试体系,作者在本书第一个例子开始,就强调了测试的重要性,这里作者强调的是单元测试,为的正是重构的前提,保证不改变软件的可观察行为。如果没有测试,那么在进行重构时,对于有没有破坏其他代码逻辑,有没有影响软件行为,心里并没有底,这里并不是测试可以完美的避免这种情况,只是想要强调,相比没有测试的环境,重构带有测试的代码,确保没有破坏软件行为的可能性大大提高。
同时,展开来说,可能在这里与重构这一主题关系不是很大,测试对于软件的设计也会产生正面的影响。为了测试代码,需要实现代码中待测试代码的可测试性,这也会驱动程序员写出与其他模块低耦合的代码,这也是测试驱动开发的真正价值所在。当然写测试因为会增加一时的工作量,所以有的时候会不受重视,这里可以给出几点建议:
1)针对逻辑复杂或者容易出错的代码编写测试,如果已经有bug出现,则需要编写测试复现此bug,以便以后在开发过程中不会再次引入这个bug
2)针对变化不大的模块优先编写测试。在程序开发中,ui肯定是最容易变化的,可以放在最后考虑,而系统的核心逻辑,相对来说变化的频率更低,同时也是第一条里提到的可能逻辑比较复杂的部分,相对的需要更全的测试
关于如何写好单元测试,这里只是一个引子,可以作为开始测试的第一步。
开始重构
1、提炼函数
当代码中存在过长的函数,或者需要使用注释来阐明一段代码的用途时,应该考虑可以从中提炼出函数。过长的函数,很容易让人望而生畏,并且包含多个职责,这个时候,就应该把各个职责的代码,提炼成函数;另一种情况,当代码需要注释来说明的时候,就应该将其提炼成一个函数,并且函数名需要仔细斟酌,以便表达原先的注释索要表达的意思。
这里顺便说一点对注释的理解。当你想要对函数,类,接口添加注释时,需要考虑到,注释也是需要与时俱进的更新的,函数,类,接口随着不断开发,其行为和内在的意义也会随着变化,很多时候,代码更新了,然而注释却依然留在那里,给以后的维护者带来困扰,这个时候,注释并没有起到其应有的作用,相反增加了维护的成本。所以,如果可以的话,让函数名,类名, 接口名直接表达其行为,并且在内在逻辑行为更新时,更新函数名,类名,接口名,表达当前的行为。考虑如下代码:
void printOwing{
double outstanding = 0.0;
//print banner
System.out.println("******");
System.out.println("***Customer Owes***");
System.out.println("******");
}
其可以重构为
void printOwing{
double outstanding = 0.0;
printBanner();
}
void printBanner(){
System.out.println("******");
System.out.println("***Customer Owes***");
System.out.println("******");
}
2、分解临时变量
临时变量有很多用途,很多时候,它们被用来保存一段代码的运算结果,以便稍后使用,这种临时变量应该只被赋值一次,如果它被赋值了多次,说明它承担了多个责任,而这会导致代码阅读者产生困惑,此时它就应该被分解为多个临时变量。(当然常用的循环变量不需要)
考虑如下代码:
```java
double getDistance(int time){
double result;
double acc = getPrimaryForce()/getMass();
int primaryTime = Math.min(time,getDelay());
result = 0.5 * acc * getPrimaryTime() * getPrimaryTime();
int secondaryTime = time - getDelay();
if(secondaryTime > 0){
double primaryVel = acc * getDelay();
acc = (getPrimaryForce() + getSecondForce())/getMass();
result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime
* secondaryTime;
}
return result;
}
其中acc被赋值了两次,并且保存的值的含义是不同的,可以考虑重构为:
double getDistance(int time){
double result;
final double primaryAcc = getPrimaryForce()/getMass();
int primaryTime = Math.min(time,getDelay());
result = 0.5 * primaryAcc * getPrimaryTime() * getPrimaryTime();
int secondaryTime = time - getDelay();
if(secondaryTime > 0){
double primaryVel = primaryAcc * getDelay();
double acc = (getPrimaryForce() + getSecondForce())/getMass();
result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime
* secondaryTime;
}
return result;
}
3、搬移字段
在开发过程中,有的时候,你会遇到这种情况,对于一个字段,在其所驻类之外的另一个类中有更多的函数使用了它,就应该考虑搬移这个字段。上述所谓使用,除了直接使用此字段(如果可以的话),也包括调用其赋值函数和取值函数。
考虑如下代码:
```java
class Account{
private double interestRate;
public double getInterestRate(){
return interestRate;
}
public void setInterestRate(double interestRate){
this.interestRate = interestRate;
}
}
class AccountType{
private Account account;
public void interestForAmountDays(double amount,int days){
return account.getInterestRate() * amount * days /365;
}
}
可以考虑重构为:
class AccountType{
private double interestRate;
public double getInterestRate(){
return interestRate;
}
public void setInterestRate(double interestRate){
this.interestRate = interestRate;
}
public void interestForAmountDays(double amount,int days){
return getInterestRate() * amount * days /365;
}
}
4、重构魔法数
在软件行业中,魔法数(magic number)大概是历史最悠久的不良现象之一了。所谓魔法数是指拥有特殊意义,却又不能明确表现出这种意义的数字。许多语音都允许声明常量,以指代魔法数。不过,在这里,我们应该观察魔法数是如何被使用的。如果魔法数是一个类型码,考虑使用类代替类型码(这个在稍后介绍);如果魔法数代表的是一个数组的长度,则可以使用Array.length替代;如果都不是的话,再考虑使用常量替代。
5、使用类代替类型码
在使用类型码替代魔法数的时候,有一个问题,类型码只是这个数值的别名,任何使用类型码作为参数的函数中,所期望的实际是一个数值,编译器无法施加强制检查以保证只使用类型码。这个时候,如果使用类替代类型码,编辑器就可以进行类型检查,使得调用更加安全。但是,只是类型码是纯数值,而不会对程序行为有影响时,才可以考虑使用类替代。
考虑如下代码,Person类中有作为其血型的类型码:
```java
class Person{
public static final int O = 0;
public static final int A = 1;
public static final int B = 2;
public static final int AB = 3;
private int bloodGroup;
public Person(int bloodGroup){
this.bloodGroup = bloodGroup;
}
public int getBloodGroup(){
return bloodGroup;
}
}
可以考虑如此重构,首先建立一个血型类:
enum BloodGroup{
O,A,B,AB
}
然后修改Person类,使用枚举,然后将针对血型的函数放到枚举类中。
6、封装集合
我们常常会在一个类中使用集合来保存一组实例,同时会提供针对此集合的取值/赋值函数。但是,集合的处理方式,是应该有别于其他种类的数据的。首先,取值函数不应该返回集合本身,因为这会让集合使用者有意或意外的修改集合内容,同时集合拥有者对此却一无所知。如果集合使用者只需要查询集合内容,则应该只提供集合的不可变的只读副本,这也额外提供了线程安全性。如果集合使用者还需要可以修改集合内容,那么也不应该提供集合的赋值函数,而是提供添加或删除集合元素的函数,这可以提供集合拥有者更精细的控制能力。
考虑如下代码:
```java
class Person{
private List<Course> courses;
public List<Course> getCourses(){
return courses;
}
public setCourser(List<Course> courses){
this.course = courses;
}
}
可以重构为如下版本:
class Person{
private List<Course> courses = new ArrayList<Course>;
public List<Course> getCourses(){
return Collections.unmodifiedList(courses);
}
public addCourse(List<Course> courses){
this.courses.addAll(courses);
}
public removeCourse(List<Course> courses){
this.courses.removeAll(courses);
}
}
上述重构只是针对集合的取值/赋值,原书中这一部分还结合了其他重构方法,大家可以参阅。
7、将查询函数和修改函数分离
书中提到了一个很好的原则:任何有返回值的函数,都不应该有看得到的副作用。这样做的好处是,可以任意调用此函数,重构调用此函数的函数,也会轻松很多。当然这里使用了“看得到的副作用”这一说法,要解释这点,考虑这种情况:查询函数将查询结果缓存起来,以便加快后续查询的速度,这种修改对于调用者来说是感知不到的,因为不论何时查询,都会返回相同结果(当然可以设计逻辑使缓存失效,这里不多做说明)。
8、移除对参数的赋值
在函数签名中有参数声明是非常常见的情况,同时很多时候还会对参数赋值,不过,这里所说的对参数赋值,不是指在参数对象上进行什么操作,而是改变这个参数,是它引用另一个对象。这里我为什么要提到这点,是因为原书在解释这一节时,讨论了Java的传参方式,Java只采用按值传递的方式,这点我之前一直是以为传参方式是区分引用对象和原始数据类型。
原书中作者提到了这么几个例子,
class Param{
public static void main(String[] args){
int x = 5;
triple(x);
System.out.println("x after tripple: " + x);
}
private static void tripple(int arg){
arg = arg * 3;
System.out.println("arg in tripple: " + arg);
}
}
上述例子使用原始数据类型会产生这样的输出:
arg int tripple : 15
x after tripple: 5
而如果参数传递的是对象,那情况就会有所不同,下面代码中传递代表日期的Date对象:
class Param{
public static void main(String[] args){
Date d1 = new Date("1 Apr 98");
nextDateUpdate(d1);
System.out.println("d1 after nextDay: " + d1);
Date d2 = new Date("1 Ar 98");
nextDateReplace(d2);
System.out.println("d2 after nextDay: " + d2);
}
private static void nextDateUpdate(Date arg){
arg.setDate(arg.getDate() + 1);
System.out.println("arg in nextDay: " + arg);
}
private static void nextDateReplace(Date arg){
arg = new Date(arg.getYear(),arg.getMonth(),arg.getDate + 1);
System.out.println("arg in nextDay: " + arg);
}
}
上述程序产生的输出:
arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d1 after nextDay: Thu Apr 02 00:00:00 EST 1998
arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d2 after nextDay: Wed Apr 01 00:00:00 EST 1998
之所以会产生这样的结果,是因为对象的引用也是按值传递的,因此修改对象内部状态没什么问题,但对参数重新赋值,就需要仔细斟酌。
先写到这里
重构这本书里还有大量重构手法,以及一个专门讲解大型重构的篇章,不过我这里先写到这里。读者如果仔细思考的话,可以发现,很多重构的核心思想,在其他书籍里也提到,就比如将查询和修改函数分离,就体现了软件开发的经典思想:单一职责原则,其实SOLID原则之一,当然其他的原则(开放封闭原则,里氏替代原则,接口隔离原则,依赖倒置原则)也都有体现,而且这些原则不仅仅指导重构技巧,其贯穿整个软件生命周期,良好的应用这些原则,可以带来很多好处。
另外,原书中的最后一张总结部分,提到了其他关于重构的部分。上述的重构技巧或者称为方法,只是进入重构世界的敲门砖,真正的困难在于,你需要知道何时开始重构,何时停止,应该以什么节奏进行重构。毕竟今天重构之后让你满意的代码,明天你就会觉得这么重构是完全有问题的,所以原书的作者提醒我们,每一步重构都应该是可以返回到原始状态的,你是可以走回头路的,而且书中大量的重构方法,很多是对于另一种方法的翻转,使你不至于陷入窘境。
原书的作者最后再次重申永远不要忘记“两顶帽子”。毕竟重构时,你会发现某些代码不正确,并且你有绝对相信自己的判断,因此想马上修改这处代码。稍等,如果你现在在重构,那么久应该先做好重构,而重构并不是修改代码功能,对于这些修改点,也许你可以使用TODO记录下来,重构完再来修改它,毕竟很多时候,当你同时重构和修改代码功能bug,然后发现程序出问题了,这个时候你却无法判断是重构还是修改代码功能导致的,也许你想调试一下很快就解决了,不过原作者的经验告诉你,调试所花的时间也许只有几分钟,也许就要几小时了。