《代码之丑》学习笔记09——可变的数据:不要让你的代码“失控”

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

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

改数据,几乎已经成了很多程序员写代码的标准做法。然而,这种做法也带来了很多的问题。这一讲,我们还是从一段问题代码开始。

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 是完全没有必要存在的。

在今天的软件开发中,人们为了简化代码的编写做出了各种努力,用 IDE 生成的代码是一种,还有一种常见的做法就是,通过工具和框架生成相应代码的。在 Java 世界中,Lombok 就是这样的一种程序库,它可以在编译的过程中生成相应的代码,而我们需要做的,只是在代码上加上对应的 Annotation。它最大的优点是不碍眼,也就是不会产生大量可以看见的代码。因为它的代码是在编译阶段生成的,所以,那些生成的代码在源码级别上是不存在的。下面就是一个例子:


@Getter
@Setter
class Book {
  private BookId bookId;
  private String title;
  private String introduction;
}

不过,我想说的是,不写 setter 的代码并不代表没有 setter。因为 @Setter 的存在,其它代码还是可以调用这个类的 setter,存在的问题并不会改变。所以,一个更好的做法是禁用 @Setter。下面是 lombok.config 的配置,通过它,我们就可以禁用 @Setter 了:


lombok.setter.flagUsage = error
lombok.data.flagUsage = error

你或许注意到了,这里除了 @Setter,我还禁用了 @Data,这是 Lombok 中另外一个 Annotation,表示的是同时生成 getter 和 setter。既然我们禁用 @Setter 是为了防止生成 setter,当然也要禁用 @Data 了

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 ”的对象。

在 JDK 的演化中,我们可以看到一个很明显的趋势,新增的类越来越多地采用了不变类的设计,比如,用来表示时间的类。原来的 Date 类里面还有各种 setter,而新增的 LocalDateTime 则一旦初始化就不会再修改了。如果要操作这个对象,则会产生一个新的对象:

LocalDateTime twoDaysLater = now.plusDays(2);

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

一个更实用的做法是,区分类的性质。我《软件设计之美》中讲 DDD 的战术设计时提到过,我们最核心要识别的对象分成两种,实体和值对象**。实体对象要限制数据变化,而值对象就要设计成不变类。**

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

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值