呈现形态:
- (1)缺失行为,暴露细节
可变数据最直白的体现就是各种 setter。存在的问题:setter 一方面破坏了封装,同时把一个类的内部行为,即实现细节暴露了出来;另一方面它会带来不可控的修改,给代码增添许多问题。
解决方式:引入行为,封装细节,对应有一种重构手法叫移除设值函数(Remove Setting Method),将变化限制在一定的范围之内。
- (2)可变的数据
可变数据是《重构》第二版新增的坏味道,它背后的思想是函数式编程所体现的不变性。
解决可变数据,一种方式是限制其变化,另一种方式是编写不变类。
- (3)全局数据
编程规则:
限制变化,尽可能编写不可变类。
在实践中,完全消除可变数据是很有挑战的。所以,一个实际的做法是,区分类的性质。值对象就要设计成不变类,实体类则要限制数据变化。
坏味道示例:
满天飞的setter
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}
上面这种操作,意味着你不仅可以读到一个对象的数据,而且还可以修改。而相比于读数据,修改是一个更危险的操作。可修改就意味着可变,而可变的数据会带来许多问题,简言之,你不知道数据会在哪里被何人以什么方式修改,造成的结果是,别人的修改会让你的代码崩溃。
可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。
缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。
上述代码优化重构之后:
public void approve(final long bookId) {
...
book.approve();
...
}
class Book {
public void approve() {
this.reviewStatus = ReviewStatus.APPROVED;
}
}
将行为进行封装之后,作为类的使用者,并不需要知道这个类到底是怎么实现的。更重要的是,这里的变化变得可控了。虽然审核状态这个字段还是会修改,但你所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。
初始化时后的setter
Book book = new Book();
book.setBookId(bookId);
book.setTitle(title);
book.setIntroduction(introduction);
实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数。当构造方法的参数过多时,可考虑使用builder模式来重构。
Book book = new Book(bookId, title, introduction);
下面是 lombok.config 的配置,通过它,我们就可以禁用 @Setter 了:
lombok.setter.flagUsage = error
lombok.data.flagUsage = error
你或许注意到了,这里除了 @Setter,我还禁用了 @Data,这是 Lombok 中另外一个 Annotation,表示的是同时生成 getter 和 setter。既然我们禁用 @Setter 是为了防止生成 setter,当然也要禁用 @Data 了。
我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。
那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。这种想法源自函数式编程这种编程范式。在函数式编程中,数据是建立在不改变的基础上的,如果需要更新,就产生一份新的数据副本,而旧有的数据保持不变。
在实际工作中,我们怎么设计不变类呢?要做到以下三点:
所有的字段只在构造函数中初始化;
所有的方法都是纯函数;
如果需要有改变,返回一个新的对象,而不是修改已有字段。
针对代码生成工具,如果它生成的是一个算法,比如编译器生成器,我可以去用,因为我只要关注接口,不需要关注细节。如果生成的是模型,我倾向于不用它,因为我要关注其中的细节。如果封装它,可能就失去了代码生成的意义。
我常常说到软件设计的品位,生成大量 setter 代码的工具,本身就是设计品位不佳的体现。基于一个品位不佳的工具谈改进,这可能不是我关注的方向。