27 谈谈我对项目重构的看法

什么叫重构

重构有两种解释,一种是作名词的解释,一种是作动词的解释。

名词:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

动词:使用一系列重构手法,在不改变软件可观察行为的前提下,调整软件的结构。

重构是软件开发过程中一个重要的事情之一,重构与重写的区别:

为何要重构

车子脏了就得洗,坏了就得修,报废了就得换。

程序也一样,不合需求就得改,难于跟上业务的变更就得重构,实在没法改了就得重写。

现在的互联网项目已经不再像传统的瀑布模型的项目,有明确的需求。现在项目迭代的速度和需求的变更都非常的迅速。在软件开发的编码之前我们不可能事先了解所有的需求,软件设计肯定会有考虑不周到不全面的地方;而且随着项目需求的不断变更,很有可能原来的代码设计结构已经不能满足当前的需求。这时就需要对软件结构进行重新调整,也就是重构。

一个项目中,团队成员的技术水平参差不齐。有一些工作年限比较低,技术水平比较差的成员写的代码质量比较差,结构比较混乱,这时就需要对这部分代码进行适当的重构,使其具有更高的可重用性。

一个软件运行时间比较长,被多代程序员进行修修补补,使得这个软件的代码非常的臃肿而庞杂,维护成本非常高。因此,也需要对这个软件进行适当的构架,以降低其修改成本。

要进行代码重构的原因,总结一下,常见的原因有以下几种:

这些导致代码重构的原因,称为代码的坏味道,我称它为脏乱差,这些脏乱差的代码是怎样形成的呢?大概有以下几种因素:

  1. 上一个写这段代码程序员经验不足、水平太差,或写代码时不够用心。
  2. 奇葩的产品经理提出奇葩的需求。
  3. 某一个模块业务太复杂,需求变更的次数太多,经手的程序员太多,每个人都在一个看似合适的地方,加一段看似合适的代码,到最后没人能之完完整整地看懂这段代码的含义。

什么时机重构

重构分为两个级别类型:(1)对现有项目进行代码级别的重构;(2)对现有的业务进行软件架构的升级和系统的升级。对于第一种情况,代码的重构应该贯穿于整个软件开发过程中;对于第二种情况,大型的重构最好封闭进行,由专门的(高水平)团队负责,期间不接任何需求;重新设计、开发新的更高可用、高并发的系统,经集成测试通过后,再用新系统逐步替换老的系统。之所以会有这种系统和架构的升级,主要是因为,对于互联网的产品,为适合的其快速发展的需求,不同的用户量级别,需要采用不同的架构。简单的架构:开发简单、迭代速度快;高可用架构:开发成本高,但支持的用户量大,可承载的并发数高。

第二种情况属于软件架构的范畴,这里主要讨论第一种情况,即对项目本身进行代码级别的重构。这个重构应该贯穿于整个软件开发过程始终,不需要单独拿出一块时间进行,只要你闻到代码的坏味道,即可进行。我们可以遵循三次法则来进行重构:事不过三,三则重构,也就是上一篇《[谈谈我对设计原则的思考]》中的 Rule Of Three 原则。

虽然重构可以随时随地的进行,但还需要一些触发点来触发你去做这一件事,这些触发点主要有以下几个:

(1)添加功能时

当添加新功能时,如果发现某段代码改起来特别困难,拓展功能特别不灵活,就要重构这部分代码了,使添加新特性和功能变得更容易。在添加新功能时,只梳理这部分功能相关的代码;一直保持这种习惯,日积月累,我们的代码会越来越干净,开发速度也会越来越快。

(2)修补错误时

在你改 Bug,查找定位问题时,发现自己以前写的代码或者别人的代码设计上有缺陷(如扩展性不灵活),或健壮性考虑的不够周全(如漏掉一些该处理的异常),导致程序频繁出问题,此时就是一个比较好的重构时机。

可能有人会说:道理都懂,但现实是线上问题出来时根本就没那么多时间允许去重构代码。我想说的是:只要不是十万紧急的高危(大部分高危问题测试阶段都测出来)问题,请尽量养成这种习惯。

每遇到一个问题就正面解决这个问题,不要选择绕行(想尽歪招绕开问题),解决前进道路上的一切障碍。这样你对这块代码就更加熟悉,更加自信;下次再遇到类似的问题,你就会再次使用这段代码或参考这段代码。软件开发就是这样,改善某段代码当前看起来会多花一些时间,但从长远来看这些时间肯定是值得的;清除当前障碍多花一小时,能为你将来避免绕路节省好几天。 持续一段时间后,你会发现代码中的坑逐步被填平,欠下的技术债务也会越来越少。

