Effective Java理解笔记系列-第2条-何时考虑用构建器?

为什么写这系列博客?

在阅读《Effective Java》这本书时,我发现有许多地方需要仔细认真地慢慢阅读并且在必要时查阅相关资料才能彻底搞懂,相信有些读者在阅读此书时也有类似感受;同时,在解决疑惑的过程中,还存在着有些内容不容易查找、查找到的解答质量不高等问题,于是我决定把我阅读此书收获到的东西写成博客,期望能够解答某些读者之困惑。

为了方便大家阅读时按章节查找,我会按照原书籍写作顺序来划分博客章节。博客中主要包含以下内容:

  • 我对原文内容的理解(再加工)
  • 一些补充知识(需要理解这些知识才能真正理解该章节内容)

何时考虑用构建器?

类中有几个必选参数,且存在大量可选参数时。

  • 大量指至少有4个
  • 可选指大部分实例只在某几个可选域存在非零值,其他都是零。

如:

public class NutritionFacts{
	private final int servingSize;//每份含量,必选
	private final int servings;//每罐含量,必选
	private final int calories;//卡路里,可选
	private final int fat;//总脂肪含量,可选
	private final int saturatedFat;//饱和脂肪含量,可选
	private final int sodium;//钠含量,可选
	private final int cholesterol;//胆固醇,可选
}

有以下几种解决方案:

重叠构造器

设置多个构造方法,并依次增加入参数量,构造方法内部自动调用参数多一个的构造方法,直到调用到最后一个全参数的构造方法。

代码如下(我又额外增加了 饱和脂肪含量 和 胆固醇含量 这两个域):

public class NutritionFacts{
	private final int servingSize;//每份含量,必选
	private final int servings;//每罐含量,必选
	private final int calories;//卡路里,可选
	private final int fat;//总脂肪含量,可选
	private final int saturatedFat;//饱和脂肪含量,可选
	private final int sodium;//钠含量,可选
	private final int cholesterol;//胆固醇含量,可选

	public NutritionFacts(int servingService,int servings){
		this(servingService,servings,0);
	}

	public NutritionFacts(int servingService,int servings,int calories){
		this(servingService,servings,calories,0);
	}

	public NutritionFacts(int servingService,int servings,int calories,int fat){
		this(servingService,servings,calories,fat,0);
	}

	public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat){
		this(servingService,servings,calories,fat,saturatedFat,0);
	}

	public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium){
		this(servingService,servings,calories,fat,saturatedFat,sodium,0);
	}
	
	public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium,int cholesterol){
		this.servingService = servingService;
		this.servings = servings;
		this.calories = calories;
		this.fat = fat;
		this.saturatedFat = saturatedFat;
		this.sodium = sodium;
		this.cholesterol = cholesterol;
	}
}

使用时,选择包含想传递参数的最短的那个构造器就可以了。如:我想传递calories和fat字段,那么下面的构造函数即可,其他可选参数会自动被设置为0。

NutritionFacts test = new NutritionFacts(240,8,5,4);

缺点如下:

  • 冗余传参

假如,客户端只需要设置最后两个可选参数sodium和cholesterol,但是却需要调用最后一个构造方法,并将所有其他可选参数传入0。

new NutritionFacts(240,8,0,0,0,240,25);

这样的方式需要客户端传入并不需要设置的参数,代码冗余不优雅。

  • 类型相同的相邻参数易传错

如果搞混了两个有相同数据类型又紧挨着的可选域的值,编译时不会出错,但运行时会出现错误行为。

new NutritionFacts(240,8,0,240,50,0,0);//本来想传入这种
new NutritionFacts(240,8,0,50,240,0,0);//实际却传入这种
  • 编写代码和阅读代码均须数数(未使用IDEA时)

编写代码时需要通过数数来确定传入的参数在第一个,同理,阅读代码时也需要数数来确定到底传入了哪些可选参数。

new NutritionFacts(240,8,0,1,0,240,0);

如果使用了IDEA,则数数问题则可以解决:IDEA会在值前提示我们是哪个域:

//"host:"和"port:"是idea添加的提示
Socket client = new Socket(host:"127.0.0.1", port:6666);

这里补充一个基础知识:

有些人会疑惑,给可选域设置一个初始值0不就可以了吗,这样就不会出现冗余传参的问题。但实际上,这种写法是无法通过编译的,因为final修饰的实例域的初始化器和初始化代码块是优先于构造函数执行的(初始化器指 用 = 直接赋值,初始化代码块指用大括号括起来的 各实例域 = 赋值的代码),final修饰的实例域在初始化器初始化后,就不能再通过构造函数进行修改了,所以设置的初始化值无效,而且也达不到后续改变需要的可选参数为非0的目的。

