重构读书笔记三-代码的坏味道

重构 Dec 22, 2020

概述

代码的坏味道的概述。什么是代码的坏味道,作者也没有给出一个标准的答案。但是有一些代码的坏味道,大家的认知应该是相同的。就像虽然每个人对臭味的忍受不同,但是当臭味超过了一定浓度,即使最能忍受的人也有可能不能忍受。
作者的经验是,依赖于见识广博者的直觉。作者会告诉我们一些迹象,指出这里有一个可以用重构解决的问题。我们必须培养自己的判断力,学会如何判断一个类內有多少实例变量算是太大了、一个函数內有多少行代码才算太长。

ps:Linus Torvalds 在一次演讲中提到什么是 good taste。下面链接一篇文章具体讲解 Linus Torvalds 所说,可以参考下。
https://medium.com/@bartobri/applying-the-linus-tarvolds-good-taste-coding-requirement-99749f37684a

3.1 神秘命名(Mysterious Name)

我们写下的代码应该直观明了。整洁代码最重要一项就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使他们能清晰地表明自己的功能和用法。
就像给人起好名字不是一件容易事情,甚至要花钱请人。命名是编程中最难的事情之一。因此,改名是最常见的重构方法,包括改变函数声明(124)(用于给函数改名)、变量改名(137)、字段改名(244)等。好的命名可以让我们快速理解代码的功能。
改名一个隐含问题是。如果想不出一个好的名字,说明背后可能潜藏着很深的设计问题。(函数可能混杂着多个功能)
ps;通常情况下,动作加对象就可以作为函数名。我理解好的函数都是一个短语:动作 + 对象 + 限定状语。如果是一个抽象的动作:build、get 等,要由一些具体的动作函数来组成。

3.2 重复代码

3.2.1 重复代码的问题:

  1. 留意重复代码之间是否完全相同
  2. 如果要修改重复代码,要记得找出所有的代码副本来修改

3.2.2 重构重复代码:

对付重复代码唯一的方案就是设法将他们合一。

场景重构方法重复的代码提炼出公共函数相似的代码移动语句,将相似的代码放在一起,然后提炼出公共函数重复的代码在一个超类的不同子类将函数上移到父类

3.3 过长函数

小函数的好处:更好的诠释力,更易于分享,更多的选择。
把函数变短,只要使用提炼函数(106)。找到函数适合集中在一起的部分,将他们提炼出来形成一个新函数。

临时变量和参数:
大量的临时变量和参数会变成函数提炼的阻碍。我们可以试试是否可以用內联变量消除临时变量。如果实在没办法删除,我们可以使用查询替代临时变量(178)。另外就是参数太多,对于经常需要同时出现的变量,讲他们变成一个参数对象(140)和保持对象完整(319)则可以将过长的参数列表变得更简介一些。还有就是终极办法——以命令取代函数(337)(后面讲)。

提炼函数的信号:

  1. 每当感觉需要以注释来说明什么的时候,我们可以把要说明的东西写进一个独立的函数,并以其用途(而非实现手法)命名。只要函数名称能够解释其用途,我们就可以这么做。关键不在于函数的长度,而在于函数做什么和如何做之间的语义距离。
  2. 条件表达式和也是提炼的信号。可以使用分解条件表达式(260)处理条件表达式。对于庞大的 switch 语句,其中的每个分支都应该通过提炼函数(106)变成独立的函数调用。如果有多个 switch 语句基于同一个条件进行分支选择,应该使用多态取代条件表达式(272)
  3. 至于循环,应该将循环和循环內的代码提炼到一个独立的函数中。如果发现提炼出的循环很难命名,可能是因为其中做了好几件不同的事情。如果遇到这种情况,请勇敢地拆分循环(227)将其拆分到各自独立的函数。

3.4 过长函数参数

将数据通过函数参数传递是正确的,因为全局数据很快就会变成邪恶的东西(全局数据的读写会涉及到竞争和一致性的问题)。但过长的参数列表本身也经常令人迷惑。

解决的办法:

  1. 如果向某个参数发起查询而获得另一个参数的值,在性能允许的范围內,可以使用查询取代参数(324)。
  2. 如果发现从数据结构中抽出很多数据项,可以考虑使用保持对象完成(319)手法,直接传入原来的数据结构。ps:我对这条持保留意见,因为对于不要和陌生人谈话原则,没有必要的数据不要传递过去,如果不熟悉的人使用和修改了不应该改动的数据,就会引发 bug。
  3. 如果有几项参数总是同时出现,可以用引入参数对象(140)将其合并成一个对象。
  4. 如果某个参数被用作区分函数行为的标记,可以使用移除标记参数(314)
  5. 使用类可以有效地缩短参数列表。如果多个函数有同样的几个参数,引入一个类就很有帮助。可以使用函数组合成类,将这些共同的参数变成这个类的字段。在函数式编程中,我们会说这个过程创造了一组部分应用函数。

3.5 全局数据

全局变量的问题在于,从代码库的任何一个角落都可以修改它。虽然现在的ide可以让我们索引到所有的修改,但是搞清楚修改的先后顺序和逻辑也是非常困难。全局数据造成了诡异的bug,但是问题的根源却在别处。

