注册

如何优雅地创建对象?

1. 写在前头


大家好,我是方圆,最近读完了《Effective Java 第三版》,准备把其中可供大家一起学习的点来分享出来。


这篇博客儿主要是关于建造者模式在创建对象时的应用,这已经成了我现在写代码的习惯,它在灵活性和代码整洁程度上,都让我十分满意。以下的内容非常的长,也是我费尽心力去完成的一篇博客儿,从初次应用建造者模式,到发现Lombok方便的注解,最后深挖Lombok的源码,大家既可以简单的学会它的应用,也可以从源码的角度来弄清楚它为什么是这样儿,就看你有什么需求了!


那,我们开始吧!


2. Java Beans创建对象


先创建一个Student类做准备,包含如下五个字段,姓名,年龄,爱好,性别和介绍


public class Student {

private String name;

private Integer age;

private String hobby;

/**
* 性别 0-女 1-男
*/

private Integer sex;

/**
* 介绍
*/

private String describe;

}

2.1 最常见的创建对象方式



  • 直接new一个对象,之后逐个set它的值,比如我们现在需要一个芳龄23岁的男生叫小明

Student xm = new Student();
xm.setName("小明");
xm.setAge(23);
xm.setSex(1);


  • 四行代码看着好多,我现在想让代码好看一些,一行就把这个对象创建出来,那就,添加个构造函数呗

// Student中添加构造函数
public Student(String name, Integer age, Integer sex) {
this.name = name;
this.age = age;
this.sex = sex;
}

// 一行一个小明
Student xm2 = new Student("小明", 23, 1);

这下看着是舒心多了,一行代替了之前的四行代码



  • 又来新需求了,创建一个对象,只要年龄和姓名,不要性别了,如果还要使用一行代码的话,我们又需要维护一个构造方法

// Student中添加构造函数
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}

// 一行一个小明
Student xm3 = new Student("小明", 23);

两个构造方法,维护起来感觉还好...



  • 但是,需求接连不断,“再给我来一个只有名字的小明!”,“我还要一个有名字,有爱好的小明”,“我还要...”

有没有发现点儿什么,也就是说,只要创建包含不同字段的对象,都需要维护一个构造方法,五个字段最多维护“5 x 4 x 3 x 2 x 1...” 个构造方法,这才仅仅是五个字段,现在想想如果每打开一个实体类文件映入眼帘的是无数个构造方法,我就...


image.png


所以这个弊端很明显,Java Beans创建对象会让代码行数很多,一行set一个属性,不美观,而采用了构造方法创建对象之后,又要对构造方法进行维护,代码量大增,难道代码美观和少代码量不能兼得吗?


3. effective Java说:用建造者模式创建对象


我先直接把代码写好,再一点点给大家讲


public class Student {

private String name;

private Integer age;

private String hobby;

/**
* 性别 0-女 1-男
*/

private Integer sex;

/**
* 介绍
*/

private String describe;

// 注意这里添加了一个private的构造函数,建造者字段和实体字段一一对应赋值
private Student(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.hobby = builder.hobby;
this.sex = builder.sex;
this.describe = builder.describe;
}

// 静态方法创建建造者对象
public static Builder builder() {
return new Builder();
}

/**
* 采用建造者模式,每个字段都有一个设置字段的方法
* 且返回值为Builder,能进行链式编程
*/

public static class Builder {
private String name;
private Integer age;
private String hobby;
private Integer sex;
private String describe;

// 私有构造方法
private Builder() {
}

public Builder name(String val) {
this.name = val;
return this;
}

public Builder age(Integer val) {
this.age = val;
return this;
}

public Builder hobby(String val) {
this.hobby = val;
return this;
}

public Builder sex(Integer val) {
this.sex = val;
return this;
}

public Builder describe(String val) {
this.describe = val;
return this;
}

public Student build() {
return new Student(this);
}
}

}


  • 需要注意的点:



  1. 为Student添加了一个private的构造函数,参数值为Builder,建造者字段和实体字段在构造函数中一一对应赋值




  2. 建造者中对每个字段都添加一个方法,且返回值为建造者本身,这样才能进行链式编程




