阅读总结 |《代码之丑》

1.缺乏业务含义的命名:如何精准命名

1.1.命名要能够描述出这段代码在做的事情

例如这样一段代码:

public void processChapter(long chapterId) {
  Chapter chapter = this.repository.findByChapterId(chapterId);
  if (chapter == null) {
    throw new IllegalArgumentException("Unknown chapter [" + chapterId + "]"); 
  }
  
  chapter.setTranslationState(TranslationState.TRANSLATING);
  this.repository.save(chapter);
}
  • 这段代码经过阅读,其实作用就是把一个章节的翻译状态改成翻译中,但是我们需要阅读完这段代码才能理解。像data、info、flag、process、handle、build、maintain、manage、modify这些参数等等。这些名字都属于典型的过于宽泛的名字

  • 如函数名processChapter(处理章节)的命名过于宽泛,不能精准描述,这是项目中很多代码在命名上存在的普遍问题,也是代码难以理解的根源所在

这段代码在做的事情就是“将章节修改为翻译中”,那是不是它就应该叫changeChapterToTranslating呢?

1.2.一个好的名字应该描述意图,而非细节

  • 就这段代码来说, 我们为什么要把翻译状态修改成翻译中,这一定是有原因的,也就是意图。具体到这里的业务,我们把翻译状态修改成翻译中,是因为我们在这里开启了一个翻译的过程。所以,这段函数应该命名startTranslation

最终修改为:

public void startTranslation(long chapterId) {
  Chapter chapter = this.repository.findByChapterId(chapterId);
  if (chapter == null) {
    throw new IllegalArgumentException("Unknown chapter [" + chapterId + "]"); 
  }
  
  chapter.setTranslationState(TranslationState.TRANSLATING);
  this.repository.save(chapter);
}

1.3.不要用技术术语命名

例如:

List<Book> bookList = service.getBooks();
  • 这个bookList变量之所以叫bookList,原因就是它声明的类型是List

比如这样一个场景:如果我发现,我现在需要的是一个不重复的作品集合,也就是说,我需要把这个变量的类型从List改成Set。变量类型你一定会改,但变量名你会改吗?这还真不一定,一旦出现遗忘,就会出现一个奇特的现象,一个叫bookList的变量,它的类型是一个Set。这样,一个新的混淆就此产生了

因为接口是稳定的,而实现是易变的。 我们需要一个更面向意图的名字。其实,我们在这段代码里真正要表达的是拿到了一堆书,所以,这个名字可以命名成books

List<Book> books = service.getBooks();
  • 虽然这里我们只是以变量为例说明了技术术语命名存在的问题,实际上,在实际的项目中,技术名词的出现,往往就代表着它缺少了一个应有的模型

  • 在一个技术类的项目中,这些技术术语其实就是它的业务语言。但对于业务项目,这个说法就必须重新审视了

1.4.用业务语言写代码

编写可维护的代码要使用业务语言,例如以下一段代码

public void approveChapter(long chapterId, long userId) {
  ...
}

这个函数的意图是,确认章节内容审核通过。这里有一个问题,chapterId是审核章节的ID,这个没问题,但userId是什么呢?了解了一下背景,我们才知道,之所以这里要有一个userId,是因为这里需要记录一下审核人的信息,这个userId就是审核人的Id。你看,通过业务的分析,我们会发现,这个userId并不是一个好的命名,因为它还需要更多的解释,更好的命名是reviewerUserId,之所以起这个名字,因为这个用户在这个场景下扮演的角色是审核人(Reviewer)

public void approveChapter(long chapterId, long reviewerUserId) {
  ...
}
  • 从某种意义上来说,这个坏味道也是一种不精准的命名,但它不是那种一眼可见的坏味道,而是需要在业务层面上再进行讨论,所以,它是一种更高级的坏味道

1.5.章节小结

一个好的命名要体现出这段代码在项目中做的事情,而无需展开代码了解其中的细节,这是最低的要求。在这个基础上再进一步,好的命名要准确地体现意图,而不是实现细节。更高的要求是,用业务语言实现代码命名

好的命名,是体现业务含义的命名。

2.乱用英语:站在中国人的视角来看英文命名

现在主流的程序设计语言都是以英语为基础的,且不说欧美人设计的各种语言,就连日本人设计的Ruby、巴西人设计的
Lua,各种语法采用的也全都是英语。所以,想要成为一个优秀的程序员,会用英语写代码是必要的。这里并不是说,程序员的英语一定要多好,但最低限度的要求是写出来的代码要像是在用英语表达

2.1.违反语法规则的命名

一般来说,常见的命名规则是:类名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作例如:

public void completedTranslate(final List<ChapterId> chapterIds) {}

这段代码要做的是将一些章节的信息标记为翻译完成,似乎函数名也能反映这个意思,但仔细一看你就会发现问题。作者想表达的是“完成翻译”,因为是已经翻译完了,所以,他用了完成时的completed,而翻译是translate。这个函数名就成了completedTranslate。由此,你可以看到,作者已经很用心了,但遗憾的是,这个名字还是起错了。

  • completedTranslate并不是一个有效的动宾结构。如果把这个名字改成动宾结构,只要把“完成”译为complete,“翻译”用成它的名词形式 translation就可以了。所以,这个函数名可以改成completeTranslation

  • 又例如: 一个函数名是retranslation,其表达的意图是重新翻译,但作为函数名,它应该是一个动词,所以,正确的命名应该是retranslate

