Lombok @With 的纯弊端及如何避免

由于是第一篇写关于 Lombok 的日志,所以有些不情愿去开门见山直接触及 @With, 而要先提一提本人对 Lombok 的接触过程。


两三年之前写 Java 代码一直都是全手工打造。一个数据类,所有必须的 setter/getter, toString, hashcode() 等全体现在源代码中,当然是在 IDE 中自动生成的。听说过 Lombok,但觉得它用了会影响到对源代码的阅读,因为造成代码的行为与源代码所展示的不一致,还可能依赖于特定的 IDE 或构建工具插件,所以一直未真正应用。

然而现代语言一直在避免不断书写象 JavaBean 里那一大片样本代码,同时解决试图提高覆盖率写出毫无意义单元测试的烦恼。比如 Scala 发展出了 case class, Kotlin 的 data class, Python 的 @dataclass,还有 JDK 14 引入的及至 JDK 16 转正的  record, 都是为了自动生成 Java 类的 setter/getter/toSring/hashcode/equals 等方法。 所以源代码中看不到实际可调用方法不该再是问题,况且在 JDK 5 加入的 enum 类型本质上也是在源代码的背后生成了一系列的方法和类型声明的。

Lombok 不只是为了写 JavaBean 而生的,它的能耐可比生成数据类强大的多,它由一系列 RetentionPolicy.SOURCE 的注解为指令,在编译器依赖于 JDK 6 引入的 JSR 269: Pluggable Annotation Processing API 对 Lombok 注解生成相应的指节代码。生成的代码反编译后可看个究竟,虽然 Lombok 的 JavaDoc 会解释每一个注解的效果,但查看生成的 class 反编译后的源代码最能真切其背后发生了什么。

对 Lombok 的 @With 注解抱怨自然是在打开相应的 class 文件后产生的,现在终于切入主题了。Lombok 最早在 v0.11.4 中实验性的加入 @Wither, 后来在 v1.18.10 中正式转正并更名为 @With。@With 很容易给人以美好的愿景,比如下面的代码
1@Data
2@AllArgsConstructor
3@With
4public class Mountain {
5    private  String name;
6    private double latitude;
7    private double longitude;
8    private String country;
9}

编译后我们就能在代码中使用 withXxx() 方法了
1Mountain mountain1 = new Mountain("", 1, 2, "");
2Mountain mountain2 = mountain1
3                         .withName("newName")
4                         .withCountry("newCountry")
5                         .withLatitude(101)
6                         .withLongitude(102);

如果不仔细阅读 @With 的文档很难理解每次 withXxx() 方法调用会发生什么, 会直接修改原对象属性,还是总是生成新对象?

读读反编译 Mountain.class 后的源代码,胜过看十遍文档,那就看到 Mountain.class 由 @With 生成的相应源码吧
 1    public Mountain withName(String name) {
 2        return this.name == name ? this : new Mountain(name, this.latitude, this.longitude, this.country);
 3    }
 4
 5    public Mountain withLatitude(double latitude) {
 6        return this.latitude == latitude ? this : new Mountain(this.name, latitude, this.longitude, this.country);
 7    }
 8
 9    public Mountain withLongitude(double longitude) {
10        return this.longitude == longitude ? this : new Mountain(this.name, this.latitude, longitude, this.country);
11    }
12
13    public Mountain withCountry(String country) {
14        return this.country == country ? this : new Mountain(this.name, this.latitude, this.longitude, country);
15    }

一看吓一跳,问题来了
  1. withXxx() 方法中一律用 == 来比较新旧值,这是不准确的。比如相同的两个字符串或 Integer 可能相等也可能不等
  2. withXxx() 是链式调用,中间过程会产生大量的临时对象(浅拷贝),虽然是被快速回收,仍然会增加内存回收与碎片整理的负担
  3. withXxx() 调用可能会生成新对象,也可能返回无修改的原对象,给调用者增添不确定性。它仿佛是为了在多线程环境下避免竞争,细究又不像

为什么 Lombok 的 @With 会让我产生迷惑,主要是在要不要创建新对象的问题上不明确。withXxx() 方法语义上一 般是暗含了可支持链式调用,比如直接属性修改或用在 Builder 模式上在最终构建对象之前逐个准备必须的属性。比如业界典型的用例有 aws-java-sdk 中的 AmazonWebServiceRequest 实现类和 Apache AVRO 生成的 domain object。

AWS 列举 S3 Bucket 中对象的请求 ListObjectRequest
1ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
2                                               .withBucketName("foo")
3                                               .withPrefix("bar")
4                                               .withMaxKeys(100);