3.1 这下能自如应对对象创建


// 创建一个23岁的小明
Student xm4 = Student.builder().name("小明").age(23).build();
// 创建一个男23岁小明
Student xm5 = Student.builder().name("小明").age(23).sex(1).build();
// 创建一个喜欢写代码的小明
Student xm6 = Student.builder().name("小明").hobby("代码").build();
// ...

3.2 新添加字段怎么办?



  • 如果要新增一个国籍的字段,不光要在实体类中添加,还需要在建造者中添加对应的字段方法,而且还要更新实体类的构造方法

// 实体类和建造者中均新增字段
private String country;

// 建造者中添加对应方法
public Builder country(String val) {
this.country = val;
return this;
}

// 更新实体类的构造方法
private Student(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.hobby = builder.hobby;
this.sex = builder.sex;
this.describe = builder.describe;
// 新增赋值代码
this.country = builder.country;
}

完成如上工作就可以创建对象为country赋值了


Student xm7 = Student.builder().name("小明").country("中国").build();



  • 那,建造者模式的好处又有什么? 难道不是既有了JavaBeans创建对象的可读性避免了繁重的代码量吗?




  • 题外话: 在我刚使用如上建造者模式创建对象的时候,觉得分分钟能吊打Java Beans创建对象的代码,也乐此不疲的为我要使用的实体类进行维护,但是也正所谓“凡事都很难经得住时间的磨砺”,当发现了更好的方法后,我变懒了!




4. Lombok的@Builder注解


4.1 注解带来的代码整洁



  • 在类上注解标注@Builder注解,会自动生成建造者的代码,且和上述用法一致,而且不需要再为新增字段特意维护代码,也太香了吧...

@Data
@Builder
public class Student {
...
}


  • 所以可以直接标注@Builder注解使用建造者模式创建对象(使用方法和上文中3.1节一致)

4.2 你可能听说过@Accessors要比@Builder灵活



  • @Builder在创建对象时具有链式赋值的特点,但是在创建对象后,就不能链式赋值了,虽然toBuilder注解属性可以返回一个新的建造者,并复用对象的成员变量值,但是这并不是在原对象上进行修改,调用完build方法后,会返回一个新的对象

// 在@Builder注解中,指定属性toBuilder = true
@Builder(toBuilder = true)

// 在创建完成对象后使用toBuilder方法获取建造者,指定新的属性值创建对象
Student xm7 = Student.builder().name("小明").country("中国").build();

Student xm8 = xm7.toBuilder().age(23).build();


  • @Accessors注解可以在原对象上进行赋值,这里先解读一下@Accessors的源码,方便对下面的用法理解

/**
* @Accessors注解是不能单独使用的,单独标记不会产生任何作用
* 需要搭配@Data或者@Getter@Setter使用才能生效
*/