2.2.不准确的英语词汇

  • 在这种情况下,最好的解决方案还是建立起一个业务词汇表,千万不要臆想。

  • 建立词汇表的另一个关键点就是,用集体智慧,而非个体智慧。

2.3.英语单词拼写错误

例如:

public class QuerySort {
    private final SortBy sortBy;
    private final SortFiled sortFiled;
    ...
}

sortFiled是啥?排序文件吗?为啥用的还是过去式?归档?

原来是同时拼错了,应该是排序的字段,是field

2.4.章节小结

使用不准确的单词简写(比如,多个单词的首字母,或者写单词其中的一部分),如何从实践层面上更好地规避这些坏味道:制定代码规范,比如:

  • 类名要用名词,函数名要用动词或动宾短语;
  • 制定代码规范,建立团队的词汇表;
  • 要经常进行代码评审

3.重复代码:简单需求到处修改,怎么办

3.1.不要直接复制粘贴

复制粘贴是最容易产生重复代码的地方,所以,一个最直白的建议就是,不要使用复制粘贴。真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数

3.2.重复的结构

先看以下代码

@Task
public void sendBook() {
  try {
    this.service.sendBook();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}
@Task
public void sendChapter() {
  try {
    this.service.sendChapter();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}
@Task
public void startTranslation() {
  try {
    this.service.startTranslation();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}
  • 这三段函数业务的背景是:一个系统要把作品的相关信息发送给翻译引擎。所以,结合着代码,我们就不难理解它们的含义,sendBook 是把作品信息发出去,sendChapter 就是把章节发送出去,而 startTranslation 则是启动翻译

  • 这几个业务都是以后台的方式在执行,所以,它们的函数签名上增加了一个 TaskAnnotation,表明它们是任务调度的入口。然后,实际的代码执行放到了对应的业务方法上,也就是 service 里面的方法

  • 这三个函数可能在许多人看来已经写得很简洁了,但是,这段代码的结构上却是有重复的,请把注意力放到catch语句里

  • 之所以要做一次捕获(catch),是为了防止系统出问题无人发觉。捕获到异常后,我们把出错的信息通过即时通讯工具发给相关人等,代码里的 notification.send 就是发通知的入口。相比于原来的业务逻辑,这个逻辑是后来加上的,所以,这段代码的作者不厌其烦地在每一处修改了代码。

我们可以看到,虽然这三个函数调用的业务代码不同,但它们的结构是一致的,其基本流程可以理解为:

  • 调用业务函数;
  • 如果出错,发通知。

当你能够发现结构上的重复,我们就可以把这个结构提取出来。从面向对象的设计来说,就是提出一个接口,就像下面这样:

private void executeTask(final Runnable runnable) {
  try {
    runnable.run();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}
  • 接下来提取相同结构后就可以这样写
@Task
public void sendBook() {
  executeTask(this.service::sendBook);
}

3.3做真正的选择

首先上例子

if (user.isEditor()) {
  service.editChapter(chapterId, title, content, true);
} else {
  service.editChapter(chapterId, title, content, false);
}
  • 相信你和我一样,第一眼看到这段代码的感觉一定是,if 选择的一定是两段不同的业务处理。但只要你稍微看一下,就会发现,if 和 else
    两段代码几乎是一模一样的。在经过仔细地“找茬”之后,才能发现,原来是最后一个参数不一样

  • 只有参数不同,是不是和前面说的重复代码是如出一辙的?没错,这其实也是一种重复代码

因为作者在写这段代码时,脑子只想到 if 语句判断之后要做什么,而没有想到这个 if 语句判断的到底是什么。但这段代码客观上也造就了重复

写代码要有表达性。 把意图准确地表达出来,是写代码过程中非常重要的一环。显然,这里的 if 判断区分的是参数,而非动作。所以,我们可以把这段代码稍微调整一下,会让代码看上去更容易理解:

boolean approved = user.isEditor();
service.editChapter(chapterId, title, content, approved);

请注意这里我把 user.isEditor() 判断的结果赋值给了一个 approved 的变量,而不是直接作为一个参数传给 editChapter,这么做也是为了提高这段代码的可读性。因为 editChapter 最后一个参数表示的是这个章节是否审核通过。通过引入approved变量,我们可以清楚地看到,一个章节审核是否通过的判断条件是“用户是否是一个编辑”,这种写法会让代码更清晰

只要你看到 if 语句出现,而且 if 和 else 的代码块长得又比较像,多半就是出现了这个坏味道

同时这里还可以做一个优化

如果将来审核通过的条件改变了,变化的点全都在 approved
的这个变量的赋值上面。如果你追求更有表达性的做法,甚至可以提取一个函数出来,这样,就把变化都放到这个函数里了,就像下面这样:

boolean approved = isApproved(user);
service.editChapter(chapterId, title, content, approved);

private boolean isApproved(final User user) {
  return user.isEditor();
}

3.4.章节小结

能够发现重复,是一个很重要的能力,请记住:不要重复自己,不要复制粘贴

4.长函数:为什么你总是不可避免地写出长函数

4.1.多长的函数才算“长”

对于函数长度容忍度高,这是导致长函数产生的关键点

如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。

  • 回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行

  • 在实际的项目中,可能不是每个人都能做到这一点,所以,我给了一个更为宽松的限制,在自己的标准上翻了番,也就是20行(ps:作者感觉我目前做不到这点,俺也做不到 ,因为对于某些代码业务逻辑复杂的地方,还是无法缩这么短)

4.2.长函数的产生

  • 限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生
4.2.1.以性能为由
  • 在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。性能优化不应该是写代码的第一考量

    • 一方面,一门有活力的程序设计语言本身是不断优化的,无论是编译器,还是运行时,性能都会越来越好;
    • 另一方面,可维护性比性能优化要优先考虑,当性能不足以满足需要时,我们再来做相应的测量,找到焦点,进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点,优化才是有意义
4.2.2.平铺直叙
  • 除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来

4.3.一次加一点

  • 有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
if (code == 400 || code == 401) {
  // 做一些错误处理
}
  • 然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
if (code == 400 || code == 401 || code == 402) {
  // 做一些错误处理
}
  • 你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如:
if (code == 400 || code == 401 || code == 402 || ...
  || code == 500 || ...
  || ...
  || code == 10000 || ...) {
  // 做一些错误处理
}
  • 任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。 对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:

让营地比你来时更干净。
—— 童子军军规

4.4.章节小结

Robert Martin把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。 但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的

5.大类:如何避免写出难以理解的大类

一听到大类,估计你的眼前已经浮现出一片无边无际的代码了。类之所以成为了大类,一种表现形式就是我们上节课讲到的长函数,一个类只要有几个长函数,那它就肯定是一眼望不到边了。大类还有一种表现形式,类里面有特别多的字段和函数,也许,每个函数都不大,但架不住数量众多啊,这也足以让这个类在大类中占有一席之地。这一讲,我们就主要来说说这种形式的大类

5.1.分模块的程序

思考:为什么不把所有的代码都写到一个文件里?

  • 事实是,把代码写到一个文件里

    • 一方面,相同的功能模块没有办法复用
    • 另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节
  • 人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块模块的数量显然会比细节数量少,人们的理解成本就降低了

如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了

5.2.大类的产生

5.2.1.职责不单一

最容易产生大类的原因在于职责的不单一。我们先来看一段代码

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;
  ...
}
  • 首先,普通的用户既不是作者,也不是编辑。作者和编辑这些相关的字段,对普通用户来说,都是没有意义的。其次,对于那些成为了作者的用户,编辑的信息意义也不大,因为作者是不能成为编辑的,反之亦然,编辑也不会成为作者,作者信息对成为编辑的用户也是没有意义

  • 在这个类的设计里面,总有一些信息对一部分人是没有意义,但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况,关键点就在于,这里只有“一个”用户类

  • 普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则而想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来

回到我们这个类上,其实,我们前面已经分析了,虽然这是一个类,但其实,它把不同角色关心的东西都放在了一起,所以,它变得如此庞大。我们只要把不同的信息拆分开来,问题也就迎刃而解了。下面就是把不同角色拆分出来的结果:

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类就这样被分解了

5.3.字段未分组

有时候,我们会觉得有一些字段确实都是属于某个类,结果就是,这个类还是很大。比如,我们看一下上面拆分的结果,那个新的 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放了进去,后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整,我们把不同的信息重新组合了一下,但每个类都比原来要小

  • 对比一下,如果说前后两次拆分有什么不同,那就是:前面是根据职责,拆分出了不同的实体,后面是将字段做了分组,用类把不同的信息分别做了封装

  • 所谓的将大类拆解成小类,本质上在做的工作是一个设计工作

另外,如果你还想有些极致的追求,我给你推荐《ThoughtWorks 文集》这本书里“对象健身操”这一篇,这里提到一个要求:每个类不超过 2 个字段(个人感觉:我觉得这样会导致类太多,业务处理更加复杂)

5.4.本章小结

把类写小,越小越好

6.长参数列表:如何处理不同类型的长参数?

从程序设计语言发展的过程中,我们也可以看到,取消全局变量已经成为了大势所趋

但函数之间还是要传递信息的,既然不能用全局变量,参数就成了最好的选择,于是,只要你想到有什么信息要传给一个函数,就自然而然地把它加到参数列表中,参数列表也就越来越长了

6.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);
}
  • 我们就会看到这里面的问题:这个函数的参数列表太长了

  • 如果你现在要在作品里增加一项信息,表明这部作品是否是签约作品,也就是这部作品是否可以收费,那你该怎么办?顺着前面的思路,我们很自然地就会想到给这个函数增加一个参数。但正如在讲“长函数”那节课里说到的,很多问题都是这样,每次只增加一点点,累积起来,便不忍直视了

那如何解决呢?
这里所有的参数其实都是和作品相关的,也就是说,所有的参数都是创建作品所必需的。所以,我们可以做的就是将这些参数封装成一个类,一个创建作品的参数类:

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) {
  ...
}

