重构

作者:黄色潜水艇
链接:https://www.zhihu.com/question/29596513/answer/46252024
来源:知乎
著作权归作者所有,转载请联系作者获得授权。

<--------- Modified at 2015/12/13 start --------->
以下是关于重构的个人书单。
两本就够了,别的都大同小异,有兴趣的朋友可以去读读:
代码整洁之道
重构:改善既有代码的设计
<--------- Modified at 2015/12/13 end --------->

<--------- Modified at 2015/11/18 start --------->

有朋友知道怎么开专栏么?想在知乎找块地专门谈谈代码的那些事儿。
<--------- Modified at 2015/11/18 end --------->

题主,我来告诉你解决你目前困境的不二法门。不要说区区五六百行的程序,再大到上W行的代码,都可以 让你满怀信心的解决。这个法门就叫做-- 重构
不用担心,它是有方法论的。只要你按下面的步骤, 小步修改,确保每次修改后通过测试。我保证:
变量众多,再牵涉到相互之间的逻辑
1.你会对这500多行代码的整体结构一目了然,豁然开朗;

生怕某个地方改错,导致不可知的错误
2.对每一次修改都充满信心,对它的影响范围都了如指掌,不担心会引起代码的degree;

接下来开始我们的重构:
1.给每一个变量和函数重命名
重命名的方法是,让每一个变量或函数的意义明确,一目了然。

如果你看到一个i,不要默认它是循环变量,给它取一个有意思的名字;
如果你想给“对战”取一个变量名,不要这么写:dz。如果你写成fight,下一个读代码的人会对你感激不尽;
如果insert函数是插入成绩,那么insertScore是一个更合适的函数名称;
如果你的函数里,做了成绩查询和修改处理,那么用retrieveAndModifyScore来命名它;
做完如此之后,如果你发现乃至 所有的注释都是多此一举,那么恭喜你:Well done!

然后Run -> Test,谨小慎微的确保我们的该步操作大功告成。

接下来我们深入函数的内部,厘清那些让我们力不从心的复杂逻辑。
2.改写if else逻辑
繁多的if else,if中的if else,else中if else,这些庞大的逻辑块看上去就让人云山雾绕,那是是时候祭出重构公式来简化它了。
重构的思路为:if和else的逻辑我们只能二选其一。
对于if,我们可在if的逻辑块里加上一个return,表明我们执行完if之后,不再涉及else的代码。
对于else,由于之前一部已确保if的逻辑执行后会返回,我们大可放心的将else的代码提出来,去掉else这个关键字。
话不多说,直接上例子:
if ( m_Prop.A == 1 )
{
    if ( m_Prop.B == 1 )
    {
        return "Good";
    }
    else if ( m_Prop.B == 2 )
    {
        return "Not too bad";
    }
    else return "Just so so";
}
else if ( m_Prop.A == 2 )
{
    if ( m_Prop.B == 1 )
    {
        if ( m_Prop.C == 1 )
        {
            return "Need improvement";
        }
        else return "Need more improvement";
    }
    else if ( m_Prop.B == 2 )
    {
        return "Have a chance to improve";
    }
    else return "Too bad";
}
这是一段重构前的代码,每每看到这里,都有一种给跪了的感觉。
于是第一步的重构开始了,我们的第一次,就从 else if ( m_Prop.A == 2 )开始,从去掉else开始。
这次重构后的代码如下,是一次简单的不能再简单的修改。
if ( m_Prop.A == 1 ){
    if ( m_Prop.B == 1 )
    {
        return "Good";
    }
    else if ( m_Prop.B == 2 )
    {
        return "Not too bad";
    }
    else return "Just so so";
}

if ( m_Prop.A == 2 )
{
    if ( m_Prop.B == 1 )
    {
        if ( m_Prop.C == 1 )
        {
            return "Need improvement";
        }
        else return "Need more improvement";
    }
    else if ( m_Prop.B == 2 )
    {
        return "Have a chance to improve";
    }
    else return "Too bad";
}
以此类推的重构后,得到如下代码:

