代码之丑(二)
“写代码”有两个维度:正确性和可维护性,不要只关注正确性。能把代码写对,是每个程序员的必备技能,但能够把代码写得更具可维护性,这是一个程序员从业余迈向职业的第一步。
本篇文章整理至郑烨老师《代码之丑》。
代码之丑音频-点我跳转
访问密码:WFjfq
长函数:为什么你总是不可避免地写出长函数?
这一讲,我们来讲一个你一定深恶痛绝的坏味道:长函数。
有一个关于程序员的段子,说程序员一定要用大屏显示器,而且一定要竖起来用,这样才能看到一个函数的全貌。这显然是在调侃函数很长,小屏甚至横屏都不足以看到整个函数,只有竖起来才行
只要一提到长函数,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,几乎所有程序员都会有不愉悦的回忆。可以这么说,没有人喜欢长函数,但在实际工作中,却不得不去与各种长函数打交道。
不知道你在实际工作中遇到最长的函数有多长,几百上千行的函数肯定是不足以称霸的。在我的职业生涯中,经常是我以为自己够见多识广了,但只要新接触到一个有悠久历史的代码库,就总会有突破认知的长函数出现。
长函数是一个“我一说,你就知道怎么回事”的坏味道,我就不准备用一个典型的长函数来开启这一讲了,否则,这一讲的篇幅都不够了。但是,为了统一认识,我准备先讨论一下多长的函数算是长函数,我们来看一个案例。
多长的函数才算“长”?
有一次,我在一个团队做分享,讲怎么把一个长函数重构成小函数。现场演示之后,我问了大家一个问题:在你心目中,多长的函数才算长呢?
一个现场听众很认真地思考了一下,给出了一个答案:100 行。我很尴尬地看了一下自己刚刚重构掉的两个函数,最长的一个都不到 100 行。换言之,以他的标准来看,这个函数根本就不是长函数,根本就没有必要重构。
对于函数长度容忍度高,这是导致长函数产生的关键点。
如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。
一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。
这里的要点就是,看具体代码时,一定要能够看到细微之处,关键点就是将任务拆解得越小越好,这个观点对代码同样适用。随着对代码长度容忍度的降低,对代码细节的感知力就会逐渐提升,你才能看到那些原本所谓细枝末节的地方隐藏的各种问题。
回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。
这不是一个说说就算的标准,我们应该把它变成一个可执行的标准。比如,在 Java 中,我们就可以把代码行的约束加到 CheckStyle 的配置文件中,就像下面这样:
<module name="MethodLength">
<property name="tokens" value="METHOD_DEF"/>
<property name="max" value="20"/>
<property name="countEmpty" value="false"/>
</module>
这样,在我们提交代码之前,执行本地的构建脚本,就可以把长函数检测出来。如果你用的是其它的程序设计语言,不妨也找一下相应的静态检查工具,看看是否提供类似的配置。
我知道,即便是以 20 行为上限,这也已经超过很多人的认知,具体的函数行数可以结合团队的实际情况来制定,但是,我非常不建议把这个数字放得很大,就像我前面说的那样,如果你放到 100 行,这个数字基本上是没有太多意义的,对团队也起不到什么约束作用。
我之所以要先讨论多长的函数算是长函数,是因为如果你不能认识到代码行的标准应该很低,那么在接下来的讨论中,有些代码示例可能在你看来,就根本不需要调整了。
长函数的产生
不过,限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生的。
以性能为由
人们写长函数的历史由来已久。像 C 语言这种在今天已经是高性能的程序设计语言,在问世之初,也曾被人质疑性能不彰,尤其是函数调用。
在一些写汇编语言的人看来,调用函数涉及到入栈出栈的过程,显然不如直接执行来得性能高。这种想法经过各种演变流传到今天,任何一门新语言出现,还是会以同样的理由被质疑。
所以,在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。性能优化不应该是写代码的第一考量。
一方面,一门有活力的程序设计语言本身是不断优化的,无论是编译器,还是运行时,性能都会越来越好;另一方面,可维护性比性能优化要优先考虑,当性能不足以满足需要时,我们再来做相应的测量,找到焦点,进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点,优化才是有意义的。
平铺直叙
除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来。
我们可以看到平铺直叙的代码存在的两个典型问题:
- 把多个业务处理流程放在一个函数里实现;
- 把不同层面的细节放到一个函数里实现。
例如,现在需要写一个翻译章节的函数,把没有翻译过的章节发到翻译引擎,然后,启动翻译过程。在这里翻译引擎是另外一个服务,需要通过 HTTP 的形式向它发送请求。
这里发送章节和启动翻译是两个过程,显然,这是可以放到两个不同的函数中去实现的,所以,我们只要做一下提取函数,就可以把这个看似庞大的函数拆开,而拆出来的几个函数规模都会小很多。
平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。关注点越多越好,粒度越小越好。
一次加一点
有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
if (code == 400 || code == 401) {
// 做一些错误处理
}
然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
if (code == 400 || code == 401 || code == 402) {
// 做一些错误处理
}
你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如
if (code == 400 || code == 401 || code == 402 || ...
|| code == 500 || ...
|| ...
|| code == 10000 || ...) {
// 做一些错误处理
}
后来人看到这段代码就想骂人了。当他从版本控制的历史中找到这些代码的作者,去询问这些处理的来龙去脉时,每个人其实都很委屈,他们当时也没做太多,只是加了一个判断条件而已。
**任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。**对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:
让营地比你来时更干净。
—— 童子军军规
Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。
至此,我们看到了代码变长的几种常见原因:
- 以性能为由;
- 平铺直叙;
- 一次加一点。
你会发现,代码变长根本是一个无意识的问题,写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道,后面的许多问题就自然而然地会被发掘出来,至于解决方案,你已经看到了,大部分情况下,就是拆分成各种小函数。
总结
以上我们讲了程序员最深恶痛绝的坏味道:长函数。没有人愿意去阅读长函数,但许多人又会不经意间写出长函数。
毫无疑问,长函数是一个坏味道。对于团队而言,一个关键点是要定义出长函数的标准。不过,过于宽泛的标准是没有意义的,想要有效地控制函数规模,几十行的函数已经是标准的上限了,这个标准越低越好。
我们还分析了长函数产生的原因:
- 有人以性能为借口;
- 有人把代码平铺直叙地摊在那里;
- 有人只是每次增加一点点。
其中,平铺直叙是把函数写长最常见的原因。之所以会把代码平摊在那里,一方面是把多个业务写到了一起,另一方面是把不同层次的代码写到了一起。究其根因,那是“分离关注点”没有做好。
每次增加一点点,是另外一个让代码变长的原因,应对它的主要办法就是要坚守“童子军军规”,但其背后更深层次的支撑就是要对坏味道有着深刻的认识。
记住一句话:把函数写短,越短越好。
大类:如何避免写出难以理解的大类?
一听到大类,估计你的眼前已经浮现出一片无边无际的代码了。类之所以成为了大类,一种表现形式就是上面内容中讲到的长函数,一个类只要有几个长函数,那它就肯定是一眼望不到边了(长函数的话题,我们上一讲已经讨论过了,这里就不再赘述了)。
大类还有一种表现形式,类里面有特别多的字段和函数,也许,每个函数都不大,但架不住数量众多啊,这也足以让这个类在大类中占有一席之地。这一讲,我们就主要来说说这种形式的大类。
分模块的程序
我先来问你一个问题,为什么不把所有的代码都写到一个文件里?
你可能会觉得这个问题很傻,心里想:除了像练习之类的特定场景,谁会在一个正经的项目上把代码写到一个文件里啊?
没错,确实没有人这么做,但你思考过原因吗?把代码都写到一个文件里,问题在哪里呢?
事实是,把代码写到一个文件里,一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节。
人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了。
好,你现在已经理解了,对程序进行模块划分,本质上就是在把问题进行分解,而这种做法的背后原因,就是人类的认知能力是有限的。
理解了这一点,我们再回过头来看大类这个坏味道,你就知道问题出在哪了。如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了。
按照这个思路,解决大类的方法也就随之而来了,就是把大类拆成若干个小类。你可能会想,这我也知道,问题是,怎么拆呢?
大类的产生
想要理解怎么拆分一个大类,我们需要知道,这些类是怎么变成这么大的。
职责不单一
最容易产生大类的原因在于职责的不单一。我们先来看一段代码:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
private AuthorType authorType;
private ReviewStatus authorReviewStatus;
private EditorType editorType;
...
}
这个 User 类拥有着一个大类的典型特征,其中包含着一大堆的字段。面对这样一个类时,我们要问的第一个问题就是,这个类里的字段都是必需的吗?
我们来稍微仔细地看一下这个类,用户 ID(userId)、姓名(name)、昵称(nickname) 之类应该是一个用户的基本信息,后面的邮箱(email)、电话号码(phoneNumber) 也算是和用户相关联的。今天的很多应用都提供使用邮箱或电话号码登录的方式,所以,这个信息放在这里,也算是可以理解。
再往后看,作者类型(authorType),这里表示作者是签约作者还是普通作者,签约作者可以设置作品的付费信息,而普通作者不能。后面的字段是作者审核状态(authorReviewStatus),就是说,作者成为签约作者,需要有一个申请审核的过程,这个状态就是审核的状态。
再往后,又出现了一个编辑类型(editorType),编辑可以是主编,也可以是小编,他们的权限是不一样的。
这还不是这个 User 类的全部。但是,即便只看这些内容,也足以让我们发现一些问题了。
首先,普通的用户既不是作者,也不是编辑。作者和编辑这些相关的字段,对普通用户来说,都是没有意义的。其次,对于那些成为了作者的用户,编辑的信息意义也不大,因为作者是不能成为编辑的,反之亦然,编辑也不会成为作者,作者信息对成为编辑的用户也是没有意义的。
在这个类的设计里面,总有一些信息对一部分人是没有意义,但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况,关键点就在于,这里只有“一个”用户类。
普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则。
单一职责原则,它让我们把模块的变化纳入考量。单一职责原则是衡量软件设计好坏的一把简单而有效的尺子,通常来说,很多类之所以巨大,大部分原因都是违反了单一职责原则。而想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来。
回到我们这个类上,其实,我们前面已经分析了,虽然这是一个类,但其实,它把不同角色关心的东西都放在了一起,所以,它变得如此庞大。我们只要把不同的信息拆分开来,问题也就迎刃而解了。下面就是把不同角色拆分出来的结果:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
...
}
public class Author {
private long userId;
private AuthorType authorType;
private ReviewStatus authorReviewStatus;
...
}
public class Editor {
private long userId;
private EditorType editorType;
...
}
这里,我们拆分出了 Author 和 Editor 两个类,把与作者和编辑相关的字段分别移到了这两个类里面。在这两个类里面分别有一个 userId 字段,用以识别这个角色是和哪个用户相关。这个大 User 类就这样被分解了。
字段未分组
大类的产生往往还有一个常见的原因,就是字段未分组。
有时候,我们会觉得有一些字段确实都是属于某个类,结果就是,这个类还是很大。比如,我们看一下上面拆分的结果,那个新的 User 类:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
...
}
前面我们分析过,这些字段应该都算用户信息的一部分。但是,即便相比于原来的 User 类小了许多,这个类依然也不算是一个小类,原因就是,这个类里面的字段并不属于同一种类型的信息。比如,userId、name、nickname 几项,算是用户的基本信息,而 email、phoneNumber 这些则属于用户的联系方式。
从需求上看,基本信息是那种一旦确定就不怎么会改变的内容,而联系方式则会根据实际情况调整,比如,绑定各种社交媒体的账号。所以,如果我们把这些信息都放到一个类里面,这个类的稳定程度就要差一些。所以,我们可以根据这个理解,把 User 类的字段分个组,把不同的信息放到不同的类里面。
public class User {
private long userId;
private String name;
private String nickname;
private Contact contact;
...
}
public class Contact {
private String email;
private String phoneNumber;
...
}
这里我们引入了一个 Contact 类(也就是联系方式),把 email 和 phoneNumber 放了进去,后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整,我们把不同的信息重新组合了一下,但每个类都比原来要小。
对比一下,如果说前后两次拆分有什么不同,那就是:前面是根据职责,拆分出了不同的实体,后面是将字段做了分组,用类把不同的信息分别做了封装。
或许你已经发现了,所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。没错,很多人写代码写不好,其实是缺乏软件设计的功底,不能有效地把各种模型识别出来。所以,想要写好代码,还是要好好学学软件设计的。
关于大类的讨论差不多就接近尾声了,但我估计结合这一节最初的讨论,有些人心中会升起一些疑问:如果我们把大类都拆成小类,类的数量就会增多,那人们理解的成本是不是也会增加呢?
其实,这也是很多人不拆分大类的借口。
在这个问题上,程序设计语言早就已经有了很好的解决方案,所以,我们会看到在各种程序设计语言中,有诸如包、命名空间之类的机制,将各种类组合在一起。在你不需要展开细节时,面对的是一个类的集合。再进一步,还有各种程序库把这些打包出来的东西再进一步打包,让我们只要面对简单的接口,而不必关心各种细节。
如此层层封装,软件不就是这样构建出来的吗?
总结
以上讲了大类这个坏味道,这是程序员日常感知最为深刻的坏味道之一。
应对大类的解决方案,主要是将大类拆分成小类。我们需要认识到,模块拆分,本质上是帮助人们降低理解成本的一种方式。
我们还介绍了两种产生大类的原因:
1. 职责不单一;
1. 字段未分组。
无论是哪种原因,想要有效地对类进行拆分,我们需要对不同内容的变动原因进行分析,而支撑我们来做这种分析的就是单一职责原则。将大类拆分成小类,本质上在做的是设计工作,所以,想要写好代码,程序员需要学好软件设计。
有人觉得拆分出来的小类过多,不易管理,但其实程序设计语言早就为我们提供了各种构造类集合的方式,比如包、命名空间等,再进一步,还可以封装出各种程序库。
记住一句话:把类写小,越小越好。
长参数列表:如何处理不同类型的长参数?
上面分别讲了长函数和大类,它们都是那种“我一说,你就知道是怎么回事”的坏味道,而且都让我们深恶痛绝,唯恐避之不及。这样典型的坏味道还有一个,就是长参数列表。
好吧,我知道你的脑子里已经出现了一个长长的参数列表了。每个程序员只要想到,一个函数拥有几十甚至上百个参数,内心就难以平静下来。
那么,函数为什么要有参数呢?我们知道,不同函数之间需要共享信息,于是才有了参数传递。
其实,函数间共享信息的方式不止一种,除了参数列表,最常见的一种方式是全局变量。但全局变量会带给我们太多意想不到的问题,所以,在初学编程的时候,老师就会告诉我们,不要使用全局变量。从程序设计语言发展的过程中,我们也可以看到,取消全局变量已经成为了大势所趋。
但函数之间还是要传递信息的,既然不能用全局变量,参数就成了最好的选择,于是乎,只要你想到有什么信息要传给一个函数,就自然而然地把它加到参数列表中,参数列表也就越来越长了。
那么,长参数列表有啥问题呢?这个问题其实我在上一讲已经说过了,人脑能够掌握的内容有限,一旦参数列表变得很长,作为普通人,我们就很难对这些内容进行把控了。
既然长参数列表的问题是数量多,秉承我们一以贯之的思路,解决这个问题的关键就在于,减少参数的数量。
既然知道了解决方案的方向,那我们接下来就具体看看,有哪些方法可以减少参数的数量。
聚沙成塔
我们来看一段代码:
public void createBook(final String title,
final String introduction,
final URL coverUrl,
final BookType type,
final BookChannel channel,
final String protagonists,
final String tags,
final boolean completed) {
...
Book book = Book.builder
.title(title)
.introduction(introduction)
.coverUrl(coverUrl)
.type(type)
.channel(channel)
.protagonists(protagonists)
.tags(tags)
.completed(completed)
.build();
this.repository.save(book);
}
这是一个创建作品的函数,我们可以看到,这个函数的参数列表里,包含了一部作品所要拥有的各种信息,比如:作品标题、作品简介、封面 URL、作品类型、作品归属的频道、主角姓名、作品标签、作品是否已经完结等等。
如果你阅读这段代码,只是想理解它的逻辑,你或许会觉得这个函数的参数列表还挺合理,它把创建一部作品所需的各种信息都传给了函数,这是大部分人面对一段代码时理解问题的角度。不过,虽然这样写代码容易让人理解,但这不足以让你发现问题。
比如,如果你现在要在作品里增加一项信息,表明这部作品是否是签约作品,也就是这部作品是否可以收费,那你该怎么办?
顺着前面的思路,我们很自然地就会想到给这个函数增加一个参数。但正如我在讲“长函数”那节课里说到的,很多问题都是这样,每次只增加一点点,累积起来,便不忍直视了。
如果我们有了“坏味道”的视角,我们就会看到这里面的问题:这个函数的参数列表太长了。
怎么解决这个问题呢?
这里所有的参数其实都是和作品相关的,也就是说,所有的参数都是创建作品所必需的。所以,我们可以做的就是将这些参数封装成一个类,一个创建作品的参数类:
public class NewBookParamters {
private String title;
private String introduction;
private URL coverUrl;
private BookType type;
private BookChannel channel;
private String protagonists;
private String tags;
private boolean completed;
...
}
这样一来,这个函数参数列表就只剩下一个参数了,一个长参数列表就消除了:
public void createBook(final NewBookParamters parameters) {
...
}
这里你看到了一个典型的消除长参数列表的重构手法:将参数列表封装成对象。
或许你还有个疑问,只是把一个参数列表封装成一个类,然后,用到这些参数的时候,还需要把它们一个个取出来,这会不会是多此一举呢?就像这样:
public void createBook(final NewBookParamters parameters) {
...
Book book = Book.builder
.title(parameters.getTitle())
.introduction(parameters.getIntroduction())
.coverUrl(parameters.getCoverUrl())
.type(parameters.getType())
.channel(parameters.getChannel())
.protagonists(parameters.getProtagonists())
.tags(parameters.getTags())
.completed(parameters.isCompleted())
.build();
this.repository.save(book);
}
如果你也有这样的想法,那说明一件事:你还没有形成对软件设计的理解。我们并不是简单地把参数封装成类,站在设计的角度,我们这里引入的是一个新的模型。一个模型的封装应该是以行为为基础的。
之前没有这个模型,所以,我们想不到它应该有什么行为,现在模型产生了,它就应该有自己配套的行为,那这个模型的行为是什么呢?从上面的代码我们不难看出,它的行为应该是构建一个作品对象出来。你理解了这一点,我们的代码就可以进一步调整了:
public class NewBookParamters {
private String title;
private String introduction;
private URL coverUrl;
private BookType type;
private BookChannel channel;
private String protagonists;
private String tags;
private boolean completed;
public Book newBook() {
return Book.builder
.title(title)
.introduction(introduction)
.coverUrl(coverUrl)
.type(type)
.channel(channel)
.protagonists(protagonists)
.tags(tags)
.completed(completed)
.build();
}
}
创建作品的函数就得到了极大的简化:
public void createBook(final NewBookParamters parameters) {
...
Book book = parameters.newBook();
this.repository.save(book);
}
好,这里我们讨论消除长参数列表的一种方法,将参数列表封装成类。还记得我们前面提到的“如何扩展需求”这个问题吗?如果需求扩展,需要增加创建作品所需的内容,那这个参数列表就是不变的,相对来说,它就是稳定的。
或许你会问,那这个类就会不断膨胀,变成一个大类,那该怎么办呢?关于这一点,你可以回顾一下我们的上一段,看看怎么解决大类的问题。
动静分离
把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类。
我们再来看一段代码:
public void getChapters(final long bookId,
final HttpClient httpClient,
final ChapterProcessor processor) {
HttpUriRequest request = createChapterRequest(bookId);
HttpResponse response = httpClient.execute(request);
List<Chapter> chapters = toChapters(response);
processor.process(chapters);
}
这个函数的作用是根据作品 ID 获取其对应的章节信息。如果,单纯以参数个数论,这个函数的参数数量并不算多。
如果你只是看这个函数,可能很难发现直接的问题。即便我们认为有问题,也可以用一个类把这个函数的参数都封装起来。不过,秉承我在这个专栏里讨论的一贯原则,绝对的数量并不是关键点,参数列表也应该是越少越好。针对这个函数,我们需要稍微分析一下这几个参数。
在这几个参数里面,每次传进来的 bookId 都是不一样的,是随着请求的不同而改变的。但 httpClient 和 processor 两个参数都是一样的,因为它们都有相同的逻辑,没有什么变化。
换言之,bookId 的变化频率同 httpClient 和 processor 这两个参数的变化频率是不同的。一边是每次都变,另一边是不变的。
郑烨老师的《软件设计之美》中讲分离关注点时曾经讲到过,不同的数据变动方向也是不同的关注点。这里表现出来的就是典型的动数据(bookId)和静数据(httpClient 和processor),它们是不同的关注点,应该分离开来。
具体到这个场景下,静态不变的数据完全可以成为这个函数所在类的一个字段,而只将每次变动的东西作为参数传递就可以了。按照这个思路,代码可以改成这个样子:
public void getChapters(final long bookId) {
HttpUriRequest request = createChapterRequest(bookId);
HttpResponse response = this.httpClient.execute(request);
List<Chapter> chapters = toChapters(response);
this.processor.process(chapters);
}
这个坏味道其实是一个软件设计问题,代码缺乏应有的结构,所以,原本应该属于静态结构的部分却以动态参数的方式传来传去,无形之中拉长了参数列表。
这个例子也给了我们一个提示,长参数列表固然可以用一个类进行封装,但能够封装出这个类的前提条件是:这些参数属于一个类,有相同的变化原因。
如果函数的参数有不同的变化频率,就要视情况而定了。对于静态的部分,我们前面已经看到了,它可以成为软件结构的一部分,而如果有多个变化频率,我们还可以封装出多个参数类来。
告别标记
我们再来看一个例子:
public void editChapter(final long chapterId,
final String title,
final String content,
final boolean apporved) {
...
}
这是我们在前面“重复代码”里提到过的一个函数,我们稍微复习一下,这几个参数分别表示,待修改章节的 ID、标题和内容,最后一个参数表示这次修改是否直接审核通过。
前面几个参数是修改一个章节的必要信息,而这里的重点就在最后这个参数上。
之所以要有这么个参数,从业务上说,如果是作者进行编辑,之后要经过审核,而如果编辑来编辑的,那审核就直接通过,因为编辑本身扮演了审核人的角色。所以,你发现了,这个参数实际上是一个标记,标志着接下来的处理流程会有不同。
使用标记参数,是程序员初学编程时常用的一种手法,不过,正是因为这种手法实在是太好用了,造成的结果就是代码里面彩旗(flag)飘飘,各种标记满天飞。不仅变量里有标记,参数里也有。很多长参数列表其中就包含了各种标记参数。这也是很多代码产生混乱的一个重要原因。
在实际的代码中,我们必须小心翼翼地判断各个标记当前的值,才能做好处理。
解决标记参数,一种简单的方式就是,将标记参数代表的不同路径拆分出来。回到这段代码上,这里的一个函数可以拆分成两个函数,一个函数负责“普通的编辑”,另一个负责“可以直接审核通过的编辑”。
// 普通的编辑,需要审核
public void editChapter(final long chapterId,
final String title,
final String content) {
...
}
// 直接审核通过的编辑
public void editChapterWithApproval(final long chapterId,
final String title,
final String content) {
...
}
标记参数在代码中存在的形式很多,有的是布尔值的形式,有的是以枚举值的形式,还有的就是直接的字符串或者整数。无论哪种形式,我们都可以通过拆分函数的方式将它们拆开。在重构中,这种手法叫做移除标记参数(Remove Flag Argument)。
本篇文章我们讲了长函数、大类和长参数列表三种不同的坏味道,但在我们阐述了对于这些坏味道的理解之后,仔细想想这些坏味道,其实背后都是一件事:我们应该编写“短小”的代码。
这是由人类理解复杂问题的能力决定的,只有短小的代码,我们才能有更好地把握,而要写出短小的代码,需要我们能够“分离关注点”。
总结
本章我们讲解的坏味道是长参数列表,它同样是一个“我一说,你就知道是怎么回事”的坏味道。
应对长参数列表主要的方式就是减少参数的数量,一种最直接的方式就是将参数列表封装成一个类。但并不是说所有的情况都能封装成类来解决,我们还要分析是否所有的参数都有相同的变动频率。
- 变化频率相同,则封装成一个类;
- 变化频率不同的话:
- 静态不变的,可以成为软件结构的一部分;
- 多个变化频率的,可以封装成几个类。
除此之外,参数列表中经常会出现标记参数,这是参数列表边长的另一个重要原因。对于这种标记参数,一种解决方案就是根据这些标记参数,将函数拆分成多个函数。
记住一句话:减小参数列表,越小越好