这里你看到了一个典型的消除长参数列表的重构手法:将参数列表封装成对象

6.2.动静分离

把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类

我们再来看一段代码:

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 都是不一样的,是随着请求的不同而改变的。但 httpClientprocessor 两个参数都是一样的,因为它们都有相同的逻辑,没有什么变化

  • 换言之,bookId 的变化频率同 httpClientprocessor 这两个参数的变化频率是不同的。一边是每次都变,另一边是不变的

具体到这个场景下,静态不变的数据完全可以成为这个函数所在类的一个字段,而只将每次变动的东西作为参数传递就可以了。按照这个思路,代码可以改成这个样子:

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);
}
  • 这个例子也给了我们一个提示,长参数列表固然可以用一个类进行封装,但能够封装出这个类的前提条件是:这些参数属于一个类,有相同的变化原因

如果函数的参数有不同的变化频率,就要视情况而定了。对于静态的部分,我们前面已经看到了,它可以成为软件结构的一部分,而如果有多个变化频率,我们还可以封装出多个参数类

6.3.告别标记

我们再来看一个例子:

public void editChapter(final long chapterId, 
                        final String title, 
                        final String content, 
                        final boolean apporved) {
  ...
}
  • 使用标记参数,是程序员初学编程时常用的一种手法,不过,正是因为这种手法实在是太好用了,造成的结果就是代码里面彩旗(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)

