代码重构的前提_代码重构pdf

代码重构的前提_代码重构pdf书名:《重构:改善既有代码的设计》作者:Martin Fowler译者:熊节https://book.douban.com/subject/30

书名:《重构:改善既有代码的设计》

作者:Martin Fowler

译者:熊节

https://book.douban.com/subject/30468597

01 重构的原则

何谓重构?

“重构”这个词,既可以用作名词,也可以用作动词:

  • 名词形式:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 动词形式:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

“结构调整”,泛指对代码库进行的各种形式的重新组织或清理,重构则是特定的一类结构调整。

“可观察行为”的意思是,整体而言,经过重构之后的代码所做的事应该与重构之前大致一样。

重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,代码很少进入不可工作的状态,即便重构没有完成,也可以在任何时刻停下来。如果代码在重构过程中有一两天时间不可用,基本上可以确定,这不是重构。

为何重构?

如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质,重构是为了改善代码,让代码“更容易理解,更易于修改”,而不关心程序运行的快慢。

何时重构?

事不过三,三则重构。

  • 第一次做某件事时只管去做;
  • 第二次做类似的事会产生反感,但无论如何还是可以去做;
  • 第三次再做类似的事,就应该重构;

重构的最佳时机就在添加新功能之前:如果你要给程序添加一个新功能,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构这个程序,使其比较容易添加该功能,然后再添加该功能。这往往是添加新功能最快的方法。

怎么对经理说重构?

如果经理懂技术,能理解”设计耐久性假说“:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。那么向他说明重构的意义应该不会很困难。

如果经理不具备这样的技术意识,不理解代码库的健康对生产率的影响,那么就“不要告诉经理”:先重构再添加新功能,以这种最快的方式完成任务。

何时不应该重构?

如果一段代码能正常工作,并且不会再被修改,那么就不需要重构它,只有当有人需要理解它的工作原理,或者需要修改它,对其进行重构才有价值。

如果重写比重构还容易,就别重构了。这是个困难的决定,真实了解重构一块代码的难度,决定到底应该重构还是重写,需要良好的判断力与丰富的经验。

重构与软件开发过程

软件开发过程中,可能会经常变换帽子:添加新功能和重构。

  • 添加新功能时,不应该修改既有代码,只管添加新功能,通过添加测试并让测试正常运行,可以衡量工作进度;
  • 重构时就不能再添加功能,只管调整代码的结构。此时不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试;

重构的第一块基石是自测试代码。我们应该有一套自动化的测试,可以频繁地运行它们,并且有信心:如果在编程过程中犯了任何错误,会有测试失败。

如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作。

自测试代码、持续集成、重构,这三大实践彼此之间有着很强的协同效应。

02 构筑测试体系

要正确地进行重构,前提是得有一套稳固可靠的测试集合,以帮助我们发现难以避免的疏漏。

事实上,撰写测试代码的最好时机是在开始动手编码之前。当我们需要添加特性时,我们先编写相应的测试代码。

听起来离经叛道,其实不然。编写测试代码其实就是在问自己:为了添加这个功能,需要实现些什么?编写测试代码还能帮我们把注意力集中于接口而非实现(这永远是一件好事)。

测试应该是一种风险驱动的行为,测试的目标是希望找出现在或未来可能出现的bug。

观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。

不要因为测试无法捕捉所有的bug就不写测试,因为测试的确可以捕捉到大多数bug。

“要写多少测试才算足够?”这个问题没有很好的衡量标准。有些人以测试覆盖率作为指标,但测试覆盖率的分析只能识别出那些未被测试覆盖到的代码,而不能用来衡量一个测试集的质量高低。

一个测试集是否足够好,最好的衡量标准其实是主观的,请你试问自己:如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集揪出来?这种信心难以被定量分析,盲目自信不应该被计算在内,但自测试代码的全部目标,就是要帮你获得此种信心。