解决办法

  1. 封装变量(132)把全局数据用一个函数包装起来,可以方便找到修改它的地方,并可以在函数內控制修改它的行为和设置访问的权限。另外将这恶搞函数搬移到另一类或者模块,只允许模块内的代码使用,从而尽量控制其作用域。

全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。有些变量需要做到全局共享,但是如果仅仅为了方便就将变量设为全局,那么随着数据越多,处理的难度就会指数上升。

3.6 可变数据

在一个函数中对数据进行了修改,然而另外一处期望的确实原始的数据,于是一个功能失效了。
所以我也很喜欢用函数式编程——完全建立在数据永不改变的基础上,如果要更新数据,就返回一份新的数据副本。这样另外一处用原始的数据,新的函数用新的数据。
封装永远好的办法,不仅可以控制访问的权限,提供有限的对外接口方便排查问题。
拆分变量,将其他接口不需要的数据传输过去,告诉对方不要修改,这样的约束力和没约束没有太大的区别。所以既然不想让别人修改就不要传递过去(最小化通信原则,不要和陌生人说话)。
函数式编程的原则之一没有副作用的函数也很有帮助(没有副作用是函数可以用返回结果替代而不会造成其他影响,函数和返回结果是等价的)
只存在于函数中的变量,即使修改了也不会有很大的问题(函数不能太长)。随着变量作用域的扩展,风险也随之扩大。可以用函数组合成类(144)或者函数组合成变换(149)来限制需要对变量进行修改的代码量。
如果一个变量内部结果中包含了数据,通常最好不要直接修改其数据,而是将引用对象改为值对象(252)。这段我的理解是不要将引用对象,然后在其他地方修改,而是将修改后的结果返回。另一方面是传递给其他变量使用的时候,不要将对象的引用传递过去,而是将当时的值传递过去。这样维持了原有值的不变性。

3.7 发散式变化

如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。当你看着一个类说:如果新加入一个数据库,我必须修改这四个函数。如果新加入一个金融工具,我必须修改这四个函数。这就是发散式变化的征兆。数据库交互和金融逻辑的处理是两个不同的上下文,将他们分别搬移到各自独立的模块中,能让程序变得更好:每当对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。
如果发生变化的两个方向自然地形成了先后次序,就可以用拆分阶段(154)将两者分开,两者之间通过一个清晰的数据结构进行沟通如果发生变化的两个方向自然地形成了先后次序(比如说,先从数据库取出数据,再对其进行金融逻辑处理),就可以用拆分阶段(154)将两者分开,两者之间通过一个清晰的数据结构进行沟通。如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后用搬移函数(198)把处理逻辑分开。如果函数内部混合了两类处理逻辑,应该先用提炼函数(106)将其分开,然后再做搬移。如果模块是以类的形式定义的,就可以用提炼类(182)来做拆分。

3.8 霰弹式修改

霰弹式修改就是当我们遇到一个需求,需要在多处不同的类內做出许多小的修改。这种代码的问题在于修改的代码散步在四处,不但很难找到他们,也很容易错过某个重要的修改。

  1. 使用搬移函数(198)和搬移字段(207)把所有需要修改的代码放进一个模块里。如果有很多函数都在操作类似的数据,可以把他们组合成类(144)。
  2. 如果有些函数的功能是转化或者充实数据结构,可以使用函数组合成变换(149)。如果一些函数的输出可以组合后提供给一段专门使用这些计算结果的逻辑,这种时候常常用得上拆分阶段(154)。我理解这段的含义是将分散到不同地点的处理同一份数据的函数,进行组合和拆分成一个业务上或者逻辑上完整的函数。
  3. 另外一个常用的策略就是使用內联(inline)相关的重构——內联函数(115)或者內联类(186)——把本不该分散的逻辑归回一处。完成內联后,可能会闻到过长函数或者过大的类的味道,不过可以用与提炼相关的重构收发将其拆解成更合理的小块。

3.9 依恋情节

所谓模块化,就是力求将代码分出区域,最大化区域内部的交互,最小化括区域的交互。
如果一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于自己所处模块内部的交流,这就是依恋情结的典型情况。
解决办法就是:这个函数想跟这些数据待在一起,使用搬移函数(198)把它搬移过去。有时候,函数中只有一部分受这种依赖之苦,这时候就使用提炼函数(106)把这一部分提炼到独立的函数中,再使用搬移函数(198)带去它的梦想家园。
一个函数用到几个模块的功能,那么究竟该划归到哪里呢?
判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。

3.10 数据泥团

数据项就像小孩子,喜欢成群结队待在一起。例如:
两个类中相同的字段,许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于他们自己的对象。
两个类中相同的字段:首先请找出这些数据以字段形式出现的地方,运用提炼类(182)将他们提炼到一个独立对象中。
函数签名中相同的参数:引入参数对象(140)或保持对象完整(319)为它瘦身。这么做的函数是可以将很多参数劣列表缩短,简化函数调用。不必在数据泥团只用上新对象的一部分字段,只要以新对象取代两个字段就值得这么做。
一个好的判断方法是:删掉众多数据中的一项。如果这么做,其他数据有没有因此失去意义?如果他们不再有意义,这就是一个明确信号:你应该为他们产生一个新对象。