6.4.本章小结

减小参数列表,越小越好

7.滥用控制语句:出现控制结构,多半是错误的提示

这个坏味道就是滥用控制语句,也就是你熟悉的if、for等等,这个坏味道非常典型,但很多人每天都用它们,却对问题毫无感知。今天我们就先从一个你容易接受的坏味道开始,说一说使用控制语句时,问题到底出在哪?

7.1.嵌套的代码

public 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 循环一层,里面有两个 if ,又多加了两层。即便不是特别复杂的代码,也有这么多的缩进,可想而知,如果逻辑再复杂一点,缩进会成什么样子

  • 这段代码之所以会写成这个样子,其实就是我在讲“长函数”那节课里所说的:“平铺直叙地写代码”

既然我们不喜欢缩进特别多的代码,那我们就要消除缩进。具体到这段代码,一个着手点是 for 循环,因为通常来说,for 循环处理的是一个集合,而循环里面处理的是这个集合中的一个元素。所以,我们可以把循环中的内容提取成一个函数,让这个函数只处理一个元素,就像下面这样:

public void distributeEpubs(final long bookId) {
  List<Epub> epubs = this.getEpubsByBookId(bookId);
  for (Epub epub : epubs) {
    this.distributeEpub(epub);
  }
}


private void distributeEpub(final Epub epub) {
  if (epub.isValid()) {
    boolean registered = this.registerIsbn(epub);
    if (registered) {
      this.sendEpub(epub);
    }
  }
}

这里我们已经有了一次拆分,分解出来distributeEpub函数每次只处理一个元素。拆分出来的两个函数在缩进的问题上,就改善了一点。第一个函数 distributeEpubs 只有一层缩进,这是一个正常函数应有的样子,不过,第二个函数distributeEpub则还有多层缩进,我们可以继续处理一下

7.2.if 和 else

以卫语句取代嵌套的条件表达式(Replace Nested Conditional with Guard Clauses)

我们来看看改进后的 distributeEpub 函数:

private void distributeEpub(final Epub epub) {
  if (!epub.isValid()) {
    return;
  }
  
  boolean registered = this.registerIsbn(epub);
  if (!registered) {
    return;
  }
  
  this.sendEpub(epub);
}

改造后的 distributeEpub 就没有了嵌套,也就没有那么多层的缩进了。你可能已经发现了,经过我们改造之后,代码里只有一层的缩进。当代码里只有一层缩进时,代码的复杂度就大大降低了,理解成本和出现问题之后定位的成本也随之大幅度降低

函数至多有一层缩进,这是“对象健身操(《ThoughtWorks 文集》书里的一篇)”里的一个规则。前面讲“大类”的时候,我曾经提到过“对象健身操”这篇文章,其中给出了九条编程规则, 下面我们再来讲其中的一条:不要使用 else 关键字

没错,else 也是一种坏味道,这是挑战很多程序员认知的。在大多数人印象中, if 和 else 是亲如一家的整体,它们几乎是比翼齐飞的。那么,else 可以不写吗?可以。我们来看看下面的代码

public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
  double price = 0;
  if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
    price = 4.99;
  } else if (sequenceNumber > START_CHARGING_SEQUENCE
        && sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
    price = 1.99;
  } else if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
    price = 2.99;
  } else {
    price = 0.99;
  }
  
  return price;
}