如果重构完代码,看见全部变绿的测试就可以十分自信没有引入额外的bug,这样,就可以高兴地说,已经有了一套足够好的测试。

测试同样可能过犹不及。测试写得太多的一个征兆是,相比要改的代码,在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。不过尽管过度测试时有发生,相比测试不足的情况还是稀少得多。

03 代码的坏味道

神秘命名

整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。

如果想不出一个好名字,说明背后很可能潜藏着更深的设计问题。

重复代码

一旦有重复代码存在,阅读这些重复的代码时就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,必须找出所有的副本来修改。

过长函数

更好的阐释力、更易于分享、更多的选择,都是由小函数来支持的。

注释、条件表达式和循环,都是提炼函数的信号。

  • 每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名;
  • 对于庞大的switch语句,其中的每个分支都应该通过提炼函数变成独立的函数调用,如果有多个switch语句基于同一个条件进行分支选择,就应该使用以多态取代条件表达式;
  • 至于循环,应该将循环和循环内的代码提炼到一个独立的函数中。如果发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。

过长参数列表

过长的参数列表经常令人迷惑

全局数据

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

一次又一次,全局数据造成了那些诡异的bug,而问题的根源却在遥远的别处,想要找到出错的代码难于登天。

可变数据

对数据的修改经常导致出乎意料的结果和难以发现的bug。在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了。

发散式变化

一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。

如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。

霰弹式修改

如果每遇到某种变化,都必须在许多不同的类内做出许多小修改,所面临的坏味道就是霰弹式修改。

如果需要修改的代码散布四处,不但很难找到它们,也很容易错过某个重要的修改。

依恋情结

所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。

数据泥团

如果在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数,那么这些总是绑在一起出现的数据就应该拥有属于它们自己的对象。

基本类型偏执

很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。如果有一组总是同时出现的基本类型数据,这就是数据泥团的征兆。

重复的switch

重复的switch,即在不同的地方反复使用同样的switch逻辑(可能是以switch/case语句的形式,也可能是以连续的if/else语句的形式)。

其问题在于:每当你想增加一个选择分支时,必须找到所有的switch,并逐一更新。

循环语句

以管道取代循环,管道操作(比如filter和map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。

冗赘的元素

程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。

夸夸其谈通用性

“我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。

这么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,就值得那么做;如果用不到,就不值得。

临时字段

内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让人发疯。

过长的消息链

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中看到的可能是一长串取值函数或一长串临时变量。

采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。

中间人

对象的基本特征之一就是封装:对外部世界隐藏其内部细节。封装往往伴随着委托。

但是人们可能过度运用委托。也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。

内幕交易

软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。

在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。

过大的类

如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。

异曲同工的类

使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。

纯数据类

所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。

纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。

但也有例外情况,一个最好的例外情况就是,纯数据记录对象被用作函数调用的返回结果。

被拒绝的遗赠

如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈。

注释

你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。

当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

  • 如果你不知道该做什么,这才是注释的良好运用时机;
  • 除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域;
  • 你可以在注释里写下自己“为什么做某某事”,这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。

04 第一组重构

提炼函数、内联函数

如果需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。

如果代码中有太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托,造成人们在这些委托动作之间晕头转向,通过内联手法,可以找出那些有用的间接层,同时将无用的间接层去除。

提炼变量、内联变量

表达式有可能非常复杂而难以阅读,这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。

有时候,变量名字并不比表达式本身更具表现力,而且变量可能会妨碍重构附近的代码,此时应该通过内联的手法消除变量。

改变函数声明

函数最重要的元素当属函数的名字。一个好名字能让人一眼看出函数的用途,而不必查看其实现代码。

函数的参数列表阐述了函数如何与外部世界共处。修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。

封装变量

对于所有可变的数据,只要它的作用域超出单个函数,就需要将其封装起来,只允许通过函数访问。

数据的作用域越大,封装就越重要。

变量改名

好的命名是整洁编程的核心。变量可以很好地解释一段程序在干什么——如果变量名起得好的话。

引入参数对象

一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,此时可以代之以一个数据结构。

这项重构真正的意义在于,它会催生代码中更深层次的改变。一旦识别出新的数据结构,就可以重组程序的行为来使用这些结构。

函数组合成类

如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),就应该组建一个类了。

