作者 | 夏梓耀
杏仁后端工程师,励志成为计算机艺术家
Introduction
本文主要探讨如何基于类型系统的一些 Trick 去提升代码的正确性。我们以变种 Builder 模式的缺点作为出发点(Type-Unsafe Builder Pattern),提出改进版本(Type-Safe Builder Pattern),优化正确性;然后再针对改进版本的缺点(Boilerplate Code),提出解决方案(JSR269 API),提升实用性;最后我们发散一下问题,并寻找解决方案。
Type-Unsafe Builder Pattern
变种 Builder 模式
我们在本文中提到的 Builder 模式,并非 GOF 中的 Builder 模式(敲黑板),而是其变种(没那么复杂)版本,其目的用于简化(不可变)对象的构造(比如 Google 的 Protobuf 协议,在生成为 Java 代码后,都会提供一个 Builder 类去构造相关 Message)
注:本文后面提到的 Builder 模式均指这种变种模式
我们定义一个数据类型 User (它有很多字段,但为了简化代码,这里只列出两个):
public class User {
private String name;
private String password;
public void setName(String name) {
this.name = name;
}
public void setPassword(String password) {
this.password = password;
}
}
然后通过无参构造函数和 setter 方法去构造这个对象:
User user = new User();
user.setName("milo");
user.setPassword("123456");
这种方式的缺点已经不用多说了(大家应该都明白,或者正深受其害中),因为对象的构造过程是非连续的,也就是说对象可处于一个构造不完全的状态,我们很容易写出将对象传入各个方法,每个方法去赋值对象的某一部分这样的代码,这其实引入了一个状态空间,如果状态空间是强可控的,那还好(但依然提高了维护成本,你需要牢牢掌握住对象的构造过程,什么字段在何处被赋值);如果不可控,那么就很难保证这个对象是否被正确的构造,可能在某个方法中覆盖了某字段,也可能遗漏了某字段导致 NPE。
现代 Java 编程更倾向于 Immutable Objects:
public class User {
private final String name;
private final String password;
public User(String name, String password) {
this.name = name;
this.password = password;
}
}
这就杜绝了对象的构造不完全状态,它会鼓励我们写出可维护性更高的代码,我们不再将一个构造不完全的对象丢入各种赋值方法中,而是会写出各种方法返回需要的值,然后传入统一的构造方法中,构造过程不再分散,状态空间被消除。
不过光靠一个构造函数是不够的,尤其是字段数很多的时候,这时我们很难保证,我们传对了值,当然现代 IDE 有这方面的优化,我们可以看到当前值被赋给了哪个参数,但是依然需要记住参数顺序,所以变种 Builder 模式产生了:
public class UserBuilder {
private String name;
public UserBuilder name (String name) {
this . name = name;
return this ;
}
public UserBuilder password (String password) {
this . password = password;
return this ;
}
public User build () {
return new User(name, password);
}
public static UserBuilder user () {
return new UserBuilder();
}
}
在构建对象时,我们可以通过链式调用完成对象的构造:
User user = UserBuilder.user()
.name("milo")
.password("123456")
.build();
注:一般我们会将 UserBuilder 作为 User 的内部类,并提供 builder 静态方法返回 UserBuilder 的构造
这种方式构造对象,解决了构造函数调用的尴尬,我们不用 care 参数传递的顺序,先 name 还是 password 都行,其次,还简化了默认值问题,通过构造函数传参,我们需要重载多个构造函数,或者将参数声明为 Optional (声明为 Optional 会加大调用端传参的负担),而使用 Builder 你只要不调用对应字段的方法即可。
这种变种 Builder 模式非常的常见,用起来也很优雅,但问题就是要写的样板代码(Boilerplate Code)太多,为此 Lombok 提供了 @Builder 注解,使我们不需要写任何额外代码,就可以优雅的构造对象
缺陷:缺少编译期保证
UserBuilder 相比构造函数的缺点在于缺少编译期保障,在最近的一个项目中,我看到有两个 Merge Request 都是专门解决因使用 Builder 时遗漏了某字段(该类型的字段比较多)的设置,而该字段并没有默认值,所以出 NPE 了,即例如如下代码:
User user = UserBuilder.user()
.name("milo")
.build();
...
user.getPassword().equals(password) // NPE
那么如何让我们放心的构造对象并且不会出现 NPE 呢?
试试 @NonNull
Lombok 提供了 @NonNull 注解,声明在字段上就会进行空值检查,这种方式有两个缺点:
强制空值检查:生成了额外的代码,每次赋值都会进行判断
运行时检查:代码在运行中才会进行检查,如果你某个 test case 没覆盖到,那么这个代码依然会上生产环境
那么有什么办法可以在编译期就保证构造正确呢?有,而且不止一种。
Type-Unsafe Builder Pattern
Phantom Type
Phantom Type 翻译为:幽灵类型,顾名思义,就是幽灵般的类型,这种类型往往在运行时可以消失,因为在运行时没有任何作用,它们最大的特点就是没有任何实例(Java 的 Void 就是幽灵类型的例子),我们可以通过合理应用幽灵类型来提高代码的正确性,比如我有一个队伍类型:Team,还有一个比赛类型:Game:
public class Team {
private List<Member> members;
public boolean isReady() {...}
public boolean isPlaying() {...}
...
}
public class Game {
public Team start(Team team) {
if (team.isReady()) {...}
}
public Team end(Team team) {
if (team.isPlaying()) {...}
}
...
}
Game 的 start 方法会检查 Team 是否已经准备好(人数是否满足),end 方法会判断队伍是否在比赛中(我们不能对还没开始的队伍进行结算),这里其实引入了一个状态机,需要我们在状态转换时进行状态检查(额外状态维护代码引入可能会影响原本的逻辑),更重要的是这个检查是运行时的,我们很容易将没有 ready 的 team 传入 start 方法,这时我们可以通过定义幽灵类型来做静态的检查,将状态检查逻辑放在编译期:
public class Team<S> {
private List<Member> members;
...
// phantom types
static abstract class READY {}
static abstract class STARTED {}
static abstract class END {}
}
public class Game {
public Team<Team.STARTED> start(Team<Team.READY> team) {...}
public Team<Team.END> end(Team<Team.STARTED> team) {...}
...
}
我们额外定义了三个幽灵类型:READY、STARTED、END 用于表示状态,将 Team 扩展为范型类,携带额外的状态类型,我们看 Game 的方法改动,一眼就可以看出这个简单的状态机:
READY -start-> STARTED -end-> END
注:幽灵类型不一定非要定义成内部类,也可以是外部,不一定是抽象类,也可以是接口类型
幽灵类型可以帮助我们写出非法状态不可表的代码,因为不合法状态的对象连编译都过不去,这里就不再详细展开了,下面我们就使用基于幽灵类型的 Builder 模式:
Phantom Type Builder Pattern
废话不多说直接上代码:
public class User {
private final String name;
private final String password;
private User(String name, String password) {
this.name = name;
this.password = password;
}
public static User build(Builder<TRUE, TRUE> builder) {
return new User(builder.name, builder.password);
}
public static Builder<FALSE, FALSE> builder() {
return new Builder<FALSE, FALSE>();
}
public static class Builder<HNAME, HPASSWORD> {
private String name;
private String password;
private Builder() {
}
private Builder(String name, String password) {
this.name = name;
this.password = password;
}
public Builder<TRUE, HPASSWORD> name(String name) {
this.name = name;
return new Builder<TRUE, HPASSWORD>(name, this.password);
}
public Builder<HNAME, TRUE> password(String password) {
this.password = password;
return new Builder<HNAME, TRUE>(this.name, password);
}
}
// phantom types
static abstract class TRUE {}
static abstract class FALSE {}
}
可以看到我们将 Builder 类,扩展成了:Builder<HNAME, HPASSWORD>
多出来的类型参数用于表示某字段是否被赋值,默认构造 Builder 类时(builder 方法)返回:Builder<FALSE, FALSE>
表示未初始化,每当调用字段赋值方法时,对应的类型参数都会被设为 TRUE;我们将 build 方法移入了 User 类中,其接受的参数为:Builder<TRUE, TRUE>
即只有全部被设值的情况下才可以调用 build 执行构建,所以如下代码不会通过编译:
User user = User.build(User.builder().name("milo")); // compile error
User user = User.build(User.builder().password("123456")); // compile error
User user = User.build(User.builder().name("milo").password("123456")); // compile success
默认值处理
那么这种方式如何处理默认值呢?其实很简单,就是将有默认值的字段对应的范型参数去掉即可,如:
public static class Builder<HNAME> {
private String name;
private String password = "abc123_";
...
public Builder<TRUE> name(String name) {
this.name = name;
return new Builder<TRUE>(name, this.password);
}
public Builder<HNAME> password(String password) {
this.password = password;
return new Builder<HNAME>(this.name, password);
}
}
此时 password 调不调用都能通过编译
这种模式有一个缺点,我想你已经看到了,就是别扭的 build 方法调用,它不再是链式的,因为我们要保证一个事情:只有 Builder<TRUE, TRUE>
类型的对象可以执行 build,目前 Java 的类型系统还做不到链式调用,但是具有强大类型系统的 Scala 可以(定义一个 implicit class 即可),实不相瞒这种方式就是从 Scala 那边来的
我们牺牲一点点优雅,换来正确性更高的程序,这是一件值得的事情,不过既能保证编译期检查,又能保持优雅的方式还是有的:
Staged Builder Pattern
阶段式构造,指为每一个字段的赋值都定义专门的类和方法,我们直接看代码,先定义阶段类型:
public interface UserBuilders {
interface Name {
Password name(String name);
}
interface Password {
Build password(String password);
}
interface Build {
User build();
}
}
然后定义 UserBuilder 实现类:
public class UserBuilder implements
UserBuilders.Name,
UserBuilders.Password,
UserBuilders.Build {
private String name;
private String password;
@Override
public UserBuilders.Password name(String name) {
this.name = name;
return this;
}
@Override
public UserBuilders.Build password(String password) {
this.password = password;
return this;
}
@Override
public User build() {
return new User(this.name, this.password);
}
}
再在 User 中定义静态方法 builder:
public static UserBuilders.Name builder() {
return new UserBuilder();
}
此时你发现,你只能写出下面的构造代码:
User user = User.builder()
.name("milo")
.password("123456")
.build();
name 方法后面一定跟着 password 方法,password 方法后面一定是 build 方法,你只能按照这个顺序去构造对象
阶段式构造,会引入很多的阶段类型,每个类型表示某个阶段的构造,只有一个设置方法;而且仅用到 Java 类型系统的核心特性,在 Java 4 等始祖版本中都能实现
硬要说个缺点出来的话,就是必须按照固定顺序去写,这可能一开始会不太适应,而幽灵类型是顺序无关的
默认值处理
依然很优雅,只需给 UserBuilders.Build
接口添加方法,且将不是必须的阶段类型删除,前一阶段返回类型跳过该阶段:
public interface UserBuilders {
interface Name {
Build name(String name);
}
interface Build {
Build password(String password)
User build();
}
}
此时,你会发现 password 不再必须
阶段式构造,本质上等价于构造函数的柯理化,每个阶段类型都等价于一个 Function,只是给每个 Function 类型加了一个更有意义的名字:
public class User {
...
public static Function<String, Function<String, Supplier<User>>> builder() {
return name -> password -> () -> new User(name, password);
}
}
...
User.builder().apply("milo").apply("123456").get();
User.builder().name("milo").password("123456").build();
注:默认值处理仅需提供重载构造函数的柯理化版本即可
缺陷:样板代码(Boilerplate Code)
一种技术可以解决问题,但是又很难(cost 过大)用于实践,那么这种技术就缺少工程完备性
上面的两种 Builder 模式都可以解决问题,但都缺少工程完备性,究其原因就是要写很多的样板代码,你可能会问,在没有 Lombok 提供 @Builder 注解下,这种模式不是照样用吗?注意这里的复杂性被乘以2了,你不仅要写本来的 Builder 代码,你还需要关注类型层面的计算,因为例子中 User 只有两个字段,如果有 5 个,我们就要写这样的代码:Builder<HNAME, HPASSWORD, TRUE, HEMAIL, HPHONE>
,每增加一个字段,都要扩展一下类型,阶段式构造也是一样的
生活的秘密在于……用一个烦恼代替另一个烦恼 —— 查尔斯.M.舒尔茨
好在这个烦恼是可以被解决的,学习 Lombok 就行了
JSR 269(Pluggable Annotation Processing API)
我们完全可以像 Lombok 那样定义一个自己的 @Builder 注解,去消除样板代码,篇幅和主题关系我不会详细展开讲解具体编写过程,参考中有很多资料可以学习,这里我们仅说说原理:
JSR 269 是什么?看这里:https://docs.oracle.com/javase/6/docs/technotes/guides/apt/index.html,2006年发布,为 JPA 2.0 提供元模型生成支持
Pluggable Annotation Processing 顾名思义就是提供注解处理的,不过是在编译期,而非我们平常使用的 Spring 那样在运行期处理,使用它仅需要实现一个抽象类:javax.annotation.processing.AbstractProcessor
,覆盖其 init 和 process 方法,剩下的就是针对 AST (抽象语法树) 的操作了,没错这和 macro 非常像,本质上就是一种编译期元编程(我亲切的将其称为:面向 AST 编程),或者说就是编译期插件
为了让编译器找到处理器,我们可以在打包时通过META-INF/services/javax.annotation.processing.Processor
文件注册自己,否则就要通过 javac 参数 -processor
来显式指定
Round
使用注解处理器时,有个比较重要的概念,就是 round,javac 的编译过程大致为:
1. 初始化插入式注解处理器(执行 AbstractProcessor 的 init 方法) ->
2. 词法分析,语法分析,输入符号表,产生 JCTree ->
3. 注解处理(执行 AbstractProcessor 的 process 方法 ) ->
4. 数据流处理 ->
5. 解语法糖 ->
6. 字节码生成
在执行完 3 注解处理后,会判断语法树有没有变动,没有就执行 4,有就要回到 2 再进行解析,直到所有插入式注解处理器都不再更改语法树为止,每次循环称为 round
注:1~3 为编译器前端,4~6 为编译器后端
面向 AST 编程
为什么叫面向 AST 编程呢?因为你的输入是 AST,操作的是各种 AST 节点,输出的还是 AST
构造语法树是一件很枯燥的事,因为不是以写代码的形式去产生代码,而是手写语法树,你需要有一个 code 到 tree 的对应机制,Lombok 就是直接通过修改 JCTree 去完成其功能的,还有一种方式就是使用 JavaPoet 库,该库提供了更友好的 API 去产生 .java 文件的(最终还是要调用 Filer(JSR 269)的接口输出文件),值得注意的是,JavaPoet 只产生新代码,不会修改原有语法树,而 JCTree (即:使用${java.home}/../lib/tools.jar
的接口)可以让你修改它,这不是一种好的实践(但确实有用)
注:我们的 vlogging 类库就是使用的 JCTree 的接口,除了 JavaPoet 外还有JavaParser 等库也可用于代码生成
产生新代码的好处就是:本质上是一种代码生成,我们可以看到源文件,而修改 JCTree 是看不到代码的,因为它只是修改了内存中的语法树,而不会去修改对应源文件
不过我还是选择了修改 JCTree 的方式去解决 Phantom Type Builder Pattern 的样板代码问题,不是真香,而是 JavaPoet 以前玩过了(就换个没接触过的),而且用 JavaPoet 吐代码需要将内部类提出来
注:这里是 Phantom Type Builder Pattern 的源码:https://github.com/MiloXia/xbuilder,有兴趣可以看看
就目前来说,不管哪种方式去操作 AST 都不是很方便,调试异常麻烦,全靠 sout 了,写过一次不会再想写第二次
Jilt
为什么不去实现更优雅的阶段式构造,而去实现基于幽灵类型的 Builder 模式呢?因为已经有人实现啦,就是这个库:https://github.com/skinny85/jilt,一个 star 数只有 20 (包括我点的)的小众库,强烈建议将 Lombok 的@Builder
换成Jilt的@Builder(style= BuilderStyle.TYPE_SAFE)
注:本文真的不是一则广告
Null-check
最后,我们来看看我们到底解决了个什么问题,表面上是优化了 Builder 模式的正确性,让我们构造对象有更好的编译期保证,实际上我们只是避免了 NPE,我们提供了一种针对 Builder 模式的编译期 @NonNull
注:虽然本文核心是解决 null-check 问题,但是前面提到的那些类型系统的 Trick 都是普适的,你可以发挥你的想象力,用于解决别的问题上
前面我们都已经写编译器插件了,那么为何不直接通过扩展编译器去实现编译期去做空值检查呢?
当然可以,且已实现,这是一个我早就想介绍的工具了:https://checkerframework.org/
(不要因为那充满学术风的主页,而掉头就走)
Checker Framework 是一个静态分析工作,它增强了 Java 的类型系统,以编译器插件的形式,提供了额外的 type-check
The Checker Framework supports adding pluggable type systems to the Java language in a backward-compatible way. Java’s built-in type-checker finds and prevents many errors — but it doesn’t find and prevent *enough* errors. The Checker Framework lets you run an additional type-checker as a plug-in to the javac compiler.
Programmers can write the type qualifiers in their programs and use the plug-in to detect or prevent errors. The Checker Framework is useful both to programmers who wish to write error-free code, and to type system designers who wish to evaluate and deploy their type systems.
我们可能不会用它去扩展 Java 类型系统,但完全可以用它现有的功能(各种 check)去优化代码正确性,最终写出 bug free 的代码(当然这只是个愿景)
这里是官方推荐的一个 step-by-step 的教程案例:https://github.com/glts/safer-spring-petclinic/wiki
主要讲的就是如何使用 Checker Framework 的 null-checker,需要注意的是:它默认就是检查任何地方的字段是否有可能为空,所以如果允许为空的,你需要添加@Nullable
注解;另一个坑是,它还会检查依赖库的代码,所以需要配置一下将第三方库排除在检查外
总结
一句话:我们应该尽可能将低级错误在编译期解决掉,将 bug 扼杀在摇篮中,因此我们需要多利用类型系统,多使用静态工具去优化代码正确性
注:这个正确性不是你是否按照要求实现了需求,而是你写的代码是否满足你自己的预期
参考
https://michid.wordpress.com/2008/08/13/type-safe-builder-pattern-in-java/
https://www.endoflineblog.com/type-safe-builder-pattern-in-java-and-the-jilt-library
Lombok原理分析与功能实现:
http://ipython.mythsman.com/2017/12/19/1/
Java-JSR-269-插入式注解处理器:
https://liuyehcf.github.io/2018/02/02/Java-JSR-269-%E6%8F%92%E5%85%A5%E5%BC%8F%E6%B3%A8%E8%A7%A3%E5%A4%84%E7%90%86%E5%99%A8/
Java注解处理器:
https://race604.com/annotation-processing/
全文完
以下文章您可能也会感兴趣:
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。