当可选参数非常多时,静态工厂和构造器,均不能很好满足扩展要求。以贴在食品包装上的营养成分标签( 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,编译器会报错。只有继承关系的子类型才被允许。

(三)生成器模式的总结

生成器模式可以为多个可变参数指定对应方法,也可以将多次调用某个方法时分别传入的参数聚合到一个字段中,如使用集合存储某种枚举值。同时,可以重复使用一个生成器来构建多个对象,也可以在对象创建时自动填充一些字段或进行规则校验,所以,生成器模式非常灵活。

缺点就是系统开销稍大且构建繁琐一些。

总而言之,当我们要设计的类的构造器或静态工厂具有多个参数,特别是其中的血多参数是可选的或具有相同的类型时,生成器模式是个不错的选择。

更多推荐