代码之丑-13种典型的坏代码味道 D4

  1. 可变的数据:不要让你的代码“失控”
  2. 变量声明与赋值分离:普通的变量声明,怎么也有坏味道?
  3. 依赖混乱:你可能还没发现问题,代码就已经无法挽救了

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

对于程序,最朴素的一种认知是“程序 = 数据结构 + 算法”,所以数据几乎是软件开发最核心的一个组成部分。在一些人的认知中,所谓做软件,就是一系列的 CRUD 操作,也就是对数据进行增删改查。再具体一点,写代码就把各种数据拿来,然后改来改去。改数据,几乎已经成了很多程序员写代码的标准做法。然而这种做法也带来了很多的问题。

满地天飞的Setter

1
2
3
4
5
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}

这是一段对作品进行审核的代码,通过 bookId,找到对应的作品,接下来,将审核状态设置成了审核通过。之所以举例到这段代码,就是因为这里用了 setter。setter 往往是缺乏封装的一种表现,它意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。你不知道数据会在哪里被何人以什么方式修改,造成的结果是往往是别人的修改会让你的代码崩溃。与之相伴的还有各种衍生出来的问题,最常见的就是我们常说的并发问题。

可变的数据是可怕,但是比可变的数据。更可怕的是不可控的变化。而暴露 setter 就是这种不可控的变化,把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以这种修改完全是不可控的。缺乏封装再加上不可控的变化,在作者的心目中 setter 几乎是排名第一的坏味道。

处理这种情况就是限制修改数据的接口,尽量不提供setter方法,例如刚刚上述的例子中,可以封装一个这样的方法对外使用

1
2
3
4
5
class Book { 
public void approve() {
this.reviewStatus = ReviewStatus.APPROVED;
}
}

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

1
2
3
4
Book book = new Book(); 
book.setBookId(bookId);
book.setTitle(title);
book.setIntroduction(introduction);

实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数,如果参数过多,可以使用Builder模式封装构造过程。

可变的数据

我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。在这种思路下,可变数据(Mutable Data)就成了一种坏味道,这是 Martin Fowler 在新版《重构》里增加的坏味道,它反映着整个行业对于编程的新理解。

这种思想源自于函数式编程范式,在part2我会系统整理这个范式,这里简单记录一下,在函数式编程中,数据是建立在不变的基础上的,如果需要更新则会产生一个新的数据副本,而旧的数据保持不变,随摄函数式编程在开发领域的地位越来越重要,人们对于不变性的理解也越发得深刻,不变性有效的解决了可变数据产生的各种问题。

Martin Fowler 对于可变数据给出的解决方案,基本上是限制对于数据的更新,降低其风险,这与我们前面提到的对 setter 的封装如出一辙。除了该方案可以解决可变数据,还有一个解决方案是编写不变类。

Java 中的 String 类就是一个不变类,比如,如果我们把字符串中的一个字符替换成
另一个字符,String 类给出的函数签名是这样的:

1
String replace(char oldChar, char newChar);

其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串。如何设计不变类,主要做到以下三点:

  • 所有的字段只在构造函数中初始化
  • 所有的方法都是纯函数
  • 如果需要有改变,返回一个新的对象,而不是修改已有字段

这里提一下第二点,笔者最早接触纯函数这个概念是在学习前端的React框架中,纯函数的意思是指:函数的输出只依赖于输入,不依赖于上下文,在React中又有一个著名的状态管理框架Redux,使用这个组件管理数据需要你先初始化一个store用初始化数据,并定义action,action是用来描述行为的数据结构,而action仅仅是描述行为,为了真正的修改数据需要使用reducer这个纯函数,这里仅仅展示一个reduce demo就能明白纯函数的含义

1
2
3
4
5
6
const counter = (state = ininialState, action) => {
switch (action.Type) {
case "ADD-ONE":
return {counter : state.counter + 1};
}
}

在 JDK 的演化中,我们可以看到一个很明显的趋势,新增的类越来越多地采用了不变类的设计,比如用来表示时间的类。就目前的开发状态而言,想要完全消除可变数据是很难做到的,但我们可以尽可能地编写一些不变类。

为什么不变类需要方法是纯函数 ?

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

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

变量的初始化

来看这段代码

1
2
3
4
5
6
7
EpubStatus status = null; 
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
status = EpubStatus.CREATED;
} else {
status = EpubStatus.TO_CREATE;
}

我们这次的重点在 status 这个变量上,虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 status 的变量值,其实是在经过后续处理之后,才有了真正的值。换言之,从语义上说,第一行的变量初始化其实是没有用的,这是一次假的初始化。按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,变量初始化最好一次性完成。

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

所以,我们编程时要有一个基本原则:变量一次性完成初始化。例如使用如下的形式:

1
2
3
4
5
6
7
8
9
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;
}

依赖混乱:你可能还没发现问题,代码就已经无法挽救了

讲大类这个坏味道的时候曾经说过,为了避免同时面对所有细节,我们需要把程序进行拆分,分解成一个又一个的小模块。但随之而来的问题就是,我们需要把这些拆分出来的模块按照一定的规则重新组装在一起,这就是依赖的缘起。

为了解决这个问题,在设计的原则中又一个叫做依赖倒置的原则是专门用处理这类问题的,会在那片博客中专门整理

本文结束 感谢阅读
0%