if ( m_Prop.A == 1 ){
    if ( m_Prop.B == 1 )
    {
        return "Good";
    }

    if ( m_Prop.B == 2 )
    {
        return "Not too bad";
    }

     return "Just so so";
}

if ( m_Prop.A == 2 )
{
    if ( m_Prop.B == 1 )
    {
        if ( m_Prop.C == 1 )
        {
            return "Need improvement";
        }
        return "Need more improvement";
    }
    if ( m_Prop.B == 2 )
    {
        return "Have a chance to improve";
    }
    return "Too bad";
}

如果 重构后的代码再也不见else,而逻辑上你很清楚与重构前相等,那么恭喜你,这一步也完成了!

在每一次if else的修改后,Run -> Test,谨小慎微的确保我们的该步操作大功告成。


--------------------------------------------
更新于2015/05/11 --------------------------------------------

感谢大家热情的反馈。上周因为换工作的事没来得及更新,换工作后白天也不能上外网,我将尽量利用晚上的时间,在这周之内更完余下的部分。
今天探讨的是 去除重复

言归正传之前,继续说说 2.改写if else逻辑。
记得大学学谭总的C语言,教材上仿佛提到过:一个函数只能有一个出口。前面提到if之后立即return,似乎有违此理。
好在这么写并不违背语法,于是我也可以斗胆谈谈这么做的初衷:
1.可读性
当函数中出现任一一个return,即意味着这个分支到此为止。这样一个个return,将一块块的逻辑分支分段。读代码的人只需专心理解每一段的逻辑,而不必耗费精力,深挖该段逻辑之后是否还有额外的处理--因为我们的代码已经return了。
2.避免误修改
诚然我们可以在每个分支的结尾,将返回值赋值给一个新的变量,在函数的末尾返回这个变量。这样做保证了函数只有一次返回,并且该处还可以做一些诸如异常捕捉的统一处理。
但是,但是这就没法保证,这个作为返回值的变量,不会在后续的处理中被意外的修改。俗话说夜长梦多,个人建议还是该出手时就出手,能返回时就返回。

关于这个话题,最后还是将评论里 侯天资兄的评论借花献佛:
进入enum用switch-case逻辑模块+带现实意义的常量作为case的值进行捕捉和处理,这个也会比==1, !=2, >=3这种if-else的逻辑判断的可读性好得多得多
即使小小的if-else也需要我们足够努力,才能让读代码的人读的不费吹灰之力。

写到这里,想请大家思考个问题:当我们要做什么的时候,我们会想到if-else / switch?
是否我们是想基于不同的业务触发条件,来做不同的数据处理?亦或是判断对象的不同状态,来调用它不同的行为响应?
好吧,我想各位已经明白我想表达的。不明白的话,可以看看多态的定义。
有人的地方就有江湖,有分支的地方就可能改为多态。
关于这个话题,后面有机会再行展开。

当逻辑分支重构得不那么令人头疼的时候,我们来说说最令代码修改者闻风丧胆、谈之色变的问题--代码重复。
3.去除重复代码
过往的项目经验里,最让人头疼的便是修改重复代码,无论是因为需求变更,还是因为bug修改。当你用着关键字,一遍遍的CTRL+F,将找到之处再一遍遍的用类似的代码替换时,是否已经精疲力尽?更糟糕的是,你还会担心这样的查找,会不会有漏网之鱼。在某个可能无法用关键字找出的角落,会不会还有类似需要修改的地方,让你担惊受怕?
遗憾的是,很多programmer意识不到这样的无用功是因为代码重复产生的。 当一行行的代码用CTRL+C、CTRL+V的方式产生时,恶趣味的重复代码就注定生成了。
将重复的代码块浓缩为函数,使用一次就调用一次这个函数,直到找不出类似代码