这是一个根据 EPUB 信息进行定价的函数,它的定价逻辑正如代码中所示

  • 如果是高品质书,而且要是章节序号超过起始付费章节,就定价 4.99
  • 对一般的书而言,超过起始付费章节,就定价 1.99
  • 超过进一步付费章节,就定价 2.99
  • 缺省情况下,定价 0.99

就这段代码而言,如果想不使用 else,一个简单的处理手法就是让每个逻辑提前返回,这和我们前面提到的卫语句的解决方案如出一辙

public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
  if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
    return 4.99;
  } 
  
  if (sequenceNumber > START_CHARGING_SEQUENCE
        && sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
    return 1.99;
  } 

  if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
    return 2.99;
  } 
  
  return 0.99;

对于这种逻辑上还比较简单的代码,这么改造还是比较容易的,而对于一些更为复杂的代码,也许就要用到多态来改进代码了。不过在实际项目中,大部分代码逻辑都是逐渐变得复杂的,所以,最好在它还比较简单时,就把坏味道消灭掉。这才是最理想的做法

7.3.重复的 Switch

通过前面内容的介绍,你会发现,循环和选择语句这些你最熟悉的东西,其实都是坏味道出现的高风险地带,必须小心翼翼地使用它们。接下来,还有一个你从编程之初就熟悉的东西,也是另一个坏味道的高风险地带。我们来看两段代码:

public double getBookPrice(final User user, final Book book) {
  double price = book.getPrice();
  switch (user.getLevel()) {
    case UserLevel.SILVER:
      return price * 0.9;
    case UserLevel.GOLD: 
      return price * 0.8;
    case UserLevel.PLATINUM:
      return price * 0.75;
    default:
      return price;
  }
}


public 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,通常都是缺少了一个模型。所以,应对这种坏味道,重构的手法是:以多态取代条件表达式(Relace Conditional with Polymorphism)。具体到这里的代码,我们可以引入一个 UserLevel 的模型,将 switch 消除掉:

interface UserLevel {
  double getBookPrice(Book book);
  double getEpubPrice(Epub epub);
}


class RegularUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice();
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice();
}


class GoldUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.8;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.85;
  }
}


class SilverUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.9;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.85;
  }
}


class PlatinumUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.75;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.8; 

有了这个基础,前面的代码就可以把 switch 去掉了:

public double getBookPrice(final User user, final Book book) {
  UserLevel level = user.getUserLevel()
  return level.getBookPrice(book);
}


public double getEpubPrice(final User user, final Epub epub) {
  UserLevel level = user.getUserLevel()
  return level.getEpubPrice(epub);
}

7.4.本章小结

我们都知道,switch 其实就是一堆“ if…else” 的简化写法,二者是等价的,所以,这个重构手法,以多态取代的是条件表达式,而不仅仅是取代 switch

8.缺乏封装:如何应对火车代码和基本类型偏执问题?

在程序设计中,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块。任何一个程序员都会认同封装的价值,但是,具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里

8.1.火车残骸

String name = book.getAuthor().getName();

这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的

你可以想一想,如果你想写出上面这段代码,是不是必须得先了解 Book 和 Author 这两个类的实现细节?也就是说,我们必须得知道,作者的姓名是存储在作品的作者字段里的。这时你就要注意了:当你必须得先了解一个类的细节,才能写出代码时,这只能说明一件事,这个封装是失败的

解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来:

class Book {
  ...
  public String getAuthorName() {
    return this.author.getName();
  }
  ...
}


String name = book.getAuthorName();

火车残骸这种坏味道的产生是缺乏对于封装的理解,因为封装这件事并不是很多程序员编码习惯的一部分,他们对封装的理解停留在数据结构加算法的层面上

比如说,有人编写一个新的类,第一步是写出这个类要用到的字段,然后,就是给这些字段生成相应的 getter,也就是各种 getXXX。很多语言或框架提供的约定就是基于这种 getter 的,就像 Java 里的 JavaBean,所以相应的配套工具也很方便。现在写出一个getter往往是 IDE 中一个快捷键的操作,甚至不需要自己手工敲代码

要想摆脱初级程序员的水平,就要先从少暴露细节开始。 声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为

在软件行业中,有一个编程的指导原则几乎就是针对这个坏味道的,叫做迪米特法则(Law of Demeter),这个原则是这样说的:

  • 每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的**
  • 每个单元只能与其朋友交谈,不与陌生人交谈;
  • 只与自己最直接的朋友交谈。
  • 这个原则需要我们思考,哪些算是直接的朋友,哪些算是陌生人。火车残骸般的代码显然就是没有考虑这些问题而直接写出来的代码

8.2.基本类型偏执

public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
  ...
}

这是我们上一讲用过的一个函数声明,根据章节信息获取 EPUB(一种电子书的格式) 的价格。也许你会问,这是一个看上去非常清晰的代码,难道这里也有坏味道吗?

没错,有。问题就出在返回值的类型上,也就是价格的类型上。

那么,我们在数据库中存储价格的时候,就是用一个浮点数,这里用 double 可以保证计算的精度,这样的设计有什么问题吗?