函数组合成变换

可以避免计算派生数据的逻辑到处重复,可以采用数据变换(transform)函数:这种函数接受源数据作为输入,计算出所有的派生数据,将派生数据以字段形式填入输出数据。

拆分阶段

如果一段代码在同时处理两件不同的事,可以把它拆分成各自独立的模块。

因为这样到了需要修改的时候,就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。

05 封装

封装记录

简单的记录型结构也有缺陷,最恼人的一点是,它强迫我清晰地区分“记 录中存储的数据”和“通过计算得到的数据”。

对于可变数据,使用类对象而非记录可以隐藏结构的细节,对象的用户不必追究存储的细节和计算的过程,同时这种封装还有助于字段的改名。

封装集合

封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。

以对象取代基本类型

如果对某个数据的操作不仅仅局限于打印时,可以为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。

以查询取代临时变量

将变量抽取成函数,只适用于处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。

提炼类、内联类

一个类应该是一个清晰的抽象,只处理一些明确的责任,但是在实际工作中,类会不断成长扩展,随着责任不断增加,这个类会变得过分复杂,此时需要分解这个类。

如果一个类不再承担足够责任,不再有单独存在的理由,那么以内联类的手法将“萎缩类”塞进其最频繁用户(也是一个类)中。

隐藏委托关系、移除中间人

如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。

封装受托对象也是有代价的,每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。

替换算法

随着对问题有了更多理解,我们往往会发现,在原先的做法之外,有更简单的解决方案,此时我们就需要改变原先的算法。

06 搬移特性

搬移函数

在类与其他模块之间搬移函数。

如果一个函数频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。

搬移字段

在类与其他模块之间搬移字段。

一个适应于问题域的良好数据结构,可以让行为代码变得简单明了,而一个糟糕的数据结构则将招致许多无用代码,这些代码更多是 在差劲的数据结构中间纠缠不清,而非为系统实现有用的行为。

搬移语句到函数、搬移语句到调用者

将语句搬入函数或从函数中搬出。

如果调用某个函数时,总有一些相同的代码也需要每次执行,那么应考虑将此段代码合并到函数里头。

如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者将它(或其一部分)搬移出来。

移动语句

在函数内部调整语句的顺序,让存在关联的东西一起出现,可以使代码更容易理解。

拆分循环

将循环拆分,让一个循环只做一件事情,确保每次修改时只需要理解要修改的那块代码的行为就可以了,同时能让每个循环更容易使用。

以管道取代循环

集合管道允许使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。

一些逻辑如果采用集合管道来编写,代码的可读性会更强——只需要从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。

移除死代码

当我们尝试阅读代码、理解软件的运作原理时,无用代码会带来很多额外的思维负担。

一旦代码不再被使用,就该立马删除它,有可能以后又会需要这段代码,可以从版本控制系统里再次将它翻找出来。

07 重新组织数据

拆分变量

如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。

字段改名

命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。

以查询取代派生变量

计算常能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。

将引用对象改为值对象、将值对象改为引用对象

引用对象和值对象最明显的差异在于如何更新内部对象的属性。值对象是不可变的,引用对象的修改会被共享。

08 简化条件逻辑

分解条件表达式

在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我们发生的事,但常常让我们弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。

和任何大块头代码一样,我们可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。

合并条件表达式

检查条件各不相同而最终行为却一致的条件检查,应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式,这对于厘清代码意义非常有用。

以卫语句取代嵌套条件表达式

如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”。

以多态取代条件表达式