(3)复审代码时

很多公司会有 Code Review 的要求,每个公司 Code Review 的形式可能不太一样;有的采用“结对编程”的方式,两个人一起互审代码;有的是部门领导进行不定期 Code Review;我们公司是在程序上线之前,代码合并申请的时候,由经验丰富、成熟稳重的资深工程师负责审查。Code Review 的好处是能有效地发现一些潜在的问题(所谓当局者谜,旁观者清!程序开发也一样,同时更能发现自己代码的漏洞);有助于团队成员进行技术的交流和沟通。

在 Code Review 时发现程序的问题,或设计的不足,这又是一个重构的极佳时机,因为 Code Review 时,对方往往能提出一些更的建议或想法。

如何重构代码

上面讲解了什么时候该重构,怎么进行重构,这又是一个重要的问题。下面将介绍一些最常用和实用的重构方法,下面的这些方法针对各种编程语言都实用。

重命名

这是最低级、最简单的一种重构手法(现在的集成 IDE 都特别智能,通过 Rename 功能一键就能搞定),但并不代表他的功效就很差。

你有没有见过一些特别奇葩、无脑、或具有误导性的变量名、函数名、类名吗?如下面这样的:

# 下面的例子改编自网上讨论比较火的几个案例

# Demo1
correct = False
# 嗯,这是对呢?还是错呢?

# Demo2
from enum import Enum
class Color(Enum):
    Green = 1   # 绿色
    Hong = 2    # 红色
# 嗯,这哥们是红色(Red)的单词不会写呢,还是觉得绿色(Lv)的拼音太难看呢?

# Demo3
def dynamic():
    pass
    # todo something
# 你能想到这个函数是干嘛用的吗?其实是一个表示“活动”的函数。这英语是数学老师教的吗~

如果有,果断把它改掉!一个良好的名称(变量名、函数名、类名),能让你的代码可读性立刻提高十倍。在下面的“代码整洁之道”中将会继续讲解程序取名的技巧和原则。

函数重构

提炼函数

有没有见过一函数一千多行的代码?如果有,那恭喜你!前人给你留了一个伟大的坑等着你去添,这种代码是极其难以阅读的,所以你需要对它进行拆分,将相对独立的一段段代码块拆分成一个个子函数,这一过程叫函数的提炼。

你是否经常看到相同(或相似)功能的代码出现了好几个地方,在需求发生变更需要修改代码的时候,每一处你都得改一遍,这个时候你也需要将相同(或相似)功能的代码提炼成一个函数,在所有用到这段代码的地方调用这个函数即可。

去除不必要的参数

函数体不再需要某个参数,果断将该参数去除。尽量不要为未来预留参数(需要用到的时候再加),除非你很确定即将要用到它。

用对象取代参数

你有没有见过有十几个参数的函数?这种函数,即使是天才也不太容易能记住每一个参数,往往是看到后面忘了前面。这个时候可以定义一个参数类,类中成员定义为函数需要的各个参数;调用函数时将这个类的对象传入即可,函数体内可通过这个对象取得各个属性。

查询函数和修改函数分离

我们在《[谈谈我对设计原则的思考]》一文中讲到 CQS 原则,根据这一原则将查询函数和修改函数分离。

隐藏函数

一个类方法,如果不被任何其他类使用,或不希望被其他类使用。应该将这个方法声明为 private,对外部进行隐藏。

重新组织数据

用常量名替换常量值

有一个字面值,带有特别的含义,而且可能在多个地方被用到。创建一个常量(或枚举变量),并根据其含义为它命名,将具体的字面数值替换为这个常量。这样,即提高代码的可读性,也方便修改(要修改这一字面值时,只要修改常量的定义即可)。

用 Getter 和 Setter 方法代替直接方法

尽量避免直接访问类的成员属性,将类的成员属性声明为 private,然后定义 public 的 Getter 和 Setter 方法来访问这些属性。

用对象取代数组

有一个数组(array),其中的元素个各自代表不同的东西,用对象替换数组。对于数组中的每个元素,以一个值域表示。

用设计模式改善代码设计

数据结构的重构和函数的重构都是相对基础的重构方法,有一些代码,类的结构及类间的关系本身就不太合理,这时就要用设计模式的思想重构设计这些类间的关系,这时就需要我们对事物和逻辑有一定的抽象思维,也就是面向对象的思想。一个大致的思考方向是:

  1. 把具有相似功能的类归纳在一起并抽象出一个基类,让这些类继承自这个基类(也称为父类)。
  2. 把子类都使用的方法和属性提炼到父类,并声明为 protected(部分方法可能要声明为 public)。
  3. 不同体系的类之间(如动物和食物),依赖抽象和接口编程,即是依赖倒置原则。

这一些方法,需要长期的经验和总结,不能一蹴而就!需要认真学习和领悟设计模式和设计原则。

代码整洁之道

命名的学问

程序中的命名包括变量名、常量名、函数名、类名、文件名等。一个良好的名称能让你的代码具有更好的可读性,让你的程序更容易被人理解;相反,一个不好的名称不仅会降低代码的可读性,甚至会有误导的作用。良好的名称应当是可读的、恰当的并且容易记忆的。 好的命名还可以取代注释的作用,因为注释通常会滞后于代码,经常会出现忘记添加注释或注释更新不及时的情况。

语义相反的词汇要成对出现

正确的使用词义相反的单词做名称,可以提高代码的可读性。比如 “first / last” 比 “first / end” 通常更让人容易理解。下面是一些常见的例子:

第1组 第2组 第3组 第4组
add / remove begin / end create / destory insert / delete
first / last get /set increment / decrement up / down
lock / unlock min / max next / previous old / new
open / close show / hide source / destination start / stop
计算限定符作为前缀或后缀

很多时候变量需要表达一些数值的计算结果,比如平均值和最大值,这些变量名中会包含一些计算限定符(Avg、Sum、Total、Min、Max),在这种时候,可以使用限定符在前或者限定符在后两种方式对变量进行命名,但不要在一个程序中同时使用两种方法。如可以使用 priceTotal 或 totalPrice 来表达总价,但不要在一段代码里两种同时使用。虽然这可能看起来微不足道,但确实这样做可以可避免一些歧义。

变量名要能准确地表示事务的含义

作为变量名应尽可能全面,准确地描述变量所代表的实体。设计一个好的名字的有效方法,是用连续的英文单词来说明变量代表什么,命名中一律使用英文单词,不要使用汉语拼音,更不要使用汉字。

变量的目的 好的名字 不好的名字
Current time currentTime ct, time, current, x
Lines per page linesPerPage lpp, lines, x
Publish date of book bookPublishDate date, bookPD, x
用动名词命名函数名

函数名通常会描述在某个对象上的某个操作,因此采用 动词 + 对象名 的方式来做为函数名的命名约定,如 uploadFile()。

使用面向对象的语言时,在一些描述类属性的函数命名中类名是多余的,因为对象本身会包含在调用的代码中。例如,使用 book.getTitle() 而不是 book.getBookTitle(),使用 report.print() 而不是 report.printReport()。

变量名的缩写

习惯性缩写:

始终使用相同的缩写。例如,对 number 的缩写,可以使用 num 也可以使用 no,但不要两个同时使用,始终保证使用同一个缩写。同样的,也不要在一些地方用缩写而另外一些地方不用,如果用了 number 这个单词,就不要在别的地方再用到 num 这个缩写。

使用的缩写要可以发音:

尽量让你的缩写可以发音。例如,用 curSetting 而不用 crntSetting,这样可以方便开发人员进行交流。

避免罕见的缩写:

尽量避免不常见的缩写。例如,msg(message)、min(Minmum) 和 err(error) 就是一些常见的缩写,而 cal(calender) 大家就不一定都能够理解。

常见命名规则

目前最常见的编程命名规则有以下几种:驼峰命名法、帕斯卡命名法、匈牙利命名法。

这些命名规则之间没有好坏之分,只是一种习惯;Java 程序员比较喜欢驼峰命名法,而 C++ 项目中匈牙利命名法用的比较多,当然也有一些采用帕斯卡命名法,PHP 和 Python 用下划线命名的比较多。一个项目一旦确认了使用某个命名规则,就要一直保持和遵守这种命名规则。

整洁的案例

整洁的代码看起来舒服,而且方便阅读,容易理解!保持代码整洁的有效途径有两个:一是养成良好的编程惯性,二是重构具有坏味道的代码。下面是我曾经在重构一个用 C++ 开发的项目(采用 Qt 框架)时,记录下来的真实案例。为保持其真实性,代码还是以 C++ 的形式呈现,读者可以忽略具体的语法细节(如果看不懂),主要关注代码结构。

相同(或及相似)的代码重复出现,提炼出一个通用的方法
void ClassWidget::slot_onChannelUserJoined(QString uid)
{
    for ( auto widget : videoWidgetList )
    {
        if ( widget->videoId == uid.toUInt() )
        {
            videoEngineMgr->someoneJoinChannel( ( void * )( widget->getVieo()->winId() ), uid.toUInt() );
            break;
        }
    }
    qDebug()<<(QString("slot_onChannelUserJoined, uid:%1").arg(uid));
}