确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但实际上,这种采用基本类型的设计缺少了一个模型。

虽然价格本身是用浮点数在存储,但价格和浮点数本身并不是同一个概念,有着不同的行为需求。比如,一般情况下,我们要求商品价格是大于 0 的,但 double 类型本身是没有这种限制的

就以“价格大于 0”这个需求为例,如果使用 double 类型你会怎么限制呢?我们通常会这样写:

if (price <= 0) {
  throw new IllegalArgumentException("Price should be positive");
}

问题是,如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写的

如果补齐这里缺失的模型,我们可以引入一个 Price 类型,这样的校验就可以放在初始化时进行:

class Price {
  private long price;
  
  public Price(final double price) {
    if (price <= 0) {
      throw new IllegalArgumentException("Price should be positive");
    }
    
    this.price = price;
  }
}

这种引入一个模型封装基本类型的重构手法,叫做以对象取代基本类型(Replace Primitive with Object)。一旦有了这个模型,我们还可以再进一步,比如,如果我们想要让价格在对外呈现时只有两位,在没有 Price 类的时候,这样的逻辑就会散落代码的各处,事实上,代码里很多重复的逻辑就是这样产生的。而现在我们可以在 Price 类里提供一个方法:

public double getDisplayPrice() {
  BigDecimal decimal = new BigDecimal(this.price);
  return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}

其实,使用基本类型和使用继承出现的问题是异曲同工的。大部分程序员都学过这样一个设计原则:组合优于继承,也就是说,我们不要写出这样的代码:

public Books extends List<Book> {
  ...
}

而应该写成组合的样子,也就是:

public Books  {
  private List<Book> books;
  ...
}

之所以有人把 Books 写成了继承,因为在代码作者眼中,Books 就是一个书的集合;而有人用double做价格的类型,因为在他看来,价格就是一个 double。这里的误区就在于,一些程序员只看到了模型的相同之处,却忽略了差异的地方。Books 可能不需要提供List的所有方法,价格的取值范围与 double 也有所差异。

但是,Books 的问题相对来说容易规避,因为产生了一个新的模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格的问题却不容易规避,因为这里没有产生新的模型,也就不容易发现这里潜藏着问题。

这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这里说的基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是一个产生这种坏味道的地方。

这里我稍微延伸一下,有很多人对于集合类型(比如数组、List、Map 等等)的使用也属于这种坏味道。之前课程里我提到过“对象健身操(出自《ThoughtWorks 文集》)”这篇文章,里面有两个与此相关的条款,你可以作为参考:

封装所有的基本类型和字符串;
使用一流的集合。

封装之所以有难度,主要在于它是一个构建模型的过程,而很多程序员写程序,只是用着极其粗粒度的理解写着完成功能的代码,根本没有构建模型的意识;还有一些人以为划分了模块就叫封装,所以,我们才会看到这些坏味道的滋生

8.3.本章小结

构建模型,封装散落的代码

9.可变的数据:不要让你的代码“失控”

对于程序,最朴素的一种认知是“程序 = 数据结构 + 算法”,所以,数据几乎是软件开发最核心的一个组成部分。在一些人的认知中,所谓做软件,就是一系列的CRUD操作,也就是对数据进行增删改查。再具体一点,写代码就把各种数据拿来,然后改来改去。我们学习编程时,首先学会的,也是给变量赋值,写出类似 a = b + 1之类的代码。改数据,几乎已经成了很多程序员写代码的标准做法。然而,这种做法也带来了很多的问题。这一讲,我们还是从一段问题代码开始

9.1.满天飞的 Setter

public void approve(final long bookId) {
  ...
  book.setReviewStatus(ReviewStatus.APPROVED);
  ...
}

这是一段对作品进行审核的代码,通过 bookId,找到对应的作品,接下来,将审核状态设置成了审核通过。

我当时之所以注意到这段代码,就是因为这里用了 setter。setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道,我们上节课已经用了一讲的篇幅在说,我提到,很多人在写代码时,写完字段就会利用 IDE 生成 getter,实际情况往往是,生成 getter 的同时,setter 也生成了出来。setter 同 getter 一样,反映的都是对细节的暴露

这就意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。

可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。

缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。

在开篇词里,我们针对代码给出的调整方案是,用一个函数替代了 setter,也就是把它用行为封装了起来:

public void approve(final long bookId) {
  ...
  book.approve();
  ...
}

通过在 Book 类里引入了一个 approve 函数,我们将审核状态封装了起来

class Book {
  public void approve() {
    this.reviewStatus = ReviewStatus.APPROVED;
  }
}

setter 破坏了封装,相信你对这点已经有了一定的理解。不过,有时候你会说,我这个 setter 只是用在初始化过程中,而并不需要在使用的过程去调用,就像下面这样:

Book book = new Book();
book.setBookId(bookId);
book.setTitle(title);
book.setIntroduction(introduction);

实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数:

Book book = new Book(bookId, title, introduction);

消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)。总而言之,setter 是完全没有必要存在的。

9.2.可变的数据

我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内

