关于项目重构的思考
# 关于项目重构的思考
# 什么叫重构
重构有两种解释,一种是作为名词的解释,一种是作为动词的解释。
- 名词解释:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 动词解释:使用一系列重构手法,在不改变软件可观察行为的前提下,调整软件的结构。
两种解释都有一个前提:“在不改变软件可观察行为的前提下”,这里的软件通常指一个方法。那么什么是软件的可观测行为呢?可观测行为是指使用软件所产生的结果。也就是说重构不能改变原代码的逻辑结果。重构是软件开发过程中一件重要的事情,通常情况下使用单元测试的方式来测量软件的可观察性有没有变化,这也是单元测试最大的用处了吧(代码只会编写一次,但重构可能会有无数次)。
与重构相关的还有一个概念:重写,这里简单描述两者的区别:
- 重构:不是对已有代码的全盘否定,而是对不合理的结构进行调整,对合理的模块进行改动;利用更好的方式,写出更好、更可维护的代码。
- 重写:已有的代码非常庞杂混乱,难以修改,重构还不如重新写一个来得快。根据需求另立一个项目,完全重写。
# 为什么要重构
车脏了就得洗,坏了就得修,报废了就得换。程序也一样,不满足需求就得改,难以跟上业务的变更就得重构,实在没法改了就得重写。
现在的互联网项目已经不再像传统的瀑布模型的项目一样有明确的需求,迭代的速度和需求的变更都非常迅速。在编码之前我们不可能了解所有的需求,软件设计肯定会有考虑不周到的地方;而且随着项目需求的不断变更,很有可能原来的代码设计结构已经不能满足当前的需求。这时就需要对软件结构进行重新调整,也就是重构。
此外,一个项目中团队成员的技术水平参差不齐。有一些工作年限比较短、技术水平比较差的成员写的代码质量可能比较差,结构可能比较混乱,这时也就需要对这部分代码进行适当的重构,使其具有更高的可复用性。
另外,如果一个软件运行时间比较长,多代程序员的修修补补会使得这个软件的代码非常臃肿庞杂,维护成本非常高。此时也需要对这个软件进行适当的重构,以降低其修改成本。
总的来说,要进行代码重构常见的原因有以下几种。
- 重复的代码太多,没有复用性,难以维护,需要修改时处处都得改。
- 代码的结构混乱,注释不清晰,没有人能清楚地理解这段代码的含义。
- 程序没有拓展性,遇到新的变化,不能灵活处理。
- 对象结构强耦合,业务逻辑太复杂,牵一发而动全身,维护时排查问题非常困难。
- 部分模块性能低,随着用户数量的增长,已无法满足响应速度的要求。
这些导致代码重构的原因,称为代码的坏味道。这只是代码层面(表象)的原因,而这些代码的坏味道形成的呢?大概有以下几种因素:
- 上一个写这段代码的程序员经验不足,水平太差,或写代码时不够用心。
- 奇葩的产品经理提出的奇葩需求。
- 某一个模块业务太复杂,需求变更的次数太多,经手的程序员太多。每个人都在一个看似合适的地方加一段看似合适的代码,到最后没人能够完完整整地看懂这段代码。
# 什么时候进行重构
重构分为两种级别:
- 第一种:对现有项目进行代码级别的重构;
- 第二种:对现有的业务进行软件架构的升级和系统的升级。
第二种情况属于软件架构的范畴。大型的重构最好封闭进行,由专门的(高水平)团队负责,期间不接任何需求,重新设计、开发新的更高可用、高并发的系统,经集成测试通过后,再用新系统逐步替换老系统。之所以会有这种系统和架构的升级,主要是因为系统软件应适应业务快速发展的需求,并根据不同的用户量级需要采用不同的架构。简单的架构表现为开发简单,迭代速度快;高可用架构表现为开发成本高,但支持的用户量大,可承载的并发数高。
第一种情况属于项目本身代码的重构。这种重构应该贯穿于软件的开发过程中,只要你闻到代码的坏味道即可进行。我们可以遵循三次法则来进行重构。虽然重构可以随时随地进行,但还是需要一些触发事件。主要有以下几点:
- 添加功能时。在添加新功能时,如果发现某段代码(与添加功能相关的代码)改起来特别困难,拓展功能特别不灵活,就要重构这部分代码使添加新特性和功能变得更容易。
- 修补错误时。在你改Bug或定位问题时,发现自己以前写的代码或者别人的代码设计上有缺陷(如扩展性不灵活),或健壮性考虑得不够周全(如漏掉一些该处理的异常),导致程序频繁出现问题,那么此时就是一个比较好的重构时机。
可能有人会说:道理都懂,但现实是线上问题发生时根本就没那么多时间去重构代码。我想说的是:只要不是十分紧急的高危问题 ,请尽量养成这种习惯。
每遇到一个问题不要选择绕行(想尽“歪招”绕开问题),而是要解决前进道路上的一切障碍,这样你对这块代码就能更加熟悉,更加自信。下次再遇到类似的问题,你就可以再次使用这段代码或参考这段代码。软件开发就是这样的,改善某段代码在当时看起来会多花一些时间,但从长远来看,这些时间肯定是值得的。多花一小时清除当前障碍,能为你将来避免绕路节省好几天。持续一段时间后,你会发现代码中的坑逐步被填平,欠下的技术债务也会越来越少。
还有一种情况:在复审代码时发现程序的问题或设计不足,也是一个重构的极佳时机。对于复审代码,很多公司会有Code Review的要求。每个公司Code Review的形式可能不太一样。有的采用“结对编程”的方式,两个人一起互审代码;有的是部门领导进行不定期的Code Review;有的是在程序上线之前,代码合并申请的时候,由经验丰富、成熟稳重的资深工程师负责审查。Code Review的好处是能有效地发现一些潜在的问题,有助于团队成员进行技术的交流和沟通。
# 如何重构代码
前面讲解了什么时候该重构我们的代码,而怎么进行重构又是另一个重要的问题。下面将介绍一些最常用和实用的重构方法,这些方法针对各种编程语言都适用。
# 重命名
重命名是最低级、最简单的一种重构手法(现在的集成IDE都特别智能,通过Rename功能一键就能搞定),但并不代表它的功效就很差。如果见到一些特别奇葩、无脑或具有误导性的变量名、函数名、类名果断把它改掉!一个良好的命名(变量名、函数名、类名),能让你的代码可读性立刻提高十倍。在下面的章节会讲解变量取名的技巧和原则。
# 函数重构
提炼函数
你有没有见过一个函数有一千多行的代码?如果有,那么恭喜你!前人给你留了一个伟大的坑等着你去填。这种代码是极其难以阅读的,所以你需要对它进行拆分,将相对独立的一段段代码块拆分成一个个子函数。这个过程叫作函数的提炼。此外你是否经常看到相同或有相似功能的代码出现在好几个地方,在需求发生变更需要修改代码的时候,每一处都得改一遍。这个时候也需要提炼函数,然后在所有用到这段代码的地方调用这个函数即可。
去除不必要的参数
如果函数体不再需要某个参数,果断将该参数去除。尽量不要为未来预留参数,除非你很确定即将用到它。
用对象取代参数
你有没有见过有十几个参数的函数?这种函数,即使是天才也不太容易记住每一个参数,往往是看到后面忘了前面。对于这种函数可以定义一个参数类,类中的成员定义为函数需要的各个参数,调用函数时将这个类的对象传入即可,函数体内可通过这个对象取得各个属性。
查询函数和修改函数分离
遵循CQS原则。
隐藏函数一个类方法
如果函数不被任何其他类使用,或不希望被其他类使用,则将这个方法私有化。(例如Java声明函数为private、Python中表现为__functionName() ),对外部隐藏。
# 重新组织数据结构
用常量名替换常量值
如果有一个值带有特别的含义,而且可能在多个地方被用到,此时可以创建一个常量(或枚举变量),并根据其含义为它命名,将具体的数值替换为这个常量。这样,既能提高代码的可读性,又方便修改。有些书称之为:避免出现“魔法值”。
用Getter和Setter方法代替直接方法
尽量避免直接访问类的成员属性,可以将类的成员属性声明为private,然后定义public的Getter和Setter方法来访问这些属性。
用对象取代数组
如果有一个数组,其中的各个元素代表不同的东西,那么用对象取代数组。例如电脑的外设[mouse,keyboard,camera],这里的每一个元素都表示外设,但它们之间的功能和特性差别非常大,因此可以定义一个ExtensionDevice类,将mouse、keyboard、camera定义为这个类的成员。
# 用设计模式改善代码设计
重命名、函数和数据结构的重构都是相对基础的重构方法。有一些代码,类的结构及类之间的关系本身就不太合理,这时就要用设计模式的思想重新设计这些类之间的关系。这需要我们有一定的面向对象的思想。大致的思考方向有以下几种:
- 把具有相似功能的类归纳在一起,并抽象出一个基类,让这些类继承自这个基类。
- 把子类都使用的方法和属性提炼到父类,并声明为protected(部分方法可能要声明为public)。
- 不同体系的类之间依赖抽象和接口编程,即依赖倒置原则。
这些方法,需要长期的经验和总结,不能一蹴而就!需要认真学习和领悟设计模式及设计原则后再使用。
# 代码整洁之道
整洁的代码看起来舒服,而且方便阅读,容易理解!保持代码整洁有两个有效途径:一是养成良好的编程习惯,二是重构具有坏味道的代码。下面从命名和一些编程习惯(技巧)来简单说说如何让我们的代码更加整洁:
# 命名的学问
程序中的命名包括变量名、常量名、函数名、类名、文件名等。一个良好的名称能让代码具有更好的可读性,让程序更容易被人理解;相反,一个不好的名称不仅会降低代码的可读性,甚至会有误导的负面作用。良好的名称应当是可读的、恰当的并且容易记忆的。好的命名甚至可以取代注释的作用,因为注释通常会滞后于代码,经常会出现忘记添加注释或注释更新不及时的情况。
语义相反的词汇要成对出现
正确地使用词义相反的单词做名称,可以提高代码的可读性。比如“first / last”比“first / end”通常更让人容易理解。下面是一些常见的例子:
第一组 第二组 第三组 第四组 add / remove first / last open / close get / set lock / unlock begin / end show / hide next / previous create / destroy min / max up / down start / stop insert / delete old / new increment / decrement source / target 计算限定符作为前缀或后缀
很多时候变量需要表达一些数值的计算结果,比如平均值或最大值。这些变量名中会包含一些计算限定符(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。
- 帕斯卡命名法(Pascal)。主要特点:每一个单词首字符大写,如MyData。
- 匈牙利命名法(Hungarian)。主要特点:在变量名的前面加上表示数据类型的前缀,如nMyData、m_strFileName。
- 下画线命名法。主要特点:单词全部小写,单词之间用下画线分隔,如my_data。
这些命名规则没有好坏之分,只是一种习惯。Java程序员比较喜欢驼峰命名法,而C++项目中匈牙利命名法用得比较多,当然也有一些情况采用帕斯卡命名法,PHP和Python用下画线命名的比较多。一个项目一旦确认了使用某种命名规则,就要一直保持和遵守这种命名规则。
# 编程习惯
除了命名的方式,还有一些编程的习惯(技巧)也能帮助我们写出整洁的代码:
- 提炼出一个通用的方法。相同或相似的代码重复出现(遵循三次法则),提炼出一个通用的方法。
- 判断放入循环内,减少循环代码。如果根据条件分别进行了两次或两次以上的循环,可以把这种判断放到循环体内进行。
- 将多if...else...判断改成枚举类型,并用switch...case...判断。这种是为了减少if...else...判断,让代码更加清晰。
- 减少嵌套的层次,如果有If判断,对否定条件提前退出。代码嵌套层次很深的代码可以形象地称为“箭头型”代码,这种代码不仅让程序的阅读性下降,如果处理得不好甚至会影响程序的性能。
参考:
罗伟富. 《人人都懂设计模式:从生活中领悟设计模式:Python实现》. 电子工业出版社