可以将条件逻辑拆分到不同的场景,从而拆解复杂的条件逻辑,使用类和多态能把逻辑的拆分表述得更清晰。

引入特例

一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。

可以创建一个特例元素,用以表达对这种特例的共用行为的处理,这样就可以用一个函数调用取代大部分特例检查逻辑。

引入断言

常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。

这样的假设通常并没有在代码中明确表现出来,必须阅读整个算法才能看出,此时可以使用断言明确标明这些假设。

09 重构API

将查询函数和修改函数分离

任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离,把更新数据的函数与只是读取数据的函数清晰分开。

函数参数化

如果发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。

移除标记参数

“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑,让人难以理解到底有哪些函数可以调用、应该怎么调用。

明确用一个函数来完成一项单独的任务,其含义会清晰得多。

保持对象完整

代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,可以把整个记录传给这个函数,在函数体内部导出所需的值。

以查询取代参数、以参数取代查询

函数的参数列表应该尽量避免重复,并且参数列表越短就越容易理解。

有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身。

移除设值函数

如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。

以工厂函数取代构造函数

当调用者要求一个新对象时,与构造函数相比,使用工厂函数有更多的灵活性。

以命令取代函数、以函数取代命令

将函数封装成自己的对象,这样的对象称之为“命令对象”,或者简称“命令”。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。

与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力,但同时也增加了复杂性。一般都选择函数,只有当特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,才考虑使用命令对象。

10 处理继承关系

函数上移、函数下移

如果某个函数在各个子类中的函数体都相同(它们很可能是通过复制粘贴得到的),这就是最显而易见的函数上移适用场合。

如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。

字段上移、字段下移

如果各子类拥有重复的字段(观察这些字段被使用的方式是否相似),可以将这些字段提升到超类中去。

如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。

构造函数本体上移

如果各个子类中的函数有共同行为,可以使用提炼函数将它们提炼到一个独立函数中,然后使用函数上移将这个函数提升至超类。

但构造函数的出现打乱了我们的算盘,因为它们附加了特殊的规则,对一些做法与函数的调用次序有所限制。

以子类取代类型码、移除子类

表现分类关系的第一种工具是类型码字段——根据具体的编程语言,可能实现为枚举、符号、字符串或者数字。

大多数时候,有类型码就够了,但也有些时候,可以再多往前一步,引入子类,为数据结构的多样和行为的多态提供支持。但随着软件的演化,如果子类所支持的变化不存在了,这时子类就失去了价值,那么最好的选择就是移除子类,因为子类存在着就有成本,阅读者要花心思去理解它的用意。

提炼超类

如果两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。

折叠继承体系

随着继承体系的演化,有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在,此时应该把超类和子类合并起来。

以委托取代子类、以委托取代超类

继承有其短板:

  • 继承这张牌只能打一次,导致行为不同的原因可能有多种,但继承只能用于处理一个方向上的变化;
  • 继承给类之间引入了非常紧密的关系;

组合优于继承:

  • 对于不同的变化原因,我可以委托给不同的类;
  • 委托是对象之间常规的关系,与继承关系相比,使用委托关系时接口更清晰、耦合更少;

如果符合继承关系的语义条件(超类的所有方法都适用于子类,子类的所有实例都是超类的实例),那么继承是一种简洁又高效的复用机制。首先(尽量)使用继承,如果发现继承有问题,再使用委托。

总结

重构是为了改善代码,那什么样的代码才算好代码呢?好代码的检验标准就是人们是否能轻而易举地修改它。

重构的每个步骤都很简单,甚至显得有些过于简单:只需要把某个字段从一个类移到另一个类,把某些代码从一个函数拉出来构成另一个函数,或是在继承体系中把某些代码推上推下 就行了。但是,聚沙成塔,这些小小的修改累积起来就可以根本改善设计质量。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/4182.html

(0)
上一篇 2023-11-18
下一篇 2023-04-01

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注