那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。没错,在这种思路下,可变数据(Mutable Data)成了一种坏味道,这是 Martin Fowler 在新版《重构》里增加的坏味道,它反映着整个行业对于编程的新理解。

解决可变数据,还有一个解决方案是编写不变类。

函数式编程的不变性,其中的关键点就是设计不变类。Java 中的String类就是一个不变类,比如,如果我们把字符串中的一个字符替换成另一个字符,String 类给出的函数签名是这样的

String replace(char oldChar, char newChar);

其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串。

那么,在实际工作中,我们怎么设计不变类呢?要做到以下三点:

  • 所有的字段只在构造函数中初始化;
  • 所有的方法都是纯函数;
  • 如果需要有改变,返回一个新的对象,而不是修改已有字段。
    回过头来看我们之前改动的“用构造函数消除 setter”的代码,其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的approve函数,同样也可以:
class Book {
  public Book approve() {
    return new Book(..., ReviewStatus.APPROVED, ...);
  }
}

这里,我们创建出了一个“其它参数和原有 book 对象一模一样,只是审核状态变成了 APPROVED ”的对象。

就目前的开发状态而言,想要完全消除可变数据是很难做到的,但我们可以尽可能地编写一些不变类

如果你还想进一步提升自己对于不变性的理解,我们可以回到函数式编程这个编程范式的本质,它其实是对程序中的赋值进行了约束。基于这样的理解,连赋值本身其实都会被归入到坏味道的提示,这才是真正挑战很多人编程习惯的一点。

9.3.本章小结

Martin Fowler 在《重构》中还提到一个与数据相关的坏味道:全局数据(Global Data)。如果你能够理解可变数据是一种坏味道,全局数据也就很容易理解了,它们处理手法基本上是类似的,这里我就不再做过多的阐述了。

10.变量声明与赋值分离:普通的变量声明,怎么也有坏味道

诚然,变量声明是写程序不可或缺的一部分,我并不打算让你戒掉变量声明,严格地说,我们是要把变量初始化这件事做好

10.1.变量的初始化

我们先来看一段代码:

EpubStatus status = null;
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
  status = EpubStatus.CREATED;
} else {
  status = EpubStatus.TO_CREATE;
}

这段代码在做的事情是向另外一个服务发请求创建 EPUB(一种电子书格式),如果创建成功,返回值是 HTTP 的 201,也就表示创建成功,然后就把状态置为 CREATED;而如果没有成功,则把状态置为 TO_CREATE。后面对于 TO_CREATE 状态的作品,还需要再次尝试创建

这里,我们暂且把是否要写 else 放下,这是我们在前面已经讨论过的一个坏味道。

我们这次的重点在 status 这个变量上,虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 status 的变量值,其实是在经过后续处理之后,才有了真正的值换言之,从语义上说,第一行的变量初始化其实是没有用的,这是一次假的初始化

按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,变量初始化最好一次性完成。这段代码里的变量赋值是在声明很久之后才完成的,也就是说,变量初始化没有一次性完成

这种代码真正的问题就是不清晰,变量初始化与业务处理混在在一起。 通常来说,这种代码后面紧接着就是一大堆更复杂的业务处理。当代码混在一起的时候,我们必须小心翼翼地从一堆业务逻辑里抽丝剥茧,才能把逻辑理清,知道变量到底是怎么初始化的。很多代码难读,一个重要的原因就是把不同层面的代码混在了一起。

这种代码在实际的代码库中出现的频率非常高,只不过,它会以各种变形的方式呈现出来。有的变量甚至是在相隔很远的地方才做了真正的赋值,完成了初始化,这中间已经夹杂了很多的业务代码在其中,进一步增加了理解的复杂度。

所以,我们编程时要有一个基本原则:变量一次性完成初始化。
有了这个理解,我们可以这样来修改上面这段代码

final CreateEpubResponse response = createEpub(request);
final EpubStatus status = toEpubStatus(response);


private EpubStatus toEpubStatus(final CreateEpubResponse response) {
  if (response.getCode() == 201) {
    return EpubStatus.CREATED;
  }


  return EpubStatus.TO_CREATE;
}

在这段改进的代码中,我们提取出了一个函数,将 response 转成对应的内部的 EPUB 状态。

其实,很多人之所以这样写代码,一个重要的原因是很多人的编程习惯是从 C 语言来的。C 语言在早期的版本中,一个函数用到的变量必须在整个函数的一开始就声明出来。

在 C 语言诞生的年代,当时计算机能力有限内存小,编译器技术也处于刚刚起步的阶段,把变量放在前面声明出来,有助于减小编译器编写的难度。到了 C++ 产生的年代,这个限制就逐步放开了,所以,C++ 程序是支持变量随用随声明的。对于今天的大多数程序设计语言来说,这个限制早就不存在了,但很多人的编程习惯却留在了那个古老的年代

还有一点不知道你注意到了没有,在新的变量声明中,我加上了 final,在 Java 的语义中,一个变量加上了 final,也就意味着这个变量不能再次赋值。对,我们需要的正是这样的限制。