@Target({ElementType.TYPE, ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Accessors {
/**
* 这个属性默认是false,为false时,getter和setter方法会有get和set前缀
* 什么意思呢,比如字段name,在该属性为false生成的get和set方法为getName和setName
* 而当属性为true时,就没有没有get和set前缀,get方法和set方法都名为name,只不过set方法要有参数,是对name方法的重载
*/

boolean fluent() default false;

/**
* chain属性,显然从字面意思它能实现链式编程,默认属性false
* 为true时,setter方法的返回值是该对象,那么我们就能进行链式编程了
* 为false时,setter的返回值为void,就不能进行链式编程了
*
* 注意:特殊的一点是,当fluent属性为true时,该值在不指定的情况下也会为true
*/

boolean chain() default false;

/**
* 这个属性值当我们指定的时候,会将字段中已经匹配到的前缀进行'删除'后生成getter和setter方法
* 但是它也有生效条件:字段必须是驼峰式命名,且前几个小写字母与我们指定的前缀一致
*
* 举个例子:
* 我们有一个字段如下
* private String lastName
* 在我们不指定prefix时,生成的getter和setter方法为 getLastName 和 setLastName
* 当我们指定prefix为last时,那么生成的getter和setter方法 为 getName 和 setName
*/

String[] prefix() default {};
}


  • 下面我们来看看用法,它实在是很灵活

// 我们为Student类标记一个如下注解,方法不含get和set前缀,同时又支持链式编程
@Accessors(fluent = true, chain = true)

// 这里我们创建一个25岁的小明
Student xm9 = new Student().age(25).name("小明");
// do something

// 使用完之后,假设这里需要对25岁的小明的属性进行修改,可采用如下方法,之后重新复用这个对象即可
xm9.country("中国");


  • 这也实在太好用了吧!

4.3 既然把@Accessors的源码读了,@Builder的源码我也讲给你听吧


@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.SOURCE)
public @interface Builder {
// 指定创建建造者的方法名,默认为builder
String builderMethodName() default "builder";

// 指定创建对象的方法名,默认为build
String buildMethodName() default "build";

// 指定静态内部建造者类的名字,默认为 类名 + Builder,如StudentBuilder
String builderClassName() default "";

// 是否能重新从对象生成建造者,默认为false,上文中有使用样例
boolean toBuilder() default false;

// 建造者能够使用的范围,默认是PUBLIC
AccessLevel access() default AccessLevel.PUBLIC;

// 标注了该注解的字段必须指定默认初始化值
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Default {
}

// 这个注解的使用是要和 @Builder(toBuilder = true) 一同使用才可生效
// 在调用toBuilder方法时,会根据被标注该注解的字段或方法对字段进行赋值
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.SOURCE)
public @interface ObtainVia {
// 指定要获取值的字段
String field() default "";

// 指定要获取值的方法
String method() default "";

// 这个值在指定method才有效,为true时获取值的方法必须为静态的,且方法参数值为本类(参考下文代码)
boolean isStatic() default false;
}
}


  • 全网很少有人讲@ObtainVia注解,那我们就来说说,它到底有什么用,该怎么用


  1. 指定field赋值

// 在类中注解标记和新增字段如下
@Builder.ObtainVia(field = "hobbies")
private String hobby;

// 供hobby获取值使用
private String hobbies = "唱跳RAP";

// 测试调用toBuilder方法,检查hobby值,若为‘唱跳RAP’证明注解生效
System.out.println(new Student().toBuilder().build().getHobby());
结果:唱跳RAP

查看编译后的源码,可以发现赋值语句hobby(this.hobbies),原来它是如此生效的


public Student.StudentBuilder toBuilder() {
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age)
.hobby(this.hobbies).hobbies(this.hobbies)
.sex(this.sex).describe(this.describe).country(this.country);
}


  1. 指定非静态method赋值

// 在类中标注如下注解和创建如下方法
@Builder.ObtainVia(method = "describe")
private String describe;

// 非静态方法赋值
private String describe() {
return "小明的自我介绍";
}

// 测试调用toBuilder方法,检查describe值,若为‘小明的自我介绍’证明注解生效
System.out.println(new Student().toBuilder().build().getDescribe());
结果:小明的自我介绍

查看编译后的源码,发现会调用该方法


public Student.StudentBuilder toBuilder() {
// 这里会调用该方法进行赋值,在下面生成Builder时使用
String describe = this.describe();
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age).hobby(this.hobby).sex(this.sex)
.describe(describe)
.country(this.country);
}


  1. 指定静态method赋值

// 在类中标注如下注解和创建如下静态方法
@Builder.ObtainVia(method = "describe", isStatic = true)
private String describe;

// 静态方法赋值,需要指定本类类型参数
private static String describe(Student student) {
return "小明的自我介绍";
}

// 测试调用toBuilder方法,检查describe值,若为‘小明的自我介绍’证明注解生效
System.out.println(new Student().toBuilder().build().getDescribe());
结果:小明的自我介绍

查看编译后的源码


public Student.StudentBuilder toBuilder() {
// 这里调用静态方法赋值
String describe = describe(this);
return (new Student.StudentBuilder()).name(this.name).lastNames(this.lastNames).age(this.age).hobby(this.hobby).sex(this.sex).describe(describe).country(this.country);
}

5. 番外:@Builder,@Singular 夫妻双双把家还


5.1 @Singular简介


@Singular必须搭配@Builder使用,相辅相成,@Singular标记在集合容器字段上,在建造者中自动生成针对集合容器的添加单个值添加多个值清除其中值的方法,可进行标记的集合容器类型如下(参考官方文档) java.util.Iterable, Collection, List, Set, SortedSet, NavigableSet, Map, SortedMap, NavigableMap com.google.common.collect.ImmutableCollection, ImmutableList, ImmutableSet, ImmutableSortedSet, ImmutableMap, ImmutableBiMap, ImmutableSortedMap, ImmutableTable



  • 使用演示

// 在类中添加如下字段,并标注@Singular注解
@Singular
private List<String> subjects;

// 测试代码,调用单个添加和多个值添加的方法
Student xm11 = Student.builder()
.subject("Math").subject("Chinese")
.subjects(Arrays.asList("English", "History")).build();

// 查看添加结果
System.out.println(xm11.getSubjects().toString());
结果:[Math, Chinese, English, History]

// 调用clearSubjects清空方法,并查看结果
System.out.prinln(xm11.toBuilder().clearSubjects().build().getSubjects().toString());
结果:[]

5.2 @Singular源码解析


@Target({FIELD, PARAMETER})
@Retention(SOURCE)
public @interface Singular {
// 指定添加单个值的方法的方法名,不指定时会自动生成方法名,比例中为'subject'
String value() default "";

// 添加多个值是否忽略null,默认不忽略,添加null的列表时会抛出异常
// 为ture时,添加为null的列表不进行任何操作
boolean ignoreNullCollections() default false;
}


  • @Singular(ignoreNullCollections = false)编译后的代码

public Student.StudentBuilder subjects(Collection<? extends String> subjects) {
// 添加的列表为null,抛出异常
if (subjects == null) {
throw new NullPointerException("subjects cannot be null");
} else {
if (this.subjects == null) {
this.subjects = new ArrayList();
}

this.subjects.addAll(subjects);
return this;
}
}


  • @Singular(ignoreNullCollections = true)编译后的代码

public Student.StudentBuilder subjects(Collection<? extends String> subjects) {
// 为null时不进行任何操作
if (subjects != null) {
if (this.subjects == null) {
this.subjects = new ArrayList();
}

this.subjects.addAll(subjects);
}

return this;
}

5.3 @Singular在build方法中的细节



  • 创建完对象后,被标记为@Singular的列表能修改吗?我们试试

Student xm11 = Student.builder()
.subject("Math").subject("Chinese")
.subjects(Arrays.asList("English", "History")).build();

// 再添加一门Java课程
xm11.getSubjects().add("Java");

结果:抛出不支持操作的异常
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at builder.TestBuilder.main(Student.java:177)


  • 为什么这样?我们看看源码中的build方法就知道了,build方法根据不同的列表大小走不同的初始化列表方法,返回的列表都是不能进行修改的

public Student build() {
List subjects;
switch(this.subjects == null ? 0 : this.subjects.size()) {
case 0:
// 列表大小为0时,创建一个空列表
subjects = Collections.emptyList();
break;
case 1:
// 列表大小为1时,创建一个不可修改的单元素列表
subjects = Collections.singletonList(this.subjects.get(0));
break;
default:
// 其他情况,创建一个不可修改的列表
subjects = Collections.unmodifiableList(new ArrayList(this.subjects));
}

// 下面进行忽略只看上边就好
String name$value = this.name$value;
if (!this.name$set) {
name$value = Student.$default$name();
}

return new Student(name$value, this.lastNames, this.age, this.hobby, this.sex, this.describe, this.country, subjects);
}

6. 写在最后


呼!终于写完了,做个总结吧(文末有博客对应的代码仓库)




  • @Accessors注解非常的轻便,我觉得它现在已经能cover我在业务开发中创建对象的需求了,代码可读性高,代码量又很少




  • @Builder注解它的功能相对来说更多一些,通过方法和字段来初始化建造者的值,搭配@Singular操作列表等,但是这些功能真正的在业务开发中的应用效果,还有待考量




巨人的肩膀



作者:方圆想当图灵
来源:juejin.cn/post/7246025362969722936

0 个评论

要回复文章请先登录注册