Apache AVRO 自动生成的 builder 方法使用
 1User user = User.newBuilder()
 2    .setUserId(123)
 3    .setUserName("Anna")
 4    .setAddresses(Arrays.asList("foo", bar"))
 5    .build();
 6
 7// 或对现有对象进行深拷贝创建新对象, 再修改某些属性
 8User newUser = User.newBuilder(user)
 9    .setUserId(456)
10    .setUserName("Scott")
11    .build()

以上两个用例中非常清楚是否要创建新对象,而且 Apache AVRO 基于现有对象创建新对象时采用的是深拷贝。

基于以上理由,这就是为什么本人如标题所说的那般,认为 @With 纯粹就是个弊端,不值得 Lombok 拥有。

如果不使用 @With, 那么有什么 Lombok 替代方案还实现链式调用或修改现有对象的多个属性,或明确创建新对象来逐个修改属性呢?答案是

  1. 直接修改现有对象属性,用 @Setter 或 @Data 搭配 @Accessors(chain = true)
  2. 基于现有对象创建新对象(浅拷贝), 用 @Builder(toBuilder = true)

@Setter 或 @Data + @Accessors(chain = true)

如果需求是只要链式方法调用修改现有对象的属性

@Setter 和 @Data 都能为我们生成 setter 方法,所以下面以 @Setter 为例

如果仅用 @Setter 或 @Data, 生成的 setter 方法返回 void,比如
1@Setter
2public class User {
3    private String name;
4    private List<String> addresses;
5}

生成的代码如下
 1public class User {
 2    private String name;
 3    private List<String> addresses;
 4
 5    public User() {
 6    }
 7
 8    public void setName(String name) {
 9        this.name = name;
10    }
11
12    public void setAddresses(List<String> addresses) {
13        this.addresses = addresses;
14    }
15}

这就无法使用链式方法调用,书写上不够流畅。不得不
1User user = new User();
2user.setName("Scott");
3user.setAddresses(Arrays.asList("foo", "bar"));

在需设置大量属性时带来不便。不过加上 @Accessors(charin = true) 就不一样了,我们来看加上它后生成的字节码
1@Setter
2@Accessors(chain = true)
3public class User {
4    private String name;
5    private List<String> addresses;
6}

生成的代码为
 1public class User {
 2    private String name;
 3    private List<String> addresses;
 4
 5    public User() {
 6    }
 7
 8    public User setName(String name) {
 9        this.name = name;
10        return this;
11    }
12
13    public User setAddresses(List<String> addresses) {
14        this.addresses = addresses;
15        return this;
16    }
17}

唯一的区别只在每个 setter 方法返回了 this, 方便下一次调用,于是我们就可以
1User user = new User();
2user.setName("Tigger")
3     .setAddresses(Arrays.asList("baz"));

@Builder(toBuilder = true)

基于现有对象浅拷贝生成新对象,再修改其中若干属性

@Builder 注解是生成构建类的,它自身就支持链式方法调用,但它默认时 toBuilder = false, 所以下面的代码
1@Builder
2public class User {
3    private String name;
4    private List<String> addresses;
5}

生成的字节码是
 1public class User {
 2    private String name;
 3    private List<String> addresses;
 4
 5    User(String name, List<String> addresses) {
 6        this.name = name;
 7        this.addresses = addresses;
 8    }
 9
10    public static UserBuilder builder() {
11        return new UserBuilder();
12    }
13
14    public static class UserBuilder {
15        private String name;
16        private List<String> addresses;
17
18        UserBuilder() {
19        }
20
21        public UserBuilder name(String name) {
22            this.name = name;
23            return this;
24        }
25
26        public UserBuilder addresses(List<String> addresses) {
27            this.addresses = addresses;
28            return this;
29        }
30
31        public User build() {
32            return new User(this.name, this.addresses);
33        }
34
35        public String toString() {
36            return "User.UserBuilder(name=" + this.name + ", addresses=" + this.addresses + ")";
37        }
38    }
39}

只能从零创建一个新对象
1User.builder()
2    .setName("Anna")
3    .setAddresses(Arrays.asList("baz", "qux"))
4    .build()

如果需基于现有对象创建新的,再修改个别属性的话就给 @Builder 加上 toBuilder = true 属性
1@Builder(toBuilder = true)
2public class User {
3    private String name;
4    private List<String> addresses;
5}

那么它会在没有 toBuilder = true 是生成的代码中加上一个额外的方法
1    public UserBuilder toBuilder() {
2        return (new UserBuilder()).name(this.name).addresses(this.addresses);
3    }

现在想基于已有对象创建新的对象并修改某些属性时
1User existingUser = ...
2User newUser = existingUser.toBuilder()
3                   .setName("newName")
4                   // .setOtherAttributes(...)
5                   .build();

它与 Apache AVRO 的 Builder 不同之处是现有对象不再作为方法参数来创建 Builder,而是作为创建 Builder 的调用者,更主要的区别是 Apache AVRO 是深拷贝,@Builder(toBuilder = true) 是浅拷贝。

小结

虽然 @With 是一个新晋注解,但本人建议把它放入到黑名单中,有相关需求时请使用 @Setter/@Data + @Accessors(chain = true) 和 @Builder(toBuilder = true)。

直接对现有对象链式修改其中多个属性时要为 @Setter 或 @Data 配置上 @Accessors(chain = true),这样产生的所有 setter 方法就会返回 this

如果要基于现有对象创建新对象,然后修改其中若干属性时就用 @Builder(toBuilder = true),记住创建新对象时采用的时浅拷贝,基本类型(int, double 等)及它们的包装类型(Integer, Double 等) 或其他 Immutable(String 等) 类型都无妨,无所谓深浅拷贝。但对像集合等 Mutable 类型就要多留点心。

对于生成式的代码想要清晰理解背后发生了什么最好的办法是看它生成的真正的源代码。使用开源框架(如 Spring 等), 有问题时阅读源代码才能得到彻底的解决方案。

但目前生成式 AI 给的答案,有时候只能令其胡说八道了,因为不可能总有条件去查看它的原始文档,也许原始文档本身就是被别有用心杜撰的。 永久链接 https://yanbin.blog/lombok-with-evil-and-how-avoid/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。