深入理解《Effective Java》 之条目2:当构造器参数较多时考虑使用生成器
当构造器参数较多时考虑使用生成器
当可选参数非常多时,静态工厂和构造器,均不能很好满足扩展要求。以贴在食品包装上的营养成分标签(
NutritionFacts)为例,标签有几个必须的字段,如每份的分量、每包装所含份数,还有较多可选标签,如卡路里、总脂肪等等。对于多数产品,这些可选字段大多为零。设计这样类,通常有三种模式:
第一种:重叠构造器模式
// 重叠构造器模式——不是很好地扩展
public class NutritionFacts {
private final int servingSize; // (每份的分量,单位为毫升) 必需的
private final int servings; // (每包装所含份数) 必需的
private final int caloriers; // (每份的卡路里) 可选的
private final int fat; // (每份所含脂肪,单位为克) 可选的
private final int sodium; // (每份所含钠,单位为毫克) 可选的
private final int carbohydrate; // (每份所含碳水化合物,单位为克) 可选的
public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0); // 调用下一个构造器
}
public NutritionFacts(int servingSize, int servings, int caloriers) {
this(servingSize, servings, caloriers, 0); // 调用下一个构造器
}
public NutritionFacts(int servingSize, int servings, int caloriers, int fat) {
this(servingSize, servings, caloriers, fat, 0); // 调用下一个构造器
}
public NutritionFacts(int servingSize, int servings, int caloriers, int fat, int sodium) {
this(servingSize, servings, caloriers, fat, sodium, 0); // 调用最终构造器
}
public NutritionFacts(int servingSize, int servings, int caloriers, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.caloriers = caloriers;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
在创建实例时,你可以选择最短参数列表的构造器。
NutritionFacts cocaCola=new NutritionFacts(240,8,100,0,35,27);
第一个弊端:有个很不好的现象,即使你不想设置的参数,也不得不传递值。比如这个例子中的,我们为fat传递了一个0。
当然,将可选参数实例化时需要设置的频率高的可选参数,在参数列表中靠前排列,是一个好的习惯,但也无法避免这种不得已。
第二个弊端:按照重叠构造器模式规则,可选参数越多,重叠构造器越多,类也就越发臃肿。随着参数数量不断增加,情况很快就会失控。
简而言之,重叠构造器模式可以工作,但是当参数的数量非常多时,客户端代码写起来很困难,读起来就更难了。
第二种:JavaBeans模式
(一)什么是JavaBean?
JavaBean 是一种符合特定约定的Java类,主要用于封装数据,以实现可重用、易于维护的组件化开发。它的核心设计目的是使Java对象能够在可视化构建工具(如早期的 IDE 图形设计器)和框架(如 Spring、Hibernate)中被方便地识别和操作。
JavaBean 的核心特征:
-
公共的无参构造函数
-
属性私有化
-
通过公共的 getter 和 setter 访问属性
-
实现
Serializable接口(可选但常见)
(二)什么是JavaBeans模式
JavaBeans 模式(又称可伸缩构造模式)是 Java 中一种通过无参构造 + setter 方法逐步构建对象的设计模式。它得名于遵循 JavaBean 规范的类(即具有 getter/setter 方法的类)。其核心思想是:将对象的构造与属性初始化分离,通过多次调用 setter 方法灵活配置对象。
工作原理:
- 先调用无参构造函数创建对象
- 通过链式或分步的 setter 方法设置属性
- 最终得到一个完整初始化的对象。
我们使用JavaBeans模式设计NutritionFacts类:
// JavaBeans模式————允许不一致性,要求可变性
public class NutritionFacts {
private int servingSize; // (每份的分量,单位为毫升) 必需的
private int servings; // (每包装所含份数) 必需的
private int caloriers; // (每份的卡路里) 可选的
private int fat; // (每份所含脂肪,单位为克) 可选的
private int sodium; // (每份所含钠,单位为毫克) 可选的
private int carbohydrate; // (每份所含碳水化合物,单位为克) 可选的
public NutritionFacts() {}
public void setServingSize(int val) {servingSize = val;}
public void setServings(int val) {servings = val;}
public void setCaloriers(int val) {caloriers = val;}
public void setFat(int val) {fat = val;}
public void setSodium(int val) {sodium = val;}
public void setCarbohydrate(int val) {carbohydrate = val;}
}
创建实例容易,代码虽冗长但不难阅读:
NutritionFacts cocaCola=new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
(三)JavaBeans缺点
一是,JavaBean对象在构建过程中可能会处于不一致的状态。
由于对象的构造被分割成了多个set调用,在最后一个属性被设置之前,对象处于“部分构建”状态。如果在这个过程中发生异常或使用该对象,极易引发故障且难以调试。
为了更好理解这个弊端,我们可以模拟一个常见的 “数据库配置类” 场景:
public class DatabaseConfig {
private String host; // 数据库地址
private int port; // 端口号
private String username; // 用户名
private String password; // 密码
// 无参构造器
public DatabaseConfig() {}
// Setter 方法
public void setHost(String host) { this.host = host; }
public void setPort(int port) { this.port = port; }
public void setUsername(String username) { this.username = username; }
public void setPassword(String password) { this.password = password; }
// 业务方法:尝试建立连接
public void connect() {
// 模拟检查:如果没有配置host,程序无法运行
if (this.host == null) {
throw new IllegalStateException("致命错误:数据库地址(host)未配置!");
}
// 模拟连接逻辑
System.out.println("正在连接数据库:" + this.host + ":" + this.port);
}
}
现在我们来看看在使用这个类的过程中会发生什么:
public static void main(String[] args) {
// 1. 实例化对象(此时对象已存在,但所有属性都是默认值 null 或 0)
DatabaseConfig config = new DatabaseConfig();
// 2. 【半成品状态】只设置了port、username 和 password,忘记设置 host
config.setPort(3306);
config.setUsername("墨问");
config.setPassword("123");
// 3. 紧接着调用业务方法
// 假设这里有复杂的业务逻辑,或者在多线程环境下,另一个线程抢占了 CPU
config.connect();
}
在上述代码中,config.connect()的执行会引发 IllegalStateException,提示“数据库地址未配置”。
为什么这会导致“难以调试的故障”?
(1)故障发生的位置与原因“相去甚远”
- 原因:真正的 Bug 其实出在第 2 步————“忘记调用
setHost”。 - 表现:但程序崩溃(抛出异常)的位置却在第 3 步的
connect()方法里。 - 调试难点:如果在真实的复杂项目中,
connect()方法可能位于很深层的调用栈中,或者跨越了多个类。开发人员查看到底哪里出错时,很容易顺着异常堆栈一路找下去,却始终找不到“为什么host会是null”的真正根源。他可能会误以为是网络配置问题,或者是配置文件解析出了问题,从而在错误的方向上浪费大量时间。
(2)多线程环境下的“竞态条件”
在单线程中,问题主要是“漏写代码”;而在多线程并发环境中,问题会变得极其不可预测:
// 线程 A:负责创建并配置对象
DatabaseConfig cfg = new DatabaseConfig();
new Thread(() -> {
cfg.setUsername("墨问");
cfg.setPassword("123");
// 假设这里有一个耗时操作,导致线程 A 暂停了几毫秒
try { Thread.sleep(100); } catch (InterruptedException e) {}
cfg.setHost("localhost"); // 还没来得及设置主机
}).start();
// 线程 B:负责使用对象
new Thread(() -> {
// 如果线程 B 在线程 A 设置 Host 之前就拿到了 cfg 对象并调用 connect()
// 就会立即崩溃!
cfg.connect();
}).start();
由于 Java 的内存模型和指令重排序,线程 B 可能会在线程 A 完成所有 setter调用之前,就看到一个“半吊子”状态的 DatabaseConfig对象。这种由于对象在构造中途被其他线程“窥视”而导致的并发 Bug,往往是偶发性的(时好时坏),极难复现和排查。
二是,如果选择了JavaBeans模式,这个类就不可能再成为不可变类,要确保线程安全,程序员就要付出额外努力。
显然,JavaBeans模式设计NutritionFacts类及属性没有final修饰,说明这不是一个不可变类。
不可变类是不能修改数据状态的,而JavaBeans模式是要求有setter方法的,所以,只能是可变类。
一旦使用 setter方法修改了对象内部状态,该对象就不再是不可变类了,也就不再是线程安全的。
那么,程序员要付出的额外努力是什么呢?
三是,当然可以通过手动“冻结”来减少这些缺点,在对象构造完毕之前不允许使用,但这种做法比较笨拙。
这里的手动“冻结”就是程序员要付出的额外努力。
我们尝试冻结下DatabaseConfig:
public class DatabaseConfig {
private String host; // 数据库地址
private int port; // 端口号
private String username; // 用户名
private String password; // 密码
private boolean frozen=false;//冻结标志
// 无参构造器
public DatabaseConfig() {}
// Setter 方法,要在冻结前才能调用
public void setHost(String host) {checkIfFrozen(); this.host = host; }
public void setPort(int port) {checkIfFrozen(); this.port = port; }
public void setUsername(String username) {checkIfFrozen(); this.username = username; }
public void setPassword(String password) {checkIfFrozen(); this.password = password; }
// 检查是否已冻结
private void checkIfFrozen() {
if(frozen) throw new IllegalStateException("对象已冻结,不可修改");
}
// 冻结方法
public void freeze() {
validate(); // 冻结前验证所有必要属性
this.frozen=true;
}
// 验证方法:确保所有必要属性都已设置
private void validate() {
if (host == null || username == null || password == null) {
throw new IllegalStateException("配置不完整,无法冻结");
}
}
// 业务方法:尝试建立连接
public void connect() {
// 不再需要检查null,因为冻结前已验证
System.out.println("正在连接数据库:" + this.host + ":" + this.port);
}
}
这里调用冻结方法freeze(),保证了所有必需参数的设置,实现类的完整构造,并通过改变冻结标志frozen为true,完成对属性的锁定。但是,这个冻结方法必须在对象调用setter方法后,马上调用,如果忘记,就达不到冻结效果,所以才叫“手动冻结”。
第三种:生成器(Builder)模式【推荐】
(一)简单的生成器(Simple Builder)模式
我们使用生成器模式优化NutritionFacts类:
public class NutritionFacts {
private final int servingSize; // (每份的分量,单位为毫升)
private final int servings; // (每包装所含份数)
private final int caloriers; // (每份的卡路里)
private final int fat; // (每份所含脂肪,单位为克)
private final int sodium; // (每份所含钠,单位为毫克)
private final int carbohydrate; // (每份所含碳水化合物,单位为克)
// 私有构造器,参数是生成器
private NutritionFacts(Builder builder) {
this.servingSize = builder.servingSize;
this.servings = builder.servings;
this.caloriers = builder.caloriers;
this.fat = builder.fat;
this.sodium = builder.sodium;
this.carbohydrate = builder.carbohydrate;
}
// 生成器,静态内部类
public static class Builder{
// 必需的属性
private final int servingSize;
private final int servings;
// 可选的属性
private int caloriers;
private int fat;
private int sodium;
private int carbohydrate;
// 必需属性通过构造器设置
public Builder(int servingSize,int servings){
this.servingSize=servingSize;
this.servings=servings;
}
// 可选属性通过方法设置
public Builder caloriers(int val) {this.caloriers=val;return this;}
public Builder fat(int val) {this.fat=val;return this;}
public Builder sodium(int val) {this.sodium=val;return this;}
public Builder carbohydrate(int val) {this.carbohydrate=val;return this;}
// 构建方法
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
}
生成器模式特点:
- 程序不直接生成想要的对象,而是由生成器(
Builder)提供的的build()方法来构建 - 创建
Builder对象,Builder的构造器(或静态工厂)应带有所有必需的参数 - 由使用生成器的类似
setter方法设置可选的参数,并返回Builder对象 - 最后,调用
build()方法返回想要的对象,通常返回的对象是不可变的
正因为生成器的setter方法会返回生成器对象本身,就可以将一系列的调用链接起来,形成一个流式的API。在营养成分标签这个例子中,我们可以这样调用:
NutritionFacts cocaCola=new NutritionFacts.Builder(240,8)
.caloriers(100)
.sodium(35)
.carbohydrate(27)
.build();
(二)平行层次生成器
可以使用一组平行层次结构的生成器,将每个生成器都嵌套在相应的类中。
1.什么是“平行层次结构”?
它是指存在两个或多个继承体系,它们之间是一 一对应的关系。
假设我们有一个产品族:
Product (抽象)
├── Car
│ ├── Sedan
│ └── SUV
└── Bike
├── RoadBike
└── MountainBike
如果为它们分别配 Builder,就会形成另一个平行的继承体系:
Builder (抽象)
├── CarBuilder
│ ├── SedanBuilder
│ └── SUVBuilder
└── BikeBuilder
├── RoadBikeBuilder
└── MountainBikeBuilder
👉这就是“平行层次结构”:Builder的层级和产品类的层级一 一对应。
2.“将每个生成器嵌套在相应的类中”是什么意思?
意思就是,谁负责创建某个类,Builder 就写在那个类里面。
我们就以汽车为例(为了举例方便,将Car视作顶层抽象类)。
Car抽象类:
public abstract class Car {
private final String brand;
public static abstract class Builder<T extends Builder<T>>{
private String brand;
public T brand(String val) {
brand=Objects.requireNonNull(val);
return self();
}
// 子类必需重写该方法返回"this"
protected abstract T self();
public abstract Car build();
}
Car(Builder<?> builder){
this.brand=builder.brand;
}
}
Sedan子类:
public class Sedan extends Car{
private final int doors;
public static class Builder extends Car.Builder<Builder>{
private int doors;
public Builder(int doors) {
this.doors=doors;
}
@Override
protected Builder self() {return this;}
@Override
public Sedan build() {return new Sedan(this);}
}
Sedan(Builder builder) {
super(builder);
this.doors=builder.doors;
}
}
代码详解:
(1) 在这个父类Car里,生成器Builder定义使用了泛型的一种特殊使用方式,叫递归类型参数(这个将在条目30会详细解析)。可以将其理解为子类的Builder,这个Builder需要通过子类重写self方法给出,这样才能保障链式调用的连续性。
递归类型和抽象的self方法一起,保证链式调用在子类中也可以不中断工作,这就是Java中所谓的模拟自身类型习惯用法。
// 本例可以这样调用
Car sedan=new Sedan.Builder(2).brand("比亚迪").build(); // ✅
// 如果Car类不适用递归类型参数,那返回的只能是Car类的Builder对象
// 那么在.brand("比亚迪")就返回Car.Builder实例
// 如果再.build()就要做个强制转换,链式调用就会被迫中断
Car sedan=(Sedan.Builder)(new Sedan.Builder(2).brand("比亚迪")).build();// ❌
(2) 本例中,子类Sedan重写父类时候有个细节,大家要注意:
// Car的生成器定义build方法是这样的
public abstract Car build();
// 而子类Sedan生成器是这样重写的
public Sedan build() {return new Sedan(this);}
考虑两个问题:
一是子类生成器build方法返回的是Sedan实例,而不是Car,为什么呢?
原因就是我们在声明子类类型变量引用Sedan对象时,调用build方法就可以不用强制转化了,像这样:Sendan sedan=new Sedan.Builder(2).brand("比亚迪").build();
如果返回Car,很显然要这样写:Sendan sedan=(Sedan)(new Sedan.Builder(2).brand("比亚迪").build());
麻烦吧V!
二是为什么能这样重写?
实际上,这是协变返回类型的运行机制在起作用。
Java虚拟机(JVM)通过字节码指令的特殊处理来支持协变返回类型。当编译器遇到协变返回类型的方法重写时,会生成桥接方法(bridge method)来确保运行时的多态行为正确。桥接方法是编译器自动生成的方法。
本例中,Sedan类桥接方法的实现:public Car build() {return this.build();}
(3)我们来深入理解下协变返回类型
首先,协变是一种类型系统规则:当某个位置期望使用父类型时,允许传入其子类型。
List<? extends Number> integerList = new ArrayList<Integer>();
List<? extends Number> doubleList = new ArrayList<Double>();
上面的代码能编译通过,正是因为 ? extends Number 引入了协变性。
协变遵守PECS 原则:Producer-Extends, Consumer-Super。如果你是从集合中“取”数据(生产者角色),用 extends;如果是“放”数据(消费者角色),用 super。
其次,协变返回类型指的是:在重写父类方法时,允许子类方法的返回类型是父类方法返回类型的子类型。
在本例中,Sedan类的build方法如果返回类型不是父子关系,比如你试图返回 String,编译器会报错。只有继承关系的子类型才被允许。
(三)生成器模式的总结
生成器模式可以为多个可变参数指定对应方法,也可以将多次调用某个方法时分别传入的参数聚合到一个字段中,如使用集合存储某种枚举值。同时,可以重复使用一个生成器来构建多个对象,也可以在对象创建时自动填充一些字段或进行规则校验,所以,生成器模式非常灵活。
缺点就是系统开销稍大且构建繁琐一些。
总而言之,当我们要设计的类的构造器或静态工厂具有多个参数,特别是其中的血多参数是可选的或具有相同的类型时,生成器模式是个不错的选择。
更多推荐


所有评论(0)