learn-tech/专栏/白话设计模式28讲(完)/27谈谈我对项目重构的看法.md
2024-10-16 09:22:22 +08:00

473 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
27 谈谈我对项目重构的看法
什么叫重构
重构有两种解释,一种是作名词的解释,一种是作动词的解释。
名词:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
动词:使用一系列重构手法,在不改变软件可观察行为的前提下,调整软件的结构。
重构是软件开发过程中一个重要的事情之一,重构与重写的区别:
重构:不是对已有代码的全盘否定,而是对不合理的结构进行调整,合理的模块进行改动;利用更好的方式,写出更好,更有维护性代码。
重写:已有的代码非常复杂混乱,难以修改,重构的时间还不如重新写一个来得快;根据需求另立一个项目,完全重写。
为何要重构
车子脏了就得洗,坏了就得修,报废了就得换。
程序也一样,不合需求就得改,难于跟上业务的变更就得重构,实在没法改了就得重写。
现在的互联网项目已经不再像传统的瀑布模型的项目,有明确的需求。现在项目迭代的速度和需求的变更都非常的迅速。在软件开发的编码之前我们不可能事先了解所有的需求,软件设计肯定会有考虑不周到不全面的地方;而且随着项目需求的不断变更,很有可能原来的代码设计结构已经不能满足当前的需求。这时就需要对软件结构进行重新调整,也就是重构。
一个项目中,团队成员的技术水平参差不齐。有一些工作年限比较低,技术水平比较差的成员写的代码质量比较差,结构比较混乱,这时就需要对这部分代码进行适当的重构,使其具有更高的可重用性。
一个软件运行时间比较长,被多代程序员进行修修补补,使得这个软件的代码非常的臃肿而庞杂,维护成本非常高。因此,也需要对这个软件进行适当的构架,以降低其修改成本。
要进行代码重构的原因,总结一下,常见的原因有以下几种:
重复的代码太多,没有复用性;难于维护,需要修改时处处都得改。
代码的结构混乱,注释也不清晰;没有人能清楚地理解这段代码的含义。
程序没有拓展性,遇到新的变化,不能灵活的处理。
对象结构强耦合,业务逻辑太复杂,牵一发而动全身,维护时排查问题非常困难。
部分模块性能低,随着用户的增长,已无法满足响应速度的要求。
这些导致代码重构的原因,称为代码的坏味道,我称它为脏乱差,这些脏乱差的代码是怎样形成的呢?大概有以下几种因素:
上一个写这段代码程序员经验不足、水平太差,或写代码时不够用心。
奇葩的产品经理提出奇葩的需求。
某一个模块业务太复杂,需求变更的次数太多,经手的程序员太多,每个人都在一个看似合适的地方,加一段看似合适的代码,到最后没人能之完完整整地看懂这段代码的含义。
什么时机重构
重构分为两个级别类型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其中的元素个各自代表不同的东西用对象替换数组。对于数组中的每个元素以一个值域表示。
用设计模式改善代码设计
数据结构的重构和函数的重构都是相对基础的重构方法,有一些代码,类的结构及类间的关系本身就不太合理,这时就要用设计模式的思想重构设计这些类间的关系,这时就需要我们对事物和逻辑有一定的抽象思维,也就是面向对象的思想。一个大致的思考方向是:
把具有相似功能的类归纳在一起并抽象出一个基类,让这些类继承自这个基类(也称为父类)。
把子类都使用的方法和属性提炼到父类,并声明为 protected部分方法可能要声明为 public
不同体系的类之间(如动物和食物),依赖抽象和接口编程,即是依赖倒置原则。
这一些方法,需要长期的经验和总结,不能一蹴而就!需要认真学习和领悟设计模式和设计原则。
代码整洁之道
命名的学问
程序中的命名包括变量名、常量名、函数名、类名、文件名等。一个良好的名称能让你的代码具有更好的可读性,让你的程序更容易被人理解;相反,一个不好的名称不仅会降低代码的可读性,甚至会有误导的作用。良好的名称应当是可读的、恰当的并且容易记忆的。 好的命名还可以取代注释的作用,因为注释通常会滞后于代码,经常会出现忘记添加注释或注释更新不及时的情况。
语义相反的词汇要成对出现
正确的使用词义相反的单词做名称,可以提高代码的可读性。比如 “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) 大家就不一定都能够理解。
常见命名规则
目前最常见的编程命名规则有以下几种:驼峰命名法、帕斯卡命名法、匈牙利命名法。
驼峰命名法Camel主要特点第一个单词首字符小写后面的单词首字符大写如 myData。
下划线命名法,主要特点:每个单词全部小写,单词之间用下划线分隔,如 my_data。
帕斯卡命名法Pascal主要特点每一个单词首字符大写如 MyData。
匈牙利命名法Hungarian主要特点在变量名的前面加上表示数据类型的前缀如 nMyData、m_strFileName。
这些命名规则之间没有好坏之分只是一种习惯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 嵌套,这种代码称为“箭头型”代码,网上有一张神图很形象地表现了这种代码: