该笔记包含以下内容:
- 长参数列表:如何处理不同类型的长参数?
- 滥用控制语句:出现控制结构,多半是错误的提示
- 缺乏封装:如何应对火车代码和基本类型偏执问题?
长参数列表:如何处理不同类型的长参数?
顾名思义,长参数就是指参数列表过长,如下的示例:1
2
3
4
5
6
7
8
9public 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)
{ ...
长参数列表问题在于数量多,参数列表本身是用来函数之间做信息传递的,而除了使用参数另外的方式就是使用全局变量,但是全局变量总是会带来意想不到的问题,消除全局变量也就是编程的大势所趋。回到长参数列表本身,其实多数时候长参数的列表也并不是一开始就存在的,就像上节笔记提到的长函数问题一样,可能是在迭代过程中的聚沙成塔一样慢慢的加出来的,许多的问题就是这样,每次只加一点点,积累起来就会不忍直视了,对于可能变坏的case,我们需要一定的策略来解决:
将参数列表封装为对象
第一次读到这个方法的时候会有一些疑惑,只是把一个参数列表封装成类,之后用到这些类的时候再把他们一个个的取出来,这会不会是多此一举。实际上站在设计的角度,我们这里是引入一个新的模型,而一个模型的封装应该是以行为为基础的,我们需要结合实际的业务场景为这个封装后的模型提供对应的行为,例如如下的参数封装对象,最后的作用是用来构筑出一个Book对象,那么可以在这个对象内部提供newBook()函数直接构建出一个Book对象
1 | public class NewBookParamters { |
将参数列表封装成类,若需求扩展修改参数实际只需要修改NewBookParamters内的实现就好
动静分离
长参数封装为同一个类的方式并不能解决大部分问题,因为不是所有的情况,参数都属于同一个类,例如如下的情况1
2
3
4
5
6
7
8 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);
}
在这几个参数里面,每次传进来的 bookId 都是不一样的,是随着请求的不同而改变的。
但 httpClient 和 processor 两个参数都是一样的,因为它们都有相同的逻辑,没有什么变
化。换言之,bookId 的变化频率同 httpClient 和 processor 这两个参数的变化频率是不同的。一边是每次都变,另一边是不变的。也就是不同的数据变动方向也是不同的关注点,这里表现就是典型的动数据以及静数据,应该分离将静态不变的数据改为这个函数所在类的一个字段。
移除标记参数
这也是我目前经常重构的一种方式,就是对于函数会根据某个参数值表现出现不同的逻辑这个情况,理想的状态是通过拆分函数将代码拆分开,例如1
2
3
4
5
6public void editChapter(final long chapterId,
final String title,
final String content,
final boolean apporved) {
...
}
最后一个参数表示这次修改是否直接审核通过。函数内会根据这个变量的值来执行不同的逻辑,在重构的时候可以将逻辑分开,原因是使用标记参数会导致代码中各种flag乱飞,不仅局部变量里会有,参数里也会有,长列表参数中就包含,这是代码产生混乱的一个原因。
滥用控制语句:出现控制结构,多半是错误的提示
滥用控制语句,即if、for等语句,这个坏味道非常典型,但是大多数的程序员每天都在使用并且对问题毫无感知,这里举例一些典型的bad case,未来会举例更多
嵌套的代码
这里的一个规模小一点的嵌套语句1
2
3
4
5
6
7
8
9
10
11public void distributeEpubs(final long bookId) {
List<Epub> epubs = this.getEpubsByBookId(bookId);
for (Epub epub : epubs) {
if (epub.isValid()) {
boolean registered = this.registerIsbn(epub);
if (registered) {
this.sendEpub(epub);
}
}
}
}
代码之所以会写成这个样子,其实原因跟长函数中出现的平铺直叙的与原因是一样的,作者把想到的直接写了出去,笔者经常犯会这样的问题,既然我们不喜欢缩进特别多的代码,那就要消除缩进,结合这段代码,我们那可以将for循环内部的逻辑提取成一个函数,如下:
1 | private void distributeEpub(final Epub epub) { |
经过封装后的代码还有有一点嵌套缩进,在distributeEpub里,造成缩进的原因是 if 语句。通常来说,if 语句造成的缩进,很多时候都是在检查某个先决条件,只有条件通过时,才继续执行后续的代码。这样的代码可以使用卫语句(guard clause)来解决,也就是设置单独的检查条件,不满足这个检查条件时,立刻从函数中返回。这也是一种经典的重构手法,使用卫语句来取代潜逃的条件表达式,让我们来看一下改造后的函数
1 | private void distributeEpub(final Epub epub) { |
函数至多缩进一层,这是对象健身操(《ThoughtWorks 文集》)里提到的一个规则,这个文章中提到过九条编程规则,除了缩进另外一条是不要使用else,无论是嵌套的代码,还是 else 语句,我们之所以要把它们视为坏味道,本质上都在追求简单,因为一段代码的分支过多,其复杂度就会大幅度增加。我们一直在说,人脑能够理解的复杂度是有限的,分支过多的代码一定是会超过这个理解范围,处理的else的方式可以使用卫语句来消除。
在软件开发中,有一个衡量代码复杂度常用的标准,叫做圈复杂度(Cyclomatic
complexity,简称 CC),圈复杂度越高,代码越复杂,理解和维护的成本就越高。在圈复杂度的判定中,循环和选择语句占有重要的地位。
只要我们能够消除嵌套,消除 else,代码的圈复杂度就不会很高,理解和维护的成本自然也就会随之降低。
重复的switch
最早我在学习的时候总是会陷入过度理解/错误理解的情况,比如在看到使用重复的switch是一个典型的坏习惯,自动理解成了不要使用switch,实际上这里的情况是指多个函数都需要区分同一种条件的场景,是许多个函数都使用了switch来判断了同一类条件,例如你设计了一个折扣的系统,在每种商品获取价格的地方使用了switch来判断用户的会员等级来返回不同的价格,然后又在获取优惠券的函数有判断了一次等级,比如如下的一个示例1
2
3
4
5
6
7
8
9
10
11
12
13public double getEpubPrice(final User user, final Epub epub) {
double price = epub.getPrice();
switch (user.getLevel()) {
case UserLevel.SILVER:
return price * 0.95;
case UserLevel.GOLD:
return price * 0.85;
case UserLevel.PLATINUM:
return price * 0.8;
default:
return price;
}
}
并不是禁止去使用switch来写这段逻辑,因为本质来说,switch就是if - else的另一种表达形式,这没有任何问题,甚至go语言使用swich的语法封装了多路复用语法,这是指不要重复这段判断逻辑,出现重复的switch通常是缺乏一个模型,对于这个经典的坏味道的重构方式就是用多态来取代条件表达式,这里取代的是条件表达式,而不仅仅是取代switch,这里具体的例子会在 软件设计的基础原则-开闭原则 做详细的讲解,那篇博客完成后会把例子贴到这里。
缺乏封装:如何应对火车代码和基本类型偏执问题?
这节讲另外一个坏味道,缺乏封装在程序设计,不仅仅是在面向对象的编程,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块,任何一个程序员都会认同封装的价值,但是具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。来举几个bad case:
火车残骸
来看一个熟悉的代码1
String name = book.getAuthor().getName();
这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的。实际上我们获取作者名字的时候是不需要知道这么多的细节的,这里的标准就是如果你必须先去了解一个了类的细节才能写出代码是,这只能明这个封装是失败的。
解决这种代码的重构手法叫隐藏委托关系(Hide Delegate,说得更直白一些就是,把
这种调用封装起来:
1 | class Book { |
在学习数据结构时,我们所编写的代码都是拿到各种细节直接操作,但那是在做编程练习,并不是工程上的编码方式,很多人把这种编码习惯带到了工作中。比如说,有人编写一个新的类,第一步是写出这个类要用到的字段,接着就是给这些字段生成相应的 getter/setter方法。很多语言或框架提供的约定就是基于这种getter的,就像 Java 里的 JavaBean,所以相应的配套工具也很方便。现在写出一个getter 往往是 IDE 中一个快捷键的操作,甚至不需要自己手工敲代码。诸如此类种种因素叠加,让暴露细节这种事越来越容易,封装反而成了稀缺品。
这里说一些我的想法,我认为这个坏case成立的前提应该是你的大类目的是用来封装,举一个我最近重构发现的问题,我在设计一个编辑器,它可以编辑操作一种草稿结构,实际上我最早设计这个草稿结构的时候,就使用了这种封装的方式,这个草稿结构是一个List,List中保存对象的属性中还包含另一个List,这个内部List持有的是一个很复杂的类型,简单来说你可以理解为草稿是一个很复杂二维数组结构,我在最早的时候设计这编辑器的Model对象时,将内部所有的方法全部写到了最顶层的草稿类中,即编辑器不知道这个草稿内部到底是什么样子,统统使用草稿类获取自己各个组件需要的data,最后发现了很多问题,比如因为草稿内部的结构过于复杂,对外暴露的方法集中在草稿最的顶层的类上,导致这个类及其的臃肿难以维护。小规模重构时我重新设计了顶层草稿类的作用,草稿类的作用仅仅维护草稿的基础结构,即草稿类不负责获取/修改每一个具体内部的数据,只负责维护这个大的二维数组,每一个小的Model对象负责维护自己的数据方法,即这个草稿类本身不是为了封装,而是为了组织每一个子Model对象,编辑器为了实现正常工作是需要了解草稿内部的一些细节,因为编辑器主UI组件就是为了适配这样特殊的二维数组Model而存在的,而在这个问题中我认为真正需要隐藏是二维数组中保存的每一个对象的实现细节。
基本类型偏执
来看这段代码,这是一个非常清晰的代码,这里有什么坏味道吗?1
public double getEpubPrice(final boolean highQuality, final int chapterSequenc ... }
有的,主要问题集中在了这个返回值上,也就是这个函数签名使用了 double 来表示一个价格,并不是说这样做会有精度问题,而是这样采用基本类型的设计缺少了一个模型,价格本身确实是用浮点数来存储的,但价格与浮点数本身不是同一个概念,有不同的行为需求,例如我们的价格是要求不能小于0的,但是 doubel 类型本身是没有这个限制的,当然使用 double 也可以做非负的限制,可以在这个函数的返回处判断一下若为负则抛出异常。如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写的。所以我们可以引入一个模型封装基础类型的重构手法,即以对象取代基本类型,创建一个Price类来代替double,出现这样问题就在于,我们只看到了模型的相同之处,却忽略了差异的地方。对象健身操这篇文章中也提到过跟这个问题相关的建议:
- 封装所有的基本类型和字符串;
笔者的做工程的朋友经常安利Kotlin,是一套设计优秀切符合软工思想的语言,就例如在Kotlin中没有基础类型,全部使用包装类代替,我最早听到这个概念的时候感觉又像是容器的迭代器一样为了面向对象而面向对象但其实不是,使用包装类是为了跟好的符合工程的思想,我第一次跟他聊这个的时候用性能来做反驳,“一个基础类型才多大呀,封装成包装类多占地”,似乎这个问题也在这次学习找到了答案,性能优化从来不是开发工程首要考虑的任务