上一讲,我们讲了可变的数据会带来怎样的影响,其中的一个结论是,尽可能编写不变的代码。这里其实是这个话题的延伸,尽可能使用不变的量

如果我们能够按照使用场景做一个区分,把变量初始化与业务处理分开,你会发现,在很多情况下,变量只在初始化完成之后赋值,就足以满足我们的需求了,在一段代码中,需要使用可变量的场景并不多。

这个原则其实可以推广一下,在能够使用 final 的地方尽量使用 final,限制变量的赋值。

这里说的“能够使用”,不仅包括普通的变量声明,还包含参数声明,还有类字段的声明,甚至还可以包括类和方法的声明。当然,我们这里改进的考量主要还是在变量上。你可以尝试着调整自己现有的代码,给变量声明都加上 final,你就会发现许多值得改进的代码。

对于 Java 程序员来说,还有一个特殊的场景,就是异常处理的场景,强迫你把变量的声明与初始化分开,就像下面这段代码:

InputStream is = null;


try {
  is = new FileInputStream(...);
  ...
} catch (IOException e) {
  ...
} finally {
  if (is != null) {
    is.close(); 
  }
}

之所以要把InputStream变量 is 单独声明,是为了能够在 finanlly 块里面访问到。其实,这段代码写成这样,一个重要的原因是 Java 早期的版本只能写成这样,而如果采用 Java 7 之后的版本,采用try-with-resource的写法,代码就可以更简洁了:

try (InputStream is = new FileInputStream(...)) {
  ...
}

这样一来,InputStream 变量的初始化就一次性完成了,我们的原则就统一了,不需要在这种特殊的场景下纠结了。

10.2.集合初始化

接下来,我们在来看一段代码:

List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
check.grantTo(Role.AUTHOR, permissions);

这是一段给作者赋予作品读写权限的代码,逻辑比较简单,但这段代码中也存在一些坏味道。我们把注意力放在 permissions 这个集合上。之所以要声明这样一个 List,是因为 grantTo 方法要用到一个List作为参数。

我们来看这个 List 是怎样生成的。这里先给 permission 初始化成了一个 ArrayList,这个时候,permissions 虽然存在了,但我们并不会把它传给 grantTo 方法,它还不能直接使用,因为它还缺少必要的信息。然后,我们将 BOOK_READBOOK_WRIT 两个枚举对象添加了进去,这样,这个 permissions 对象才是我们真正需要的那个对象。

我们不难发现,其实 permissions 对象一开始的变量声明,并没有完成这个集合真正的初始化,只有当集合所需的对象添加完毕之后,这个集合才是它应有的样子。换言之,只有添加了元素的集合才是我们需要的。

这样解释这段代码,你是不是就发现了,这和我们前面所说的变量先声明后赋值,本质上是一回事,都是从一个变量的声明到初始化成一个可用的状态,中间隔了太远的距离。

之所以很多人习惯这么写,一个原因就是在早期的 Java 版本中,没有提供很好的集合初始化的方法。像这种代码,也是很多动态语言的支持者调侃 Java 啰嗦的一个靶子。

现如今,Java 在这方面早已经改进了许多,各种程序库已经提供了一步到位的写法,我们先来看看 Java 9 之后的写法:

List<Permission> permissions = List.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);
check.grantTo(Role.AUTHOR, permissions);

如果你的项目还没有升级 Java 9 之后的版本,使用 Guava(Google 提供的一个 Java 库)也是可以做成类似的效果

List<Permission> permissions = ImmutableList.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);
check.grantTo(Role.AUTHOR, permissions);

不知道你注意到没有,第二段代码里的 List 用的是一个 ImmutableList,也就是一个不可变的 List,实际上,你查看第一段代码的实现就会发现,它也是一个不变的 List。这是什么意思呢?也就是说,这个 List 一旦创建好了,就是不能修改了,对应的实现就是各种添加、删除之类的方法全部都禁用了。

初看起来,这是限制了我们的能力,但我们对比一下代码就不难发现,很多时候,我们对于一个集合的使用,除了声明时添加元素之外,后续就只是把它当作一个只读的集合。所以,在很多情况下,一个不变集合对我们来说就够用了

其实,这段代码,相对来说还是比较清晰的,稍微再复杂一些的,集合的声明和添加元素之间隔了很远,不注意的话,甚至不觉得它们是在完成一次初始化。

private static Map<Locale, String> CODE_MAPPING = new HashMap<>();
...


static {
  CODE_MAPPING.put(LOCALE.ENGLISH, "EN");
  CODE_MAPPING.put(LOCALE.CHINESE, "CH");
}

对比我们改造前后的代码,二者之间还有一个更关键的区别:前面的代码是命令式的代码,而后面的代码是声明式的代码。

命令式的代码,就是告诉你“怎么做”的代码,就像改造前的代码,声明一个集合,然后添加一个元素,再添加一个元素。而声明式的代码,是告诉你“做什么”的代码,改造后就是,我要一个包含了这两个元素的集合。

所以,用声明式的标准来看代码,是一个发现代码坏味道的重要参考

10.3.本章小结

一次性完成变量的初始化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值