3.11 基本类型偏执

大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。一些库会引入一些对象,如日期。但是我们发现一个有趣的对象:很多程序员不愿意创建对自己的问题域有用的基本类型:如钱、坐标、范围等。于是钱被当作普通数字来计算
字符串是这种坏味道的最佳培养皿,比如,电话号码不只是一串字符。一个体面的类型,有统一的处理逻辑,至少能包含一致的显示逻辑,在用户界面需要显示时可以使用。
可以运用对象取代基本类型(174)将原本单独存在的数据值替换为对象。

3.12 重复的 switch

switch应该被多态替代,重复的swith应该被提炼函数替代

3.13 循环语句

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

3.14 冗赘的元素

程序元素(如类和函数)给代码增加结构,从而支持变化、促进服用或者哪怕只是提供更好的名字也好,但是有时我们真的不需要这层额外的结构。可能有这样一个函数,他的名字就和实现带啊吗看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,设计者认为可能会一天天长大,变复杂,但那天并没有到来。也可能是因为,这个类原本是有用的,但随着重构进行越变越小,最后只剩下了一个函数。但无论怎样,上面的情况只需要使用內联函数(115)或者內联类(186)。如果这个类处于一个继承体系中,可以使用折叠继承体系(380)(就是去掉几个层级)

3.15 夸夸其谈未来性

不要过度设计,如果一段代码唯一的作用是给测试用例,那么就会出现这种坏味道。移除掉测试用例,去掉无用代码。

3.16 临时字段

不要在内部某个字段仅为某个特定情况而设。这样的代码让人不易理解,因为通常认为对象的所有字段都会被用到。在字段未被用到的时候猜测设置它的目的,会让人发疯。举例:一个函数传进来十个参数,但是这十个字段三个用来处理一个业务,另外三个用来处理另外一个业务,最后一个是临时字段。看这段代码会让人疯掉。
处理方法是使用提炼类(182)和搬移函数(198)所有相关代码放进一个新家。

3.17 过长的消息链

消息链就是一个对象请求另一个对象,再请求一个对象,循环下去...这种情况的问题是否有必要,另外就是一旦中间的状态发生了改变,初始调用方就必须要修改。
修改的办法是隐藏委托关系(189),也就是通过抽象,或者代理将消息链隐藏起来。不过这样可能会要修改大量的代码,可以尝试使用提炼函数(106)。

3.18 中间人

对象的基本特征之一是封装——对外部世界隐藏内部细节。封装还往往伴随着委托,但是过度的委托就是过度使用了。这样会导致业务逻辑的分散,是一种过度设计的体现。遇到这种情况可以使用移除中间人(192),直接把代码在负责的对象实现。或者内联函数(115)将逻辑搬运到调用方。

3.19 内幕交易(insider trading)

这段说的是两个模块之间应该有限的数据交流,不要和陌生人说话。另外不要使用继承,继承会导致子类对父类的数据滥用。多用组合和依赖注入。

3.20 过大的类

没有任何理由,大类就是万恶之源。重构首先解决的就是这些大类,大的函数。

3.21 异曲同工的类

因为各种历史原因,我们也会遇到功能类似的类和函数。我们要做的就是将代码合并到一处,可以使用改变函数声明(124),让两边的对外函数调用一致,然后使用搬移函数(198)。

3.22 纯数据类

纯数据类分两种情况,一种是真的纯数据类,像bean,entity。他们就是表示数据的结构类。另外一种是只有纯数据类,但是操作数据的逻辑散落到了各个地方。这时候有两种选择,把操作的业务逻辑提炼到数据类里面,使用搬移函数(198)或者提炼函数(106)。还有就是创建一个业务逻辑类,专门用来操作这个数据类。

3.23 被拒绝的遗赠

子类要继承超类的方法和字段,但是我们会发现继承了不想要的字段和方法。这是一种不明显的坏味道,只有当某些情况,子类不小心覆盖了父类的方法和字段时候才会发现。传统的观点认为,这种情况是因为我们没有设计好父类和子类的层次关系,但是这时候往往为了隐藏一个字段就增加了一个父类让整个继承体系变得越来越复杂。所以现在我们更喜欢使用组合和依赖注入的方法来实现代码的敷用。组合优于继承。

3.24 注释

注释是好事情,但是在写注释之前。我们看看是否能够通过重构或者其他方法让代码变得容易理解,省去写注释的必要性。代码本身应该是最好的注释。另外一个过时或者错误的注释还可能让人难以理解和造成错误。

总结

近几年随着敏捷理论和tdd,以及ddd的发展。让一些问题有了更好的定义和解决方法。同时这些理论并没有大量的应用,我想因为对从业人员的素质要求更高。可能是近几年行业的极速发展是建立在野蛮扩展的基础上。对比建筑行业因为关乎人身安全,所以行业的标准在经历安全事故后,快速建立。当国内软件行业到了存量竞争的时候,相关的理论才会被重视起来吧。