来源:知乎
著作权归作者所有,转载请联系作者获得授权。
以下是关于重构的个人书单。
两本就够了,别的都大同小异,有兴趣的朋友可以去读读:
代码整洁之道
重构:改善既有代码的设计
<--------- 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) {
//{取得对战时间}
//{取得转化为百分数后的分数}
//{取得作战详细}
}
在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,保证每次调用都结果不变。
++++++++++++++++我是做总结的分割线++++++++++++++++
到这里已经谈了关于变量、代码块、以及函数的重构方法。所有的这些重构,说到底都是为了将函数写漂亮。那什么是漂亮的函数?我的目标是:
- 每个函数每次只做一件事
- 将函数的处理流程和具体实现分开
例如下面的代码,既描述了更改密码的处理流程,又描述了验证的具体实现。
需要将处于不同的层级的两块代码分开。
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));
}
我们读目录时,先看共有多少章节,再看一个章节里分了哪些小节,最后可根据索引去查询具体内容。
于是我写的函数大体这样:
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();
<以上>