当构造器参数较多时考虑使用生成器
- 第一种:重叠构造器模式
- 第二种:JavaBeans模式
- (一)什么是JavaBean?
- (二)什么是JavaBeans模式
- (三)JavaBeans缺点
- 第三种:生成器(Builder)模式【推荐】
- (一)简单的生成器(Simple Builder)模式
- (二)平行层次生成器
- (三)生成器模式的总结
当可选参数非常多时,静态工厂和构造器,均不能很好满足扩展要求。以贴在食品包装上的营养成分标签(
NutritionFacts)为例,标签有几个必须的字段,如每份的分量、每包装所含份数,还有较多可选标签,如卡路里、总脂肪等等。对于多数产品,这些可选字段大多为零。设计这样类,通常有三种模式:第一种:重叠构造器模式
// 重叠构造器模式——不是很好地扩展publicclassNutritionFacts{privatefinalintservingSize;// (每份的分量,单位为毫升) 必需的privatefinalintservings;// (每包装所含份数) 必需的privatefinalintcaloriers;// (每份的卡路里) 可选的privatefinalintfat;// (每份所含脂肪,单位为克) 可选的privatefinalintsodium;// (每份所含钠,单位为毫克) 可选的privatefinalintcarbohydrate;// (每份所含碳水化合物,单位为克) 可选的publicNutritionFacts(intservingSize,intservings){this(servingSize,servings,0);// 调用下一个构造器}publicNutritionFacts(intservingSize,intservings,intcaloriers){this(servingSize,servings,caloriers,0);// 调用下一个构造器}publicNutritionFacts(intservingSize,intservings,intcaloriers,intfat){this(servingSize,servings,caloriers,fat,0);// 调用下一个构造器}publicNutritionFacts(intservingSize,intservings,intcaloriers,intfat,intsodium){this(servingSize,servings,caloriers,fat,sodium,0);// 调用最终构造器}publicNutritionFacts(intservingSize,intservings,intcaloriers,intfat,intsodium,intcarbohydrate){this.servingSize=servingSize;this.servings=servings;this.caloriers=caloriers;this.fat=fat;this.sodium=sodium;this.carbohydrate=carbohydrate;}}在创建实例时,你可以选择最短参数列表的构造器。
NutritionFactscocaCola=newNutritionFacts(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模式————允许不一致性,要求可变性publicclassNutritionFacts{privateintservingSize;// (每份的分量,单位为毫升) 必需的privateintservings;// (每包装所含份数) 必需的privateintcaloriers;// (每份的卡路里) 可选的privateintfat;// (每份所含脂肪,单位为克) 可选的privateintsodium;// (每份所含钠,单位为毫克) 可选的privateintcarbohydrate;// (每份所含碳水化合物,单位为克) 可选的publicNutritionFacts(){}publicvoidsetServingSize(intval){servingSize=val;}publicvoidsetServings(intval){servings=val;}publicvoidsetCaloriers(intval){caloriers=val;}publicvoidsetFat(intval){fat=val;}publicvoidsetSodium(intval){sodium=val;}publicvoidsetCarbohydrate(intval){carbohydrate=val;}}创建实例容易,代码虽冗长但不难阅读:
NutritionFactscocaCola=newNutritionFacts();cocaCola.setServingSize(240);cocaCola.setServings(8);cocaCola.setCalories(100);cocaCola.setSodium(35);cocaCola.setCarbohydrate(27);(三)JavaBeans缺点
一是,JavaBean对象在构建过程中可能会处于不一致的状态。
由于对象的构造被分割成了多个set调用,在最后一个属性被设置之前,对象处于“部分构建”状态。如果在这个过程中发生异常或使用该对象,极易引发故障且难以调试。
为了更好理解这个弊端,我们可以模拟一个常见的“数据库配置类”场景:
publicclassDatabaseConfig{privateStringhost;// 数据库地址privateintport;// 端口号privateStringusername;// 用户名privateStringpassword;// 密码// 无参构造器publicDatabaseConfig(){}// Setter 方法publicvoidsetHost(Stringhost){this.host=host;}publicvoidsetPort(intport){this.port=port;}publicvoidsetUsername(Stringusername){this.username=username;}publicvoidsetPassword(Stringpassword){this.password=password;}// 业务方法:尝试建立连接publicvoidconnect(){// 模拟检查:如果没有配置host,程序无法运行if(this.host==null){thrownewIllegalStateException("致命错误:数据库地址(host)未配置!");}// 模拟连接逻辑System.out.println("正在连接数据库:"+this.host+":"+this.port);}}现在我们来看看在使用这个类的过程中会发生什么:
publicstaticvoidmain(String[]args){// 1. 实例化对象(此时对象已存在,但所有属性都是默认值 null 或 0)DatabaseConfigconfig=newDatabaseConfig();// 2. 【半成品状态】只设置了port、username 和 password,忘记设置 hostconfig.setPort(3306);config.setUsername("墨问");config.setPassword("123");// 3. 紧接着调用业务方法// 假设这里有复杂的业务逻辑,或者在多线程环境下,另一个线程抢占了 CPUconfig.connect();}在上述代码中,config.connect()的执行会引发IllegalStateException,提示“数据库地址未配置”。
为什么这会导致“难以调试的故障”?
(1)故障发生的位置与原因“相去甚远”
- 原因:真正的 Bug 其实出在第 2 步————“忘记调用
setHost”。 - 表现:但程序崩溃(抛出异常)的位置却在第 3 步的
connect()方法里。 - 调试难点:如果在真实的复杂项目中,
connect()方法可能位于很深层的调用栈中,或者跨越了多个类。开发人员查看到底哪里出错时,很容易顺着异常堆栈一路找下去,却始终找不到“为什么host会是null”的真正根源。他可能会误以为是网络配置问题,或者是配置文件解析出了问题,从而在错误的方向上浪费大量时间。
(2)多线程环境下的“竞态条件”
在单线程中,问题主要是“漏写代码”;而在多线程并发环境中,问题会变得极其不可预测:
// 线程 A:负责创建并配置对象DatabaseConfigcfg=newDatabaseConfig();newThread(()->{cfg.setUsername("墨问");cfg.setPassword("123");// 假设这里有一个耗时操作,导致线程 A 暂停了几毫秒try{Thread.sleep(100);}catch(InterruptedExceptione){}cfg.setHost("localhost");// 还没来得及设置主机}).start();// 线程 B:负责使用对象newThread(()->{// 如果线程 B 在线程 A 设置 Host 之前就拿到了 cfg 对象并调用 connect()// 就会立即崩溃!cfg.connect();}).start();由于 Java 的内存模型和指令重排序,线程 B 可能会在线程 A 完成所有setter调用之前,就看到一个“半吊子”状态的DatabaseConfig对象。这种由于对象在构造中途被其他线程“窥视”而导致的并发 Bug,往往是偶发性的(时好时坏),极难复现和排查。
二是,如果选择了JavaBeans模式,这个类就不可能再成为不可变类,要确保线程安全,程序员就要付出额外努力。
显然,JavaBeans模式设计NutritionFacts类及属性没有final修饰,说明这不是一个不可变类。
不可变类是不能修改数据状态的,而JavaBeans模式是要求有setter方法的,所以,只能是可变类。
一旦使用setter方法修改了对象内部状态,该对象就不再是不可变类了,也就不再是线程安全的。
那么,程序员要付出的额外努力是什么呢?
三是,当然可以通过手动“冻结”来减少这些缺点,在对象构造完毕之前不允许使用,但这种做法比较笨拙。
这里的手动“冻结”就是程序员要付出的额外努力。
我们尝试冻结下DatabaseConfig:
publicclassDatabaseConfig{privateStringhost;// 数据库地址privateintport;// 端口号privateStringusername;// 用户名privateStringpassword;// 密码privatebooleanfrozen=false;//冻结标志// 无参构造器publicDatabaseConfig(){}// Setter 方法,要在冻结前才能调用publicvoidsetHost(Stringhost){checkIfFrozen();this.host=host;}publicvoidsetPort(intport){checkIfFrozen();this.port=port;}publicvoidsetUsername(Stringusername){checkIfFrozen();this.username=username;}publicvoidsetPassword(Stringpassword){checkIfFrozen();this.password=password;}// 检查是否已冻结privatevoidcheckIfFrozen(){if(frozen)thrownewIllegalStateException("对象已冻结,不可修改");}// 冻结方法publicvoidfreeze(){validate();// 冻结前验证所有必要属性this.frozen=true;}// 验证方法:确保所有必要属性都已设置privatevoidvalidate(){if(host==null||username==null||password==null){thrownewIllegalStateException("配置不完整,无法冻结");}}// 业务方法:尝试建立连接publicvoidconnect(){// 不再需要检查null,因为冻结前已验证System.out.println("正在连接数据库:"+this.host+":"+this.port);}}这里调用冻结方法freeze(),保证了所有必需参数的设置,实现类的完整构造,并通过改变冻结标志frozen为true,完成对属性的锁定。但是,这个冻结方法必须在对象调用setter方法后,马上调用,如果忘记,就达不到冻结效果,所以才叫“手动冻结”。
第三种:生成器(Builder)模式【推荐】
(一)简单的生成器(Simple Builder)模式
我们使用生成器模式优化NutritionFacts类:
publicclassNutritionFacts{privatefinalintservingSize;// (每份的分量,单位为毫升)privatefinalintservings;// (每包装所含份数)privatefinalintcaloriers;// (每份的卡路里)privatefinalintfat;// (每份所含脂肪,单位为克)privatefinalintsodium;// (每份所含钠,单位为毫克)privatefinalintcarbohydrate;// (每份所含碳水化合物,单位为克)// 私有构造器,参数是生成器privateNutritionFacts(Builderbuilder){this.servingSize=builder.servingSize;this.servings=builder.servings;this.caloriers=builder.caloriers;this.fat=builder.fat;this.sodium=builder.sodium;this.carbohydrate=builder.carbohydrate;}// 生成器,静态内部类publicstaticclassBuilder{// 必需的属性privatefinalintservingSize;privatefinalintservings;// 可选的属性privateintcaloriers;privateintfat;privateintsodium;privateintcarbohydrate;// 必需属性通过构造器设置publicBuilder(intservingSize,intservings){this.servingSize=servingSize;this.servings=servings;}// 可选属性通过方法设置publicBuildercaloriers(intval){this.caloriers=val;returnthis;}publicBuilderfat(intval){this.fat=val;returnthis;}publicBuildersodium(intval){this.sodium=val;returnthis;}publicBuildercarbohydrate(intval){this.carbohydrate=val;returnthis;}// 构建方法publicNutritionFactsbuild(){returnnewNutritionFacts(this);}}}生成器模式特点:
- 程序不直接生成想要的对象,而是由生成器(
Builder)提供的的build()方法来构建 - 创建
Builder对象,Builder的构造器(或静态工厂)应带有所有必需的参数 - 由使用生成器的类似
setter方法设置可选的参数,并返回Builder对象 - 最后,调用
build()方法返回想要的对象,通常返回的对象是不可变的
正因为生成器的setter方法会返回生成器对象本身,就可以将一系列的调用链接起来,形成一个流式的API。在营养成分标签这个例子中,我们可以这样调用:
NutritionFactscocaCola=newNutritionFacts.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抽象类:
publicabstractclassCar{privatefinalStringbrand;publicstaticabstractclassBuilder<TextendsBuilder<T>>{privateStringbrand;publicTbrand(Stringval){brand=Objects.requireNonNull(val);returnself();}// 子类必需重写该方法返回"this"protectedabstractTself();publicabstractCarbuild();}Car(Builder<?>builder){this.brand=builder.brand;}}Sedan子类:
publicclassSedanextendsCar{privatefinalintdoors;publicstaticclassBuilderextendsCar.Builder<Builder>{privateintdoors;publicBuilder(intdoors){this.doors=doors;}@OverrideprotectedBuilderself(){returnthis;}@OverridepublicSedanbuild(){returnnewSedan(this);}}Sedan(Builderbuilder){super(builder);this.doors=builder.doors;}}代码详解:
(1)在这个父类Car里,生成器Builder定义使用了泛型的一种特殊使用方式,叫递归类型参数(这个将在条目30会详细解析)。可以将其理解为子类的Builder,这个Builder需要通过子类重写self方法给出,这样才能保障链式调用的连续性。
递归类型和抽象的self方法一起,保证链式调用在子类中也可以不中断工作,这就是Java中所谓的模拟自身类型习惯用法。
// 本例可以这样调用Carsedan=newSedan.Builder(2).brand("比亚迪").build();// ✅// 如果Car类不适用递归类型参数,那返回的只能是Car类的Builder对象// 那么在.brand("比亚迪")就返回Car.Builder实例// 如果再.build()就要做个强制转换,链式调用就会被迫中断Carsedan=(Sedan.Builder)(newSedan.Builder(2).brand("比亚迪")).build();// ❌(2)本例中,子类Sedan重写父类时候有个细节,大家要注意:
// Car的生成器定义build方法是这样的publicabstractCarbuild();// 而子类Sedan生成器是这样重写的publicSedanbuild(){returnnewSedan(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<?extendsNumber>integerList=newArrayList<Integer>();List<?extendsNumber>doubleList=newArrayList<Double>();上面的代码能编译通过,正是因为? extends Number引入了协变性。
协变遵守PECS 原则:Producer-Extends, Consumer-Super。如果你是从集合中“取”数据(生产者角色),用extends;如果是“放”数据(消费者角色),用super。
其次,协变返回类型指的是:在重写父类方法时,允许子类方法的返回类型是父类方法返回类型的子类型。
在本例中,Sedan类的build方法如果返回类型不是父子关系,比如你试图返回String,编译器会报错。只有继承关系的子类型才被允许。
(三)生成器模式的总结
生成器模式可以为多个可变参数指定对应方法,也可以将多次调用某个方法时分别传入的参数聚合到一个字段中,如使用集合存储某种枚举值。同时,可以重复使用一个生成器来构建多个对象,也可以在对象创建时自动填充一些字段或进行规则校验,所以,生成器模式非常灵活。
缺点就是系统开销稍大且构建繁琐一些。
总而言之,当我们要设计的类的构造器或静态工厂具有多个参数,特别是其中的血多参数是可选的或具有相同的类型时,生成器模式是个不错的选择。