void ClassWidget::slot_onChannelUserLeaved(QString uid)
{
    for ( auto widget : videoWidgetList )
    {
        if ( widget->videoId == uid.toUInt() )
        {
            videoEngineMgr->someoneLeaveChannel( uid.toUInt() );
            break;
        }
    }
    qDebug()<<(QString("slot_onChannelUserLeaved, uid:%1").arg(uid));
}

上面两个函数中 for 循环的功能几乎是一样的,就是通过 uid 来找到对应的 Widget,然后进行相应的操作。这里就可以提炼出一个 getWidgetByUid(uid) 的方法,两个函数通过调用这个方法获得 widget 对象并进行相应的操作。

判断放入循环内,减少循环代码
void ClassWidget::isShowMessageWidget(bool isShow)
{
    if(!isShow)
    {
        for ( auto widget : videoWidgetList )
        {
            if ( widget->user.userType == STATUS_STUDENT )
            {
                widget->show();
            }
        }
    }
    else
    {
        for ( auto widget : videoWidgetList )
        {
            if ( widget->user.userType == STATUS_STUDENT )
            {
                widget->hide();
            }
        }
    }
}

这时根据 isShow 变量值的不同,分别进行了两个循环,可以把这种判断放到循环内进行。重构后的代码如下:

void ClassWidget::isShowMessageWidget(bool isShow)
{
    for ( auto widget : videoWidgetList )
    {
        if ( widget->user.userType == STATUS_STUDENT )
        {
                isShow ? widget->hide() : widget->show();
        }
    }
}

代码量是不是一下减了一半?

枚举类型的判断用 switch…case…
if(state == STATE_LOADING)
{
    ui->widgetLoading->show();
    ui->widgetLoadingFailure->hide();
    ui->widgetNoData->hide();
}
else if(state == STATE_FAILURE)
{
    ui->widgetLoading->hide();
    ui->widgetLoadingFailure->show();
    ui->widgetNoData->hide();
}
else if(state == STATE_NODATA)
{
    ui->widgetLoading->hide();
    ui->widgetLoadingFailure->hide();
    ui->widgetNoData->show();
}

重构后的代码:

switch (state) {
case STATE_LOADING:
{
    ui->widgetLoading->show();
    ui->widgetLoadingFailure->hide();
    ui->widgetNoData->hide();
    break;
}
case STATE_FAILURE:
{
    ui->widgetLoading->hide();
    ui->widgetLoadingFailure->show();
    ui->widgetNoData->hide();
    break;
}
case STATE_NODATA:
{
    ui->widgetLoading->hide();
    ui->widgetLoadingFailure->hide();
    ui->widgetNoData->show();
    break;
}
case STATE_FINISHED:
{
    ui->widgetLoading->hide();
    ui->widgetLoadingFailure->hide();
    ui->widgetNoData->hide();
    break;
}
default:
    break;
}

这时其实可以进行进一步的重构,就是每一个 case 里的代码段都提炼成一个方法(函数)。

减少嵌套的层次,如果有 If 判断,对否定条件提前退出
WidgetVideoItem* pItem =  videoWidgets.getWidgetByUid(uid);
if(pItem && pItem->isJoined())
{
    // sync page num
    pWhiteBoard->sendTurnToPageMsg(pWhiteBoard->getCurPageIdx() + 1);
    // sync status of sharing desktop
    if(inSharingDesktop)
    {
        StartSharingDesktop sharingDesktopData;
        sharingDesktopData.classId = this->classId;
        sendStartSharingDesktopMsg(sharingDesktopData);
    }
}

重构后的代码,由两层嵌套变成了一层嵌套:

WidgetVideoItem* pItem =  videoWidgets.getWidgetByUid(uid);
if(!pItem || !pItem->isJoined())
{
        return;
}
// sync page num
pWhiteBoard->sendTurnToPageMsg(pWhiteBoard->getCurPageIdx() + 1);
// sync status of sharing desktop
if(inSharingDesktop)
{
    StartSharingDesktop sharingDesktopData;
    sharingDesktopData.classId = this->classId;
    sendStartSharingDesktopMsg(sharingDesktopData);
}

这代码其实还算好的,只有两层嵌套。我见过一段前端的代码,有七、八层的 if 嵌套,这种代码称为“箭头型”代码,网上有一张神图很形象地表现了这种代码:

enter image description here