继续思考,那么不设置为final域,这样就可以设置默认的初始化值了,这样就引出了下一种方式,JavaBean方式。

public class NutritionFacts{
    private final int calories = 0;//卡路里,可选,初始化为0,省略其他实例域
   
   	//省略其他构造函数

    public NutritionFacts(int servingService,int servings,int calories,int fat,int saturatedFat,int sodium,int cholesterol){
        this.servingSize = servingService;
        this.servings = servings;
        this.calories = calories;//这行编译器会报错
        this.fat = fat;
        this.saturatedFat = saturatedFat;
        this.sodium = sodium;
        this.cholesterol = cholesterol;
    }
}

JavaBeans方式

先创建对象,再调用set方法赋值。

代码如下:

public class NutritionFacts{
	private int servingSize;//每份含量,必选,通过构造函数设置
	private int servings;//每罐含量,必选,通过构造函数设置
	private int calories;//卡路里,可选
	private int fat;//总脂肪含量,可选
	private int saturatedFat;//饱和脂肪含量,可选
	private int sodium;//钠含量,可选,
	private int cholesterol;//胆固醇含量,可选

	public NutritionFacts(int servingSize,int servings){
		this.servingSize = servingSize;
		this.servings = servings;
	}

	public void setcalories(int calories){
		this.calories = calories;
	}

	//以下set函数省略,同上
}

使用时,先创建对象,再依次调用set方法设置需要设置的值即可。

这种方式因为可以按需设置了,所以不仅解决了代码阅读和编写时数参数的问题,还解决了冗余参数的问题;但是却使类从不可变类变成了可变类(因为提供了set函数),可能会带来线程安全问题。

原书中还提到了一个缺点:

遗憾的是,JavaBeans模式自身有着很严重的缺点。因为构造过程被分到了几个调用中,在构造过程中JavaBeans可能处于不一致状态。类无法仅仅通过检验构造器参数的有效性来保证一致性。

“JavaBeans可能处于不一致状态”是什么意思呢?

我认为,理解这句话需要先理解它后面这句话,即“类无法仅仅通过检验构造器参数的有效性来保证一致性”:

这句话其实给出了作者认为的一致性的含义,即,所有参数都校验通过所创建的对象就是符合一致性的。那么怎样做参数校验?作者也给出了答案,即“仅仅通过检验构造器参数”,意思是,通过构造器方式设置可选参数时,通过构造器这一个方法做参数校验即可,但是JavaBeans模式需要调用多个set方法,如果在set函数中的某些方法遗漏了参数校验代码,那么创建出的对象会出现某个或某几个字段的值不符合规则,但是其他值却符合规则的情况,此种情况即是不一致。

所以解决不一致的方式就是需要在set方法中加入参数校验代码,保证当某个传递进来的参数不符合规则时可以立即报错。

虽然不一致问题可以解决,但是从不可变类变成可变类这个问题却无法解决。

构建器模式

通过一个构建器类,先设置参数值,最后再创建对象。

public class NutritionFacts{
	private final int servingSize;//每份含量,必选
	private final int servings;//每罐含量,必选
	private final int calories;//卡路里,可选
	private final int fat;//总脂肪含量,可选
	private final int saturatedFat;//饱和脂肪含量,可选
	private final int sodium;//钠含量,可选
	private final int cholesterol;//胆固醇含量,可选

	public static class NutritionFactsBuilder{
		//注意:这里的final并不是一定需要的
		//写上final,我认为可以在编程时让编译器帮助我们检查是否初始化
		private final int servingSize;//每份含量,必选
		private final int servings;//每罐含量,必选

		//注意:这里的设置初始值为0也不是必要的,但是可以增加代码可读性。
		//了解Java的人会清楚这里会设置为默认值0
		//但是这样写可以让不了解Java的人也清楚的知道默认值被设置为了0
		private int calories = 0;//卡路里,可选
		private int fat = 0;//总脂肪含量,可选
		private int saturatedFat = 0;//饱和脂肪含量,可选
		private int sodium = 0;//钠含量,可选
		private int cholesterol = 0;//胆固醇含量,可选

		public NutritionFactsBuilder(int servingSize,int servings){
			this.servingSize = servingSize;	
			this.servings = servings;
		}

		public NutritionFactsBuilder calories(int calories){
			this.calories = calories;
		}

		public NutritionFactsBuilder fat(int fat){
			this.fat= fat;
		}

		//以下省略其他可选字段方法

		public NutritionFacts build(){
			return new NutritionFacts(this);
		}
	}

	public NutritionFacts(NutritionFactsBuilder builder){
		this.servingService = builder.servingService;
		this.servings = builder.servings;
		this.calories = builder.calories;
		this.fat = builder.fat;
		this.saturatedFat = builder.saturatedFat;
		this.sodium = builder.sodium;
		this.cholesterol = builder.cholesterol;
	}		
}

缺点:编写冗长,为了创建对象需要先创建一个构建器,某些注重性能的情况下有问题

上述这种构建器模式,其实就是简单工厂模式,即客户端依赖具体类NutritionFactsBuilder来创建NutritionFacts,不符合针对接口编程这个设计原则,所以书中提到了这种模式的优化方式---抽象工厂模式,即通过创建一个接口Builder,提供build()方法,让NutritionFactsBuilder实现这个接口,这样客户端就可以面向接口Builder编程,如果修改了具体实现,则除了创建新的具体实现Builder以外,客户端其他的代码都不需要修改。

public interface Builder<T>{
	public T build();
}

这里补充下工厂模式的最后一种:工厂方法模式。我在网络上查阅资料时发现许多人会把这个模式和抽象工厂模式搞混,其实这两者并不相同。

简而言之,工厂方法模式更适合用来控制一个方法的整体业务流程。整体业务流程由具体代码以及各个业务方法调用组成,而其中某些业务方法是需要由不同的子类来实现的,所以工厂方法模式编写时并不是定义一个抽象的接口(抽象工厂模式),而是利用抽象父类来限定一个方法的整体业务流程,然后提供一个或多个抽象的protected业务方法由子类继承父类来重写,以此实现上述目的。

补充

最后,书中还提到了Class的newInstance这个方法,这个方法在Java9中被标记为过时,而且在第三版书籍中已经被去除,虽然已被删除及标记过时,但了解它的原理也是有必要的,因为如果能够理解站在当时的视角为什么会写这段文字,又理解它为什么会被删除,对巩固Java基础大有裨益。

缺点逐句解读:

该方法总是试图调用无参构造函数:然而可能类中并不存在无参构造函数

如果用new的方式创建,不存在无参构造函数却想要调用无参构造函数时,编译器会检测出来,但是使用newInstance,需要等到运行期才能发现此事。

运行时处理:Instantiation Exception 和 IllegalAccessException(这两个都是受检异常)

我查阅了JDK1.6版本的源码,关于这两个异常的注释如下:

IllegalAccessException – if the class or its nullary constructor is not accessible.
InstantiationException – if this Class represents an abstract class, an interface, an array class, a primitive type, or void; or if the class has no nullary constructor; or if the instantiation fails for some other reason.

第一点所说的不存在无参构造函数的情况,是属于会抛出InstantiationException的情况之一。该方法的签名用throws 关键字明确抛出异常,需要调用者处理。

public T newInstance() 
        throws InstantiationException, IllegalAccessException

客户端代码必须在运行时处理IllegalAccessException和InstantiationException,这样既不雅观也不方便

关键字还是运行时!虽然客户端会编写try-catch代码来处理这两个异常,但是很明显,这两个异常仍然是在运行期间才会发生并被处理的,如果不用newInstance,编译器就会发现你想访问的类是noAccess的或者 你调用的无参构造函数并不存在 或者 你试图创建了一个抽象类 一个接口 等等之类的问题。

上面这几句其实说的是一件事,就是运用newInstance会把问题推后到运行期而非在编译期解决,作者认为是不好的。关于书中作者对编译期提前发现问题如此执着的原因,我猜可能在于有些软件并不能非常轻松的在本地运行起来(虽然我还没有接触过),在本地运行如此不易的情况下,编译期能及时发现问题就显得难能可贵了。

newInstance方法还会传播由无参构造器抛出的任何异常,newInstance缺乏相应的throws语句

这个问题比较重要,也是后来这个方法过时的原因。

调用构造器时,如果发生非受检异常,newInstance方法直接向上传播并且不写throws语句没有任何问题,但是如果发生受检异常,这就代表着需要调用者处理该异常,newInstance方法直接抛出却不写throws语句破坏了原来的目的:失去了编译器强制异常检测的功能。

可以看到,后来的替代者(如下)的newInstance方法可以不仅仅调用无参构造函数,并且还提供了一个由构造函数抛出的异常的包装异常InvocationTargetException来解决受检异常被抛出却无throws语句的问题。

这样一来,原来的newInstance就可以安心退休了。

InvocationTargetException – if the underlying constructor throws an exception.

//这样调用
getDeclaredConstructor(Class<?>... parameterTypes).newInstance(Object ... initargs)

//关注InvocationTargetException
 @CallerSensitive
    public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
posted @ 2024-08-29 15:48  Ging  阅读(3)  评论(0编辑  收藏  举报