`重构`对于大部分工程师来说应该经常听说过,但是真正进行过重构工作的人不多,而能把持续重构作为开发一部分的人,就更是少之又少了。
重构对于一个工程师的要求,要比单纯的写代码高得多。重构需要你能没空出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。
同时,需要对为什么要重构、重构什么、什么时候重构、如何重构等相关问题要有深入的理解,对重构没有系统性、全局性的认知,面对一堆烂代码,没有相应的重构技巧,想到哪改到哪,并不能全面地改善代码的质量。
如何重构 WWWH
重构的目的 Why
“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更容易理解,修改成本更低” —— Martin Fowler
重构是保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在不断的演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进。当代码混乱到一定程序之后,量变引起质变,项目的维护成本高过重新开发一套代码的成本,这时再想去重构,已经没有人能做到了。
其次,优秀的代码或架构不是一开始就能完全设计好的,就像优秀的框架产品也都是迭代出来的。我们无法100%的预见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的。
所以,重构是有效的避免过度设计的手段。在我们维代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效的避免前期投入过多的时间做过度的设计。
除此之外,**重构有助于一个工程师本身技术的成长**。
重构实际上是对设计思想、设计原则、设计模式、编程规范的一种应用,是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。
同时,重构也是衡量一个工程师代码能力的有效手段。
所谓`初级工程师在维护代码,高级工程师在设计代码,资深工程师在重构代码`。
重构什么 What
重构可以笼统地分为:大规模高层次重构、小规模层次重构。
大规模高层次重构是指对顶层代码的重构,包括:系统、模块、代码结构、类与类之间的关系等。手段有:分层、模块化、 解耦 、 抽象 可复用组件等。
对于大规模高层次重构,相应的工具就是设计思想、原则和模式。这类的重构涉及的代码改动会较多,影响面较大,所以难度也较大,耗时较长,引入Bug的风险性相对较高。
小规模层次重构是指对代码细节的重构,主要针对类、函数、变量等代码级别的重构。
比如规范命名、注释、消除超大类或函数、提取重复代码等。
小规模层次重构更多的是利用编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时较短,引入Bug的风险相对较低。
重构时机 When
什么时候重构?是代码烂到一定程度之后吗?当然不是。当代码真的烂到出现**开发效率低,人多还天天加班,出活却少,线上Bug频发,领导发飙,中层束手无策,工程师抱怨不断,查找Bug困难”时,基本上重构也无法解决问题了。
平时不注重代码质量,堆砌烂代码,实在维护不了就大刀阔斧地重构、甚至重写的行为其实很难将重构做到彻底,最后搞出一个**四不像**,反而更麻烦。所以,寄希望到集中重构解决所有问题是不现实的,重构应该**可持续、可演进**,也就是**持续重构**。
**持续重构**,可以在平时没事的时候,看看项目中那些写的不够好的、可以优化的代码,主动去重构。或者在修改、添加某个功能代码的时候,顺手把不符合编码规范、不好的设计重构一下。总之,我们应该将**持续重构**作为开发的一部分,成为一种开发习惯,对自己、对项目都会有好处。
如何重构 How
对于大型重构来说,涉及到的模块、代码会比较多,如果项目代码质量又比较差,耦合比较严重,往往会牵一发而动全身,本来觉得一天可以完成的重构,你会发现越改越多、越改越乱,没有一两个礼拜都搞不定。而新的业务开发又与重构冲突,最后只能半途而废,回滚所有的改动,然后又去堆砌烂代码。
在进行大型重构的时候,要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们要控制好重构影响到的代码范围,考虑如何兼容老的代码逻辑,必要时还需要写一些兼容过渡代码。只有这样,才能让每一阶段的重构都不至于耗时太长(最好一天就能完成),不至于与新的功能开发冲突。
大规模高层次的重构一定要有组织、有计划,并且非常谨慎的,需要有经验、熟悉业务的资深同事来主导。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,有时间随时都可以去做。(可以借助 CheckStyle、FindBugs、PMD来自动发现代码中的问题,然后针对性地进行重构优化)
对于重构,资深的工程师、项目Leader要负起相关责任,保持持续的重构,时刻保证代码质量处理一个良好的状态。否则 ,一旦出现**破窗效应**,一个人往里面堆了一些烂代码,之后就会有更多的人往里面堆更烂的代码。毕竟,堆砌烂代码的成本太低了。
重构的有效手段 —— 解耦
软件设计与开发最重要的工作之一就是应对复杂性。过于复杂的代码往往在可读性、可维护性上都不友好。那么,如何控制代码的复杂性呢?最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药的地步的有效手段,那么利用解耦的函数对 代码重构 ,就是保证代码不至于复杂到无法控制的有效手段。
**松耦合,高内聚**的特性可以让我们聚焦于某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。而且,因为依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码改动比较集中,引入Bug的风险也就减少了很多。同时,**松耦合、高内聚**的代码可测试性也更加好,容易mock或者很少需要mock外部依赖的模块或类。
除此之外,代码**松耦合、高内聚**,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即使某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了。
代码是否需要`解耦`?
那现在问题来了,我们该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合**松耦合、高内聚**呢?再或者说,如何判断系统是否需要解耦重构呢?
间接的衡量标准有很多,前面我们讲到了一些,比如看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,就需要考虑是否可以通过解耦的函数,让依赖关系变得清晰、简单。当然,这种判断标准还是有比较强的主观色彩,但是可以作为一种参与和梳理依赖的手段,配合间接的衡量标准一块来使用。
如何给代码`解耦`?
封装与抽象
封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
比如`Unix`系统提供的` open ()`文件操作函数,用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的`open()`函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部伟代码中。除此之外,因为`open()`函数基于抽象而非具体的实现来定义,所以我们在改动`open()`函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合**高内聚、松耦合**。
中间层
引入中间层很简化模块或类之间的依赖关系。下面这张图引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A,B,C三个模块都要依赖内存一级缓存、Redis二级缓存、DB持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图上可以看出,中间层的引入明显简化了依赖关系,让代码结构更加清晰。
除此之外,在进行重构的时候,引入中间层可以起来过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,需要修改它的定义,同时所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,可以分为以下四个阶段来完成接口的修改:
– 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
– 第二阶段:新开发的代码和中间层提供的新接口。
– 第三阶段:将依赖老接口的代码改为调用新接口。
– 第四阶段:确保所有的代码都高用新接口之后,删除掉老的接口。
这样,每个阶段的开发工作量都不会很大,可以在很短的时间内完成。重构与开发冲突的概念也就小了。
模块化
模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人掌握所有的细节。之所以我们能搭建出如何复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即使在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
聚集到软件开发上面,很多大型软件(比如Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过API来进行通信,每个模块之间耦合很小,每个小的团队聚集于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。
我们再聚集到代码层面。合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。所以,在开发代码的时候,一定要有模块意味,将每个模块都当作一个独立的lib一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。
实际上,模块化的思想无处不在,像SOA、微服务、lib库、系统内模块划分,甚至是类、函数的设计,都体现了模块思想。
模块化思想更加本质的东西就是**分而治之**。
其他设计思想和原则
**高内聚,松耦合**是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。很多设计原则都以实现代码的**高内聚、松耦合**为目的的。
单一职责原则
内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要原则就是单一职责原则。模块或类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。
基于接口而非实现编程
基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为弱依赖关系(弱耦合)。
依赖注入
跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管 依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换。
多用组合少用继承
继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。
迪米特原则
迪米特原则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,可以明显看出,这条原则的目的就是为了实现代码的松耦合。
重构正确保障 —— 单元测试
如何保障重构之后不出错呢?首先需要对各种设计原则、思想、模式需要熟悉掌握,还需要对重构的业务和代码有足够的了解。除此之外,最可落地执行、最有效的保证重构不出错的手段应该就是**单元测试(Unit Testing)**了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有的逻辑正确性未被破坏,原有的外部可见性行为未变。
什么是单元测试
单元测试是由研发工程师自己编写,用来测试自己写的代码的正确性。单元测试相对于 集成测试 (Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整 个系统 或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否按照预期的逻辑执行,是代码层级的测试。
单元测试作用
单元测试能有效的发现代码中的Bug
能否写出bug free的代码,是判断工程师编码能力的重要标准之一。现在的很多公司,开发模式都是**快、糙、猛**,对单元测试没有要求,而能利用完善的单元测试,写出几乎bug free的代码往往可以在工作上赢得很多人的认可。可以说,坚持写单元测试是保证代码质量的一个**杀手锏**,可以帮助拉开与其他人的差距。
单元测试有助于发现代码设计上的问题
代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依赖单元测试框架里很高级的特性才能完成,那往往就意味着代码设计不够合理,比如没有使用依赖注入、大量静态函数、全局变量、代码高度耦合等。
单元测试对集成测试的有力补充
程序运行的Bug往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都是比较难在测试环境中模拟。而单元测试可以利用mock的方式,控制mock对象的返回需要模拟的异常等,来测试代码在这些情况下的表现。
除此之外,对于一些复杂系统来说,集成测试也无法覆盖很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备。
尽管单元测试无法完成替代集成测试,但如果我们能保证每个类、每个函数都能按照我们的预期来执行,底层Bug少了,那组装起来的整个系统,出问题的概率也就相应减少了。
写单元测试的老实本身就是代码重构的过程
单元测试实际上就是落地持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题得想清楚。而编写单元测试就相当于对代码的一次自我Code Review,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如边界条件问题)等,然后针对性的进行重构。
阅读单元测试能帮助快速熟悉代码
阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。很多程序员都不怎么喜欢 写文档和注释,而大部分程序员写的代码又很难做到**不言自明**。在没有文档和注释的情况下,单元测试就起来的了替代性的作用。单元测试用例实际上就是用户用例 ,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知识代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
单元测试真的很耗时吗
单元测试的代码量可以是被测试代码本身的1~2倍,写的过程很繁琐,但并不是很耗时。单元测试并不需要考虑太多代码设计上的问题,测试代码写起来也比较简单。不同测试作例之间的代码差别可能并不是很大,简单的copy-paste改改就行。
单元测试需要了解代码实现逻辑吗
单元测试不需要依赖被测试函数的具体实现逻辑,它只关心被测试函数实现了什么功能。不需要逐行阅读代码,然后针对实现逻辑编写单元测试。否则,如果代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原来的单元测试都会运行失败,起不到为重构保驾护航的作用,也违背了单元测试的初衷。
单元测试落地执行难
单元测试是保证重构不出错的有效手段,也有非常多的人认识到了单元测试的重要性。但是非常非常少有项目有完善的、高质量的单元测试,包括BAT这样级别的公司。国内的很多大厂开源的项目,有很多项目完成没有单元测试,还有很多项目的单元测试写得非常不完备,仅仅测试了逻辑是否运行正确而已。所以,落实扫行单元测试是件**知易难行**的事。
写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至要多出好几倍。很多人会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。很多团队在和项目在刚开始推行单元测试的时候,还比较认真,执行的比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现**破窗效应**,慢慢地,大家就都不写了,这种情况很觉。
还有一种情况就是,由于历史遗留问题,原来的代码都没有单元测试,代码已经堆砌了十几万行了,不可能再一个一个去补充单元测试。这种情况下,首先要保证新写的代码都要有单元测试,其次,每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们需要有足够强的主人翁意识(ownership),毕竟光靠Leader督促,很多事情很难执行到位的。
除此之外,还有人觉得,有了测试团队,写单元测试就是浪费时间,没有必要。程序员这行一行业本该是智力密集型的,但现在很多公司把它搞成了劳动密集型的,包括一些大厂,在开发过程中,即没有单元测试,也没有Code Review流程。即使有,做的也是差强人意。写好代码真接提交,然后丢给黑盒测试狠命去测,测出问题就反馈到开发团队再修改,测不出的问题就留在线上出了问题再修复。
在这种开发模式下,团队往往会觉得没有必要写单元测试,但如果我们把单元测试写好、做好Code Review,重视起代码质量,其实可以很大程序 上减少黑盒测试的投入。在Google, 很多项目几乎没有测试团队参与,代码的正确性完全靠开发团队来保障,线上Bug反而比较少。
快速地改善代码质量
命名
命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。除此之外,命名能力也体现了一个程序员的基本编程素养。
命名多长最合适
在足够表达其含义的情况下,命名越短越好。但是,大部分情况下,短的命名都没有长的命名更能达意。所以,很多书籍或者文章都不推荐在命名时使用缩写。
命名的一个原则就是能准确达意为目标。
命名要可读、可搜索
**可读**是指不要用一些特别生僻、难发音的英文单词来命名。
**可搜索**是指如键入某个对象`.get`,希望IDE返回这个对象的所有`get`开头的函数。再比如,通过IDE搜索`Array`搜索JDK中数组相关的类。所以,我们在命名的时候,最好符合整个项目的命名习惯。大家都用`selectXXX`表示查询,就不要用`queryXXX`;大家都用`insertXXX`表示插入一条数据,你就不要用`addXXX`。统一规约是很重要的,可以减少很多不必要的麻烦。
注释
命名很重要,注释跟命名同等重要。注释是命名不够详尽时的一个很好的补充。
注释到底该写什么
注释的目的是为了让代码更容易看懂。只要符合这个要求的内容,就可以写到注释里。注释的内容主要包括三个方面:做什么、为什么、怎么做。
注释是不是越多越好
注释太多或者太小都有问题。太多,有可能意味着代码写得不够可读,需要写很多注释来补充。除此之外,注释太多也会对代码本身的阅读起来干扰。而且,后期的维护成本也比较高,有时候代码改了,注释忘了同步修改,就会让代码阅读者更加迷惑。当然,如果代码中一行注释都没有,那只能说明这个程序员很懒,我们要适当督促一下,让他注意添加一些必要的注释。
正常,类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。
代码风格
在团队、项目中保持风格统一,让代码像同一个人写出来的,整齐划一。这样能减少阅读干扰,提高代码的可读性。
类、函数多大才合适
类、函数的代码行数不能太多,但也不能太少。类或函数的代码行数太多,一个类上千行,一个函数几百行,逻辑过于繁杂,阅读代码的时候,很容易就会看了后面忘了前面。相反,类或函数的代码行数太少,在代码问题相同的情况下,被分割成的类和函数就会相应增多,调用关系就会变得更复杂,阅读某个代码逻辑的时候,需要频繁地在n多类或者n多函数之间跳来跳去,阅读体验也不好。
那一个类或函数有多少行代码才最合适呢?
要给出一个精确的量化值是很难的。对于函数行数的最大限制,最好是不要超过一个显示屏的垂直高度。因为超过一屏之后,在阅读挖出的的时候,为了串联前后的代码逻辑,就可能需要频繁地上下滚动屏幕,阅读体验不好不好,还容易出错。
对于类的行数最大限制,那就是,当一个类的代码读起来让你感觉头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明这个类的行数过多了。
一行代码多长最合适
在`Google Java Style Guide`文档中,一行代码最长限制为`100`个字符。不过,不同的编程语言、不同的规范、不同的项目团队,对此的限制可能都不相同。不管这个限制是多少,总体上来说就是遵循一个原则:一行代码最长不超过IDE显示的宽度。需要滚动鼠标才能看一行的代码,显示不利于代码的阅读。当然,这个限制也不可以太小,太小会导致很多稍长点的语句被折成两行,也会影响到代码的整洁,不利于阅读。
善用空行分割单元块
对于比较长的函数,如果逻辑上可以分为几个独立的代码块,在不方便将这些独立的代码块抽取成小函数的情况下,为了让逻辑更加清晰,可以使用空行来分割各个代码块。
编程技巧
把代码分割成更小的单元块
大部分人阅读代码的习惯都是,先看整理再看细节。所以,我们要有模块化和抽象思维,关于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大提高代码的可读性。不过,只有代码逻辑比较复杂的时候,才建议提炼类或者函数。毕竟如果提炼 出的函数只包含两三行代码,在阅读代码的时候,还得跳过去看一下,反倒增加了阅读成本。
避免参数过多
函数最多应该只包含3、4个参数,大于等于5个的时候,会影响代码的可读性,使用起来也不方便。
勿用函数参数来控制逻辑
不要在函数中使用`bool`的标识**参数**来控制内部逻辑,`true`的时候走这块逻辑,`false`的时候走另一块逻辑。这明显违背了**单一职责原则**和**接口隔离原则**。建议将其拆成两个函数,可读性上也要更好。
不过,如果函数是`private`私有函数,影响范围有限,或者拆分之后的两个函数经常同时被调用,可以酌情考虑保留标识参数。
除了`bool`类型作为标识参数来控制逻辑的情况外,还有一种**根据参数是否为null**来控制逻辑的情况。针对这种情况,也应该将其拆成多个函数。拆分之后的函数职责更明确,不容易出错。
函数设计要职责单一
相对于类和模块、函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。
迁移过深的嵌套层次
代码嵌套层次过深往往是因为`if – else`、`switch – case`、`for`循环过多嵌套导致的。嵌套最好不要超过两层,超过两层之后就要思考一下是否可以减少嵌套。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。