当前位置: 首页 > news >正文

深入理解《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.servingSizeservingSize;this.servingsservings;this.calorierscaloriers;this.fatfat;this.sodiumsodium;this.carbohydratecarbohydrate;}}在创建实例时你可以选择最短参数列表的构造器。NutritionFactscocaColanewNutritionFacts(240,8,100,0,35,27);第一个弊端有个很不好的现象即使你不想设置的参数也不得不传递值。比如这个例子中的我们为fat传递了一个0。当然将可选参数实例化时需要设置的频率高的可选参数在参数列表中靠前排列是一个好的习惯但也无法避免这种不得已。第二个弊端按照重叠构造器模式规则可选参数越多重叠构造器越多类也就越发臃肿。随着参数数量不断增加情况很快就会失控。简而言之重叠构造器模式可以工作但是当参数的数量非常多时客户端代码写起来很困难读起来就更难了。第二种JavaBeans模式一什么是JavaBeanJavaBean是一种符合特定约定的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){servingSizeval;}publicvoidsetServings(intval){servingsval;}publicvoidsetCaloriers(intval){caloriersval;}publicvoidsetFat(intval){fatval;}publicvoidsetSodium(intval){sodiumval;}publicvoidsetCarbohydrate(intval){carbohydrateval;}}创建实例容易代码虽冗长但不难阅读NutritionFactscocaColanewNutritionFacts();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.hosthost;}publicvoidsetPort(intport){this.portport;}publicvoidsetUsername(Stringusername){this.usernameusername;}publicvoidsetPassword(Stringpassword){this.passwordpassword;}// 业务方法尝试建立连接publicvoidconnect(){// 模拟检查如果没有配置host程序无法运行if(this.hostnull){thrownewIllegalStateException(致命错误数据库地址(host)未配置);}// 模拟连接逻辑System.out.println(正在连接数据库this.host:this.port);}}现在我们来看看在使用这个类的过程中会发生什么publicstaticvoidmain(String[]args){// 1. 实例化对象此时对象已存在但所有属性都是默认值 null 或 0DatabaseConfigconfignewDatabaseConfig();// 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负责创建并配置对象DatabaseConfigcfgnewDatabaseConfig();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方法修改了对象内部状态该对象就不再是不可变类了也就不再是线程安全的。那么程序员要付出的额外努力是什么呢三是当然可以通过手动“冻结”来减少这些缺点在对象构造完毕之前不允许使用但这种做法比较笨拙。这里的手动“冻结”就是程序员要付出的额外努力。我们尝试冻结下DatabaseConfigpublicclassDatabaseConfig{privateStringhost;// 数据库地址privateintport;// 端口号privateStringusername;// 用户名privateStringpassword;// 密码privatebooleanfrozenfalse;//冻结标志// 无参构造器publicDatabaseConfig(){}// Setter 方法要在冻结前才能调用publicvoidsetHost(Stringhost){checkIfFrozen();this.hosthost;}publicvoidsetPort(intport){checkIfFrozen();this.portport;}publicvoidsetUsername(Stringusername){checkIfFrozen();this.usernameusername;}publicvoidsetPassword(Stringpassword){checkIfFrozen();this.passwordpassword;}// 检查是否已冻结privatevoidcheckIfFrozen(){if(frozen)thrownewIllegalStateException(对象已冻结不可修改);}// 冻结方法publicvoidfreeze(){validate();// 冻结前验证所有必要属性this.frozentrue;}// 验证方法确保所有必要属性都已设置privatevoidvalidate(){if(hostnull||usernamenull||passwordnull){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.servingSizebuilder.servingSize;this.servingsbuilder.servings;this.caloriersbuilder.caloriers;this.fatbuilder.fat;this.sodiumbuilder.sodium;this.carbohydratebuilder.carbohydrate;}// 生成器静态内部类publicstaticclassBuilder{// 必需的属性privatefinalintservingSize;privatefinalintservings;// 可选的属性privateintcaloriers;privateintfat;privateintsodium;privateintcarbohydrate;// 必需属性通过构造器设置publicBuilder(intservingSize,intservings){this.servingSizeservingSize;this.servingsservings;}// 可选属性通过方法设置publicBuildercaloriers(intval){this.caloriersval;returnthis;}publicBuilderfat(intval){this.fatval;returnthis;}publicBuildersodium(intval){this.sodiumval;returnthis;}publicBuildercarbohydrate(intval){this.carbohydrateval;returnthis;}// 构建方法publicNutritionFactsbuild(){returnnewNutritionFacts(this);}}}生成器模式特点程序不直接生成想要的对象而是由生成器Builder提供的的build()方法来构建创建Builder对象Builder的构造器或静态工厂应带有所有必需的参数由使用生成器的类似setter方法设置可选的参数并返回Builder对象最后调用build()方法返回想要的对象通常返回的对象是不可变的正因为生成器的setter方法会返回生成器对象本身就可以将一系列的调用链接起来形成一个流式的API。在营养成分标签这个例子中我们可以这样调用NutritionFactscocaColanewNutritionFacts.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;publicstaticabstractclassBuilderTextendsBuilderT{privateStringbrand;publicTbrand(Stringval){brandObjects.requireNonNull(val);returnself();}// 子类必需重写该方法返回thisprotectedabstractTself();publicabstractCarbuild();}Car(Builder?builder){this.brandbuilder.brand;}}Sedan子类publicclassSedanextendsCar{privatefinalintdoors;publicstaticclassBuilderextendsCar.BuilderBuilder{privateintdoors;publicBuilder(intdoors){this.doorsdoors;}OverrideprotectedBuilderself(){returnthis;}OverridepublicSedanbuild(){returnnewSedan(this);}}Sedan(Builderbuilder){super(builder);this.doorsbuilder.doors;}}代码详解1在这个父类Car里生成器Builder定义使用了泛型的一种特殊使用方式叫递归类型参数这个将在条目30会详细解析。可以将其理解为子类的Builder这个Builder需要通过子类重写self方法给出这样才能保障链式调用的连续性。递归类型和抽象的self方法一起保证链式调用在子类中也可以不中断工作这就是Java中所谓的模拟自身类型习惯用法。// 本例可以这样调用CarsedannewSedan.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 sedannew Sedan.Builder(2).brand(比亚迪).build();如果返回Car很显然要这样写Sendan sedanSedan)(new Sedan.Builder(2).brand(比亚迪).build();麻烦吧V二是为什么能这样重写实际上这是协变返回类型的运行机制在起作用。Java虚拟机JVM通过字节码指令的特殊处理来支持协变返回类型。当编译器遇到协变返回类型的方法重写时会生成桥接方法bridge method来确保运行时的多态行为正确。桥接方法是编译器自动生成的方法。本例中Sedan类桥接方法的实现public Car build() {return this.build();}3我们来深入理解下协变返回类型首先协变是一种类型系统规则当某个位置期望使用父类型时允许传入其子类型。List?extendsNumberintegerListnewArrayListInteger();List?extendsNumberdoubleListnewArrayListDouble();上面的代码能编译通过正是因为? extends Number引入了协变性。协变遵守PECS 原则Producer-Extends, Consumer-Super。如果你是从集合中“取”数据生产者角色用extends如果是“放”数据消费者角色用super。其次协变返回类型指的是在重写父类方法时允许子类方法的返回类型是父类方法返回类型的子类型。在本例中Sedan类的build方法如果返回类型不是父子关系比如你试图返回String编译器会报错。只有继承关系的子类型才被允许。三生成器模式的总结生成器模式可以为多个可变参数指定对应方法也可以将多次调用某个方法时分别传入的参数聚合到一个字段中如使用集合存储某种枚举值。同时可以重复使用一个生成器来构建多个对象也可以在对象创建时自动填充一些字段或进行规则校验所以生成器模式非常灵活。缺点就是系统开销稍大且构建繁琐一些。总而言之当我们要设计的类的构造器或静态工厂具有多个参数特别是其中的血多参数是可选的或具有相同的类型时生成器模式是个不错的选择。
http://www.gsyq.cn/news/1387005.html