我相信有时候并不是程序员不知道如何解决前文提到的困扰,他们只是缺乏这么做的信心和勇气。
想想终将修改这些重复代码时,你的疲乏和担心。反正都要一个个的修改,何不如一个个的替换为函数。 用函数代替重复代码块,Run -> Test,替换一处就测试一次,直到全部的重复代码都变为函数调用。



--------------------------------------------
更新于2015/05/12 --------------------------------------------
完成了变量、代码块的重构后,接下来谈谈如何重构一个函数。
4.让函数只做一件事情
我大学刚开始写C代码时,脑子里充满了如果XXX,就XXX,然后XXX的逻辑思维。
翻译成C语言,就是main里面一大段一气呵成的代码。后来觉着这样不对啊:没用到函数啊!于是乎将相关又相邻的代码提炼出来,取一个函数名,函数调用一用上,大功告成!
就好像下面这个修改密码的函数,咋一看逻辑清楚,似乎也没啥大问题:
public Boolean ChangePassword(int id, String oldPassword, String newPassword) {
    oldPassword = EncodeHelper.MD5(oldPassword.trim());
    newPassword = EncodeHelper.MD5(newPassword.trim());

    if (!oldPassword.Equals(this.GetStudent(id).LoginPassword)) {
	 return false;
    }
	
    return this.UpdateStudent(new Student(id, newPassword));
}

可是,若后续要修改密码的验证逻辑,我们还能否记得,在ChangePassword的函数里,留有一行小小的if语句必须修改?
重构函数的方法,也是函数分割的基准:一个函数一次只能做一件事。
而一个函数一次能做的,有且仅有下面三件事~~之一:
1.查询
2.修改
3.调用上述两个过程

按照这个思路,重构上面的代码:
public Boolean ChangePassword(int id, String oldPassword, String newPassword){
    if(this.ValidateOldPassword(id, oldPassword)) {
        return this.SaveNewPassword(id, newPassword);
    }
    return false;
}

private Boolean ValidateOldPassword(int id, String oldPassword){
    oldPassword = EncodeHelper.MD5(oldPassword.trim());
    return oldPassword.Equals(this.GetStudent(id).LoginPassword);
}

private Boolean SaveNewPassword(int id, String newPassword){
    newPassword = EncodeHelper.MD5(newPassword.trim());
    return this.UpdateStudent((new Student(id, newPassword)));
}

完成上述重构后, 每个函数体中的代码逻辑,一定是围绕着函数名所示的功能,绝无多余。
函数一次只做一件事,至少有如下2点好处:
1.可读性强
我们甚至不用深入函数体,只浏览函数名就能理解其逻辑意义。
2.便于维护修改
上面的例子中,若要修改密码的验证逻辑,我们只需修改ValidateOldPassword()即可。

此外,功能单一,划分维度细小的函数也有助于发现代码中的漏洞。
例如下面的代码展示了一个游戏在服务器端,是如何返回PVP画面显示用的JSON:
//fightHistories:从PVP表中获得的记录列表
public String getPVPResult(int userId, int rivalId, List<FightHistoryDao> fightHistories){
    String myProfile = getMyProfile(userId);
    String rivalProfile = getRivalProfile(rivalId);
    String fightHistory = getFightHistories(fightHistories);
    return generateJsonForPVPResult(myProfile, rivalProfile, fightHistory);
}

private String getFightHistories(List<FightHistoryDao> fightHistoryDaos){
    for(FightHistoryDao oneFightScore in fightHistoryDaos) {
        //{取得对战时间}
        //{取得转化为百分数后的分数}
        //{取得作战详细}
}
这段代码若是按照上述的方法进行重构分析,在第1和第4步是能够发现潜在的漏洞的:
在getFightHistories()中,{取得转化为百分数后的分数}做了不止一件事:
既将画面显示用的分数取出,做了格式转换;
同时由于oneFightScore是List中的一个对象,因而悄无声息地修改了fightHistories中记录的值。

getFightHistories这个函数既查询了数据,又修改了数据。更糟糕的是,单单看这个函数名,我们很难想到它修改了fightHistories。

若后续代码修改,调用getFightHistories之后,再次使用了已被修改过的fightHistories变量,产生不可预料的结果就不足为奇了。

为了避免上述问题,请严格按照一次只做一事的标准分割函数,并且参数传递时尽量使用immutable变量


然后Run -> Test,保证每分割出一个函数,中间的调用和返回都准确无误。


--------------------------------------------更新于2015/05/14--------------------------------------------

5.减少函数参数

公用函数的参数越多,调用时了解的细节必须越多,不利于函数的公用;

另一方面,参数越多也就意味着变数越大。当与传参有关的逻辑发生变化时,拥有多个参数的函数,总是让人顾虑重重。

减少函数参数具体的办法有:

①函数参数 -> context变量(如cookie、session)

②函数参数 -> TABLE字段

函数参数 -> 提取类参

函数参数 -> 公共变量

这里主要讲讲③和④,

所谓类参,就是将‘类“作为参数。比如说代码里,有多个函数都有一些固定搭配的参数,就可以将他们组合成一个类(或结构体),从而达到减少参数的目的。

例如你在不止一个函数调用中见到过id、password这种搭配。那么就可以将他们组成一个类,取一个合适的名字,如user。

俗话说不是一家人,不进一家门。这几个参数既然搭配出现,不如就让他们在一起吧。


至于第④点:公共变量。以上面重构后的代码为例,目前重构后的代码有三个函数,分别为:

public Boolean ChangePassword(int id, String oldPassword, String newPassword)
private Boolean ValidateOldPassword(int id, String oldPassword)
private Boolean SaveNewPassword(int id, String newPassword)

这三个函数的参数虽然各有不同,但实质都是围绕三个变量来处理:

int id, String oldPassword, String newPassword

因此我们将这三个参数作为公共变量提取出来,改为类变量。将使用这些参数的函数作为类的方法,去掉函数参数,改为直接使用类变量。重构后的代码为:


public class Student {
	private int id;
	private String oldPassword;
	private String newPassword;
	
	public Student(int id, String password) {
		this.id = id;
		this.oldPassword = password;
		this.newPassword = password;
	}
	
	public Boolean ChangePassword(){
		if(ValidateOldPassword()) {
			return SaveNewPassword();
		}
		return false;
	}

	private Boolean ValidateOldPassword(){
		oldPassword = EncodeHelper.MD5(oldPassword.trim());
		return oldPassword.Equals(GetStudent(id).LoginPassword);
	}

	private Boolean SaveNewPassword(){
		newPassword = EncodeHelper.MD5(newPassword.trim());
		return this.UpdateStudent((new Student(id, newPassword)));
	} 

        //其余部分略
}

通过提取类参、将公共变量改为类变量,使函数参数尽可能减少,越少越好。


这样每重构一次函数,就修改相应的调用语句。然后Run -> Test,保证每次调用都结果不变。


++++++++++++++++我是做总结的分割线++++++++++++++++

到这里已经谈了关于变量、代码块、以及函数的重构方法。所有的这些重构,说到底都是为了将函数写漂亮。那什么是漂亮的函数?我的目标是:

  1. 每个函数每次只做一件事
  2. 将函数的处理流程和具体实现分开

例如下面的代码,既描述了更改密码的处理流程,又描述了验证的具体实现。

需要将处于不同的层级的两块代码分开。

public Boolean ChangePassword(int id, String oldPassword, String newPassword) {
    oldPassword = EncodeHelper.MD5(oldPassword.trim());
    newPassword = EncodeHelper.MD5(newPassword.trim());

    if (!oldPassword.Equals(this.GetStudent(id).LoginPassword)) {
	 return false;
    }
	
    return this.UpdateStudent(new Student(id, newPassword));
}
3. 函数读起来应该像书的目录

我们读目录时,先看共有多少章节,再看一个章节里分了哪些小节,最后可根据索引去查询具体内容。

于是我写的函数大体这样:

1.最高层次的函数描述处理流程,流程中的每一步就是一个函数 -> 介绍章节

2. 1中每一步描述处理方法,每一个方法就是一个函数 -> 章节里分小节

3. 2中的每一个函数描述具体算法 -> 具体内容

这样读代码时,可以先从【1流程】看起,不必纠结具体的处理方法;

看懂后再了解【2方法】,不必拘泥于算法;

逐层升入,以此类推...


另外有一些个人的经验是:

1.不要有else;

2.将if、while、for里的代码块用函数代替;

3.尽量少的函数参数;

4.不要怕函数体短小(1~15行就行);

※短小的函数读起来轻而易举,同时能将修改限定在最小范围


--------------------------------------------更新于2015/05/16--------------------------------------------

函数的重构讲完了,来谈谈如何重构类。

这个问题可以分解为:重组一个类和分解一个类。

6.重组一个类

类是怎样划分的?也许是基于你头脑中一个实体的映射,也许是基于UML的一个建模?

但既有的代码用起来,也有这样那样的不顺手。是时候祭出法宝,重新审视既有的函数和类了。

这个办法在重构第5法中提到过:尽可能减少函数的参数。

规则1:

当你发现函数的多数参数,是另一个类A的成员变量

-> 将该函数从现有类抽离出来,改造成为A的方法

规则2:

当你发现若干函数有相同的参数,且无有关的类变量与之对应

-> 重新定义一个类,将这些函数作为类方法,其相同的行参作为类的成员变量

重构以后发现成员方法的参数减少,成员变量在方法体内被充分使用。


然后Run -> Test,确保每次函数的变化(参数变更、类间移动)都不影响其output。


7.分解一个类

在函数重构中,一直有个概念:函数一次只能做一事。

同样的,一个类如果责任过多,做了太多太多事,也不利于代码的维护。

如果你发现类的某些成员变量,只被部分方法所使用,而另一些变量,被其他方法所使用,就意味这你可以分解这个类。

以一个例子来说明重构的方法:

public class Person {
	private String name;
	private String officeAreaCode;
	private String officeNumber;
	
	public String getName() {
		return name;
	}
	
	public String getTelephoneNumber() {
		return "(" + officeAreaCode + ")" + officeNumber;
	}
	
	public String getOfficeAreaCode() {
		return officeAreaCode;
	}
	
	public void setOfficeAreaCode(String officeAreaCode) {
		this.officeAreaCode = officeAreaCode;
	}
	
	public String getOfficeNumber() {
		return officeNumber;
	}
	
	public void setOfficeNumber(String officeNumber) {
		this.officeNumber = officeNumber;
	}
}

这段代码中,name只被getName()使用,其余的成员变量和方法看起来另成一派。于是我们将Person类拆分为两个

public class Person {
	private String name;
	
	public String getName() {
		return name;
	}
	
}

public class TelephonePhone {
	private String officeAreaCode;
	private String officeNumber;

	public String getTelephoneNumber() {
		return "(" + officeAreaCode + ")" + officeNumber;
	}
	
	public String getOfficeAreaCode() {
		return officeAreaCode;
	}
	
	public void setOfficeAreaCode(String officeAreaCode) {
		this.officeAreaCode = officeAreaCode;
	}
	
	public String getOfficeNumber() {
		return officeNumber;
	}
	
	public void setOfficeNumber(String officeNumber) {
		this.officeNumber = officeNumber;
	}	
}

这样划分后两个类,责任划分清晰明了。 但人总得有个电话号码吧,所以还得把两者的联系加上:


public class Person {
	private String name;
	private TelephonePhone telephonePhone = new TelephonePhone();
	
	public String getName() {
		return name;
	}
	
	public String getTelephoneNumber() {
		return telephonePhone.getTelephoneNumber();
	}
}


在Person类中添加telephonePhone成员变量, 建立从旧类到新类的连接关系

每次搬移一个变量或方法时,Run -> Test。


8.消除类的重复
经过这么多的重构尝试,你应该注意到重复是编码的大忌。
函数中重复的代码块可变为函数,那么类中重复的变量和方法呢?Bingo,就是父类(或接口)!
规则:
1.建立或修改父类,添加类中重复的变量和方法名;
2.将添加的变量或方法限定为protected或public;
3.之前的类继承该父类;
4.如果重复的方法处理逻辑一致,就在父类中实现该方法,视情况添加final限定;
5.如果重复的方法处理不一致,则父类中只声明方法名(或提取成接口)。视情况添加abstract限定;
6.移出子类中的重复变量和函数;
按照这种方式越重构下去,会发现父类越像是一个模版:把控了业务处理的流程,把具体的算法逻辑,交由子类重载。 -- 这便是设计模式中的[模版模式]。

子类每移出一次重复变量和函数,Run -> Test,确保子类对父类相关字段或方法的正确使用。


个人对重构方法的理解大体就是如此了。
在这篇文章的最后,再一次和各位分享我对重构的认识:
1.重构是个持续的过程
并非等到程序无法控制的最后,才进行重构。恰恰相反,从你写第一个函数、第一个类开始,就想着重构它吧。越早开始重构,就会让项目开发越早进入良性循环,避免后续莫名奇妙的错误,以及大量耗时的修改。
2.重构将更深入理解编程
审视重构后的代码,会发现在不知不觉中,用到了抽象,用到了多态,用到了设计模式。这些编程中的概念,我们往往只知道是什么,却不知道它们从何而来、为何而去?重构的过程就像一次拾遗,让代码清晰的揭示:这些概念是因何产生,以及它们被使用的场景。
--人类最初数数时,只知道加法。后来它们总结出了乘法,应用在生活里简化了计算。一次次的重构,就是不断总结、孰能生巧的过程。我们回顾重构后的代码,给它分类,叫它XX模式。时间久了,编程遇到类似的需求时,就会自然而然想使用该模式。

在过去的项目中,见识过糟糕的代码是如何带来灾难性的后果的:要不就是程序产生莫名奇妙的错误,要不就是一次小小的修改就牵一发动全身;
而另一方面,我们也曾体会过重构带来的美妙:一切都是简洁易修改的。往往我们面对一次大的逻辑变动,经调查后才发现,原来只需改一个函数,一切都会迎刃而解。
Wow,重构让一切的代码,都变得刚刚好!

++++++++++++++++我是有彩蛋的分割线++++++++++++++++
前前前回书有说到,有if、else的地方就有可能使用多态。举一个例子,JAVA的对象在未创建时为NULL,对一个NULL对象操作会引发系统错误。因而在代码中常需要对一个对象是否为NULL进行判断,如:
public Book getBook(int id) {
	if (id < 0) {
		return null;
	}
	return new Book(1, "Design Pattern", 100);
}
Book book = getBook(-1);
if (book != null) {
   book.getName();			
}

book != null 和 book == null成了两个分支。我们可以:
1.添加Book的一个NULL子类
2.在创建Book对象时,视情况创建NULL子类
3.在调用Book方法的逻辑处利用多态,去掉对象是否为null的判断

重构后的代码变为:
//添加的Null对象
public Class NullBook extends Book {
        public NullBook() {
        }

	@Override
	public String getName() {
		return "";
	}
}
//创建Book对象
public Book getBook(int id) {
	if (id < 0) {
		return new NullBook();
	}
	return new Book(1, "Design Pattern", 100);
}
//调用Book方法时去掉null判断
Book book = getBook(-1);  
book.getName(); 

<以上>
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值