你好,我是郑晔。
我们已经用连续几讲的篇幅在挑战很多人固有的编程习惯了,从各种控制语句,到 getter 和 setter,甚至连直接使用基本类型都已经成了坏味道,这一讲,我们再来挑战一个很多人习以为常的编程习惯:变量的声明与赋值。
我估计有人已经露出了惊讶的表情。你可能会想:要说前面几种坏味道可能确实是编码没有做好,该封装没封装,该返回没返回,一个变量声明怎么还会有坏味道啊?难道是变量声明都不让用了吗?
诚然,变量声明是写程序不可或缺的一部分,我并不打算让你戒掉变量声明,严格地说,我们是要把变量初始化这件事做好。
我们先来看一段代码:
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 变量的初始化就一次性完成了,我们的原则就统一了,不需要在这种特殊的场景下纠结了。
接下来,我们在来看一段代码:
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_READ 和 BOOK_WRITE 两个枚举对象添加了进去,这样,这个 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");
}
这是一个传输时的映射方案,将不同的语言版本映射为不同的代码。这里 CODE_MAPPING 是一个类的 static 变量,而这个类的声明里还有其它一些变量。所以,隔了很远之后,才有一个 static 块向这个集合添加元素。
如果我们能够用一次性声明的方式,这个单独的 static 块就是不需要的:
private static Map<Locale, String> CODE_MAPPING = ImmutableMap.of(
LOCALE.ENGLISH, "EN",
LOCALE.CHINESE, "CH"
);
对比我们改造前后的代码,二者之间还有一个更关键的区别:前面的代码是命令式的代码,而后面的代码是声明式的代码。
命令式的代码,就是告诉你“怎么做”的代码,就像改造前的代码,声明一个集合,然后添加一个元素,再添加一个元素。而声明式的代码,是告诉你“做什么”的代码,改造后就是,我要一个包含了这两个元素的集合。
我在《软件设计之美》专栏中讲 DSL 时,曾经讲过二者的区别,声明式的代码体现的意图,是更高层面的抽象,把意图和实现分开,从某种意义上来说,也是一种分离关注点。
所以,用声明式的标准来看代码,是一个发现代码坏味道的重要参考。
回想一下今天讲的坏味道,无论是变量的声明与赋值分离,还是初始化一个集合的分步骤,其实反映的都是不同时代编程风格的烙印。变量的声明是 C 早期的编程风格,异常处理是 Java 早期的风格,而集合声明也体现出不同版本 Java 的影子。
我们学习编程不仅仅是要学习实现功能,编程的风格也要与时俱进。
今天我们继续挑战着很多人习惯的编程方式,讲了变量初始化带来的问题。变量的初始化包含变量的声明和赋值两个部分,一个编程的原则是“变量要一次性完成初始化”。
这就衍生出一个坏味道:变量的声明和赋值是分离的。二者分离带来的问题就是,把赋值的过程与业务处理混杂在一起。发现变量声明与赋值分离一个做法就是在声明前面加上 final,用“不变性”约束代码。
我们还谈到了集合的初始化,传统的集合初始化方式是命令式的,而今天我们完全可以用声明式的方式进行集合的初始化,让初始化的过程一次性完成。再进一步,以声明式的标准来看代码,会帮助我们发现许多的坏味道。
如果今天的内容你只能记住一件事,那请记住:一次性完成变量的初始化。
变量初始化可能是一个很多人都没有注意过的坏味道,你可以回去翻翻自己的代码,用这个标准衡量一下,你能发现什么问题吗?欢迎在留言区分享你的发现。
参考资料:
评论