相关文章:

  • 从‘公开’到‘私有’:深入理解虚幻蓝图变量权限,打造更健壮的交互逻辑
  • day30_fasttext分类任务
  • OpenGL笔记之光照原理一漫反射
  • 【Linux 系列·第 02 篇】操作系统原理:进程·内存·文件系统·I/O——Linux 怎么工作
  • Maven高级—分模块设计与开发、继承、聚合和私服
  • 从‘虚轴’到‘实轴’:深入解读汇川Inoproshop中CIA402轴的两种工作模式与应用场景
  • Spine动画在Unity里卡顿?性能优化实战:从Draw Call、材质实例化到网格合并
  • 给OpenGL学完就忘的你:用Unity Shader重温渲染管线,打通任督二脉
  • ARM SPE技术:硬件级性能分析与优化实践
  • TVA视觉智能体专栏(五):2026工业视觉行业复盘:低端调参彻底内卷,TVA智能体成工程师高薪破局核心
  • 没有银弹,从来就没有
  • Redis分布式锁进阶第十六篇
  • 教育科技产品集成AI批改功能时如何通过Taotoken保障服务稳定性
  • ARM调试与复位机制详解及实践技巧
  • LMD优化器:低精度训练与MXFP6格式的突破
  • FlashAttention与长视频理解:60分钟视频的单轮推理
  • 贪吃蛇游戏 模拟实现
  • 01华夏之光永存:马斯克火星窗口期与轨道运算问题全链条解决方案
  • 告别拖拽式布局:用IntelliJ IDEA + SceneBuilder 8.5.0高效构建JavaFX桌面应用界面
  • 为什么你的灰度总在凌晨2点崩?DeepSeek 2023全年137次灰度数据揭示:3类配置漂移占比达68.3%
  • Unity动画师必看:用Parent Constraint替代父子关系,轻松实现多目标跟随(附C#动态绑定代码)
  • Unity URP程序化材质与立方体纹理实战指南
  • 用Python+skimage搞定图像纹理分析:从GLCM六种特征到实战代码避坑
  • 用XGBoost和SHAP搞定多分类预测:一份Python 3.7下的实战避坑指南
  • 星盘接口开发文档:星座语料接口指南
  • ARM内存映射与定时器架构解析
  • 经颅超声刺激(TUS)技术原理与PlanTUS系统应用指南
  • 用Python手搓SMO算法:从SVM理论到sklearn源码级复现(附避坑指南)
  • STM32单片机学习(28) —— STM32的SPI外设
  • DeepSeek代码质量评估实战手册:7步完成从混沌到可度量的质变跃迁