news 2026/5/26 3:52:59

深入理解《Effective Java》 之条目2:当构造器参数较多时考虑使用生成器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解《Effective Java》 之条目2:当构造器参数较多时考虑使用生成器

当构造器参数较多时考虑使用生成器

    • 第一种:重叠构造器模式
    • 第二种: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,编译器会报错。只有继承关系的子类型才被允许。

(三)生成器模式的总结

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

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/26 3:52:04

C++11——并发库介绍

说明&#xff1a;本篇旨在介绍并发库怎么使用&#xff0c;介绍的不会太全面。前置条件是了解 Linux/window 的相应的系统调用再看。这是文档&#xff1a;cplusplus。 1、<thread>库 1.1 说明 其实 C 的 <thread> 库就是对系统调用封装了一层&#xff08;其实不仅是…

作者头像 李华
网站建设 2026/5/26 3:50:10

STM32CubeMX保姆级教程:从零点亮STM32F103C8T6最小系统板的LED

STM32CubeMX零基础实战&#xff1a;点亮你的第一颗LED 拿到STM32开发板的第一天&#xff0c;就像站在乐高积木城堡前的孩子——那些密密麻麻的引脚和陌生的专业术语让人既兴奋又忐忑。本文将带你用最直观的方式跨出第一步&#xff1a;让板载的LED灯亮起来。这不是简单的步骤罗列…

作者头像 李华
网站建设 2026/5/26 3:48:47

没有银弹,从来就没有

我反复回想起Fred Brooks在1986年写的一段话。 他的论点很简单。没有任何单一发明、工具或技术能让构建软件变得 dramatically 更容易。进步确实在发生&#xff0c;但它永远是局部的&#xff0c;永远留下某些未解决的问题。 四十年后&#xff0c;我认为他是对的。而我认为我们…

作者头像 李华
网站建设 2026/5/26 3:45:59

用Python+skimage搞定图像纹理分析:从GLCM六种特征到实战代码避坑

Pythonskimage图像纹理分析实战&#xff1a;GLCM参数优化与特征工程避坑指南 当你第一次尝试用灰度共生矩阵&#xff08;GLCM&#xff09;分析医学影像中的肿瘤区域&#xff0c;或是检测工业零件表面的细微裂纹时&#xff0c;很可能会遇到这样的困惑&#xff1a;为什么相同的代…

作者头像 李华