《Effective Java 第三版》笔记之二 当构造参数很多的时候考虑使用builder

标签:#effectivejava##java# 时间:2018/09/17 22:15:33 作者:小木

静态工厂和构造方法都有一个缺点:当有很多可选参数的时候,其扩展性并不是很好。例如,考虑这样一个类,它表示食物包装上的营养物质标签。这些标签有一部分是必须的字段——例如分量大小、每个包装容器包含的分量大小、每份物质包含的卡路里等,还有一部分是可选字段——例如总的脂肪含量、饱和脂肪含量、反式脂肪含量等等。大多数食品只有一小部分字段是非零的结果。

对于这样一个类,要如何使用构造方法或者是静态工厂方法呢?传统上,编程者可以使用重叠构造函数模式(telescoping constructor pattern),即在某个构造方法中只包含必须的字段,然后添加其他的构造方法包含其他可选字段。举个例子:假设只有4个可选字段:

  1. public class NutritionFacts {
  2. private final int servingSize; // (mL) required
  3. private final int servings; // (per container) required
  4. private final int calories; // (per serving) optional
  5. private final int fat; // (g/serving) optional
  6. private final int sodium; // (mg/serving) optional
  7. private final int carbohydrate; // (g/serving) optional
  8. public NutritionFacts(int servingSize, int servings) {
  9. this(servingSize, servings, 0);
  10. }
  11. public NutritionFacts(int servingSize, int servings, int calories) {
  12. this(servingSize, servings, calories, 0);
  13. }
  14. public NutritionFacts(int servingSize, int servings, int calories, int fat) {
  15. this(servingSize, servings, calories, fat, 0);
  16. }
  17. public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
  18. this(servingSize, servings, calories, fat, sodium, 0);
  19. }
  20. public NutritionFacts(int servingSize, int servings, int calories, int fat,
  21. int sodium, int carbohydrate) {
  22. this.servingSize = servingSize;
  23. this.servings = servings;
  24. this.calories = calories;
  25. this.fat = fat;
  26. this.sodium = sodium;
  27. this.carbohydrate = carbohydrate;
  28. }
  29. }

当你使用这个类创建对象实例的时候,需要选择相对应的构造方法:

  1. NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

一般情况下,这个构造方法的调用会需要许多不必要的参数,但是你必须要给它一些值。例如,在上述的例子中,我们给fat传递了一个0。如果只有6个参数,这也不是一个多么难以接受的事情,但是当参数数量增长的时候,这种方式就有点难以忍受了。

简单来说,重叠构造函数模式很有效,但是当参数很多时候写起来很麻烦,阅读也不友好。用户必须仔细阅读这些方法,并小心的计算参数的数量以避免出错。很长的相同类型的参数容易导致一些微小的错误。当用户把两个参数搞反了,程序也不会报错,但实际已经是错误的了。

第二个选择是使用JavaBean的模式来解决这个问题,你可以调用一个无参数的构造函数来创建对象,然后使用set方法将所需的字段赋值,例如:

  1. // JavaBeans Pattern - allows inconsistency, mandates mutability
  2. public class NutritionFacts {
  3. // Parameters initialized to default values (if any)
  4. private int servingSize = -1; // Required; no default value
  5. private int servings = -1; // Required; no default value
  6. private int calories = 0;
  7. private int fat = 0;
  8. private int sodium = 0;
  9. private int carbohydrate = 0;
  10. public NutritionFacts() { }
  11. // Setters
  12. public void setServingSize(int val) { servingSize = val; }
  13. public void setServings(int val) { servings = val; }
  14. public void setCalories(int val) { calories = val; }
  15. public void setFat(int val) { fat = val; }
  16. public void setSodium(int val) { sodium = val; }
  17. public void setCarbohydrate(int val) { carbohydrate = val; }
  18. }

这种模式没有重叠构造函数模式的缺点,而且很容易构造,对代码阅读也很友好:

  1. NutritionFacts cocaCola = new NutritionFacts();
  2. cocaCola.setServingSize(240);
  3. cocaCola.setServings(8);
  4. cocaCola.setCalories(100);
  5. cocaCola.setSodium(35);
  6. cocaCola.setCarbohydrate(27);

然而,JaveBeans本身有很大的缺点。由于构造过程有多次不同的调用,因此JavaBeans可能会产生不一致的情况。例如,JavaBeans类不能只通过检查构造函数参数的有效性来保证一致性。当一个对象处于一种不一致的状态时,试图使用它可能会引起失败,这个失败很难从包含错误的代码中去掉,因此很难调试。与此相关的一个缺点是JavaBeans的模式无法创建不可变的类,因此需要编程者花费其他成本来保证线程安全。

当构造工作完成时,可以通过手动『冰冻』对象并且在冰冻完成之前不允许使用它来弥补这个缺点,但这种方式太笨重了,在实践中很少使用。而且,由于编译器不能保证程序员在使用对象之前调用了冰冻方法,因此它可能在运行时引起错误。

幸运的是,有第三种方法既保证有重叠构造函数模式的安全性,也有JavaBeans的简洁性。这就是生成器模式(Builder pattern)。客户端使用构造方法来初始化所有必要的字段,然后使用类似setter方法来构建可选参数。最终,客户端使用一个无参的builder方法来产生一个对象,通常该对象都是不可变的。Builder通常都是一个静态的成员类:

  1. // Builder Pattern
  2. public class NutritionFacts {
  3. private final int servingSize;
  4. private final int servings;
  5. private final int calories;
  6. private final int fat;
  7. private final int sodium;
  8. private final int carbohydrate;
  9. public static class Builder {
  10. // Required parameters
  11. private final int servingSize;
  12. private final int servings;
  13. // Optional parameters - initialized to default values
  14. private int calories = 0;
  15. private int fat = 0;
  16. private int sodium = 0;
  17. private int carbohydrate = 0;
  18. public Builder(int servingSize, int servings) {
  19. this.servingSize = servingSize;
  20. this.servings = servings;
  21. }
  22. public Builder calories(int val) {
  23. calories = val;
  24. return this;
  25. }
  26. public Builder fat(int val) {
  27. fat = val;
  28. return this;
  29. }
  30. public Builder sodium(int val) {
  31. sodium = val;
  32. return this;
  33. }
  34. public Builder carbohydrate(int val) {
  35. carbohydrate = val;
  36. return this;
  37. }
  38. public NutritionFacts build() {
  39. return new NutritionFacts(this);
  40. }
  41. }
  42. private NutritionFacts(Builder builder) {
  43. servingSize = builder.servingSize;
  44. servings = builder.servings;
  45. calories = builder.calories;
  46. fat = builder.fat;
  47. sodium = builder.sodium;
  48. carbohydrate = builder.carbohydrate;
  49. }
  50. }

这个NutritionFacts就是不可变类,所有的默认参数都在一个地方。builder的setter方法返回builder本身从而使得可以链式调用API。调用的方法如下:

  1. NutritionFacts cocaCola = new NutritionFacts.Builder(240,8).calories(100).sodium(35).carbohydrate(27).build();

这种API很容易写,且阅读起来也很方便。生成器模式模拟了Python和Scala中命名可选参数。

为了简短起见,参数的有效性检验在这里没有写出来。为了尽快的检测到无效的参数,可以在builder的构造器和方法中检验。检查有build方法调用的构造方法涉及到多个参数的不可变量。为了防止这些不可变量收到攻击,从builder中复制参数后对对象字段进行检验。如果检测失败,抛出IllegalArgumentException异常,可以显示哪些参数是无效的。

生成器模式非常适合具有层次结构的类。使用并行的层次构造器,每一个都被嵌套在相关的类中。抽象类有抽象的builder; 具体的类有具体的builder。例如,考虑一个层次类的根节点是一个抽象类,代表了不同的pizza:

  1. // Builder pattern for class hierarchies
  2. public abstract class Pizza {
  3. public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
  4. final Set<Topping> toppings;
  5. abstract static class Builder<T extends Builder<T>> {
  6. EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
  7. public T addTopping(Topping topping) {
  8. toppings.add(Objects.requireNonNull(topping));
  9. return self();
  10. }
  11. abstract Pizza build();
  12. // Subclasses must override this method to return "this"
  13. protected abstract T self();
  14. }
  15. Pizza(Builder<?> builder) {
  16. toppings = builder.toppings.clone(); // See Item 50
  17. }
  18. }

注意,Pizza.Builder是一个有着递归参数的通用类型(泛型)。它和抽象的self方法一起,允许子类中的方法进行链式调用,而不需要转换。这个方法实际上是Java确实self类型的一个变通解决方案,这个类型通常称为模拟自我类型( the simulated self-type idiom)。

现在有两个具体的Pizza子类,一个代表了标准的纽约式pizza,一个是意式包馅比萨(calzone)。前者需要大小(size)这个参数,后者需要指定酱要放在里面还是外面。

  1. public class NyPizza extends Pizza {
  2. public enum Size { SMALL, MEDIUM, LARGE }
  3. private final Size size;
  4. public static class Builder extends Pizza.Builder<Builder> {
  5. private final Size size;
  6. public Builder(Size size) {
  7. this.size = Objects.requireNonNull(size);
  8. }
  9. @Override
  10. public NyPizza build() {
  11. return new NyPizza(this);
  12. }
  13. @Override
  14. protected Builder self() { return this; }
  15. }
  16. private NyPizza(Builder builder) {
  17. super(builder);
  18. size = builder.size;
  19. }
  20. }
  21. public class Calzone extends Pizza {
  22. private final boolean sauceInside;
  23. public static class Builder extends Pizza.Builder<Builder> {
  24. private boolean sauceInside = false; // Default
  25. public Builder sauceInside() {
  26. sauceInside = true;
  27. return this;
  28. }
  29. @Override
  30. public Calzone build() {
  31. return new Calzone(this);
  32. }
  33. @Override
  34. protected Builder self() { return this; }
  35. }
  36. private Calzone(Builder builder) {
  37. super(builder);
  38. sauceInside = builder.sauceInside;
  39. }
  40. }

注意到,子类的builder被声明为返回正确的类型了。NyPizza.Builder的build方法返回的是NyPizza类,而Calzone.Builder返回的是Calzone类。这种子类方法返回父类返回值的子类型称之为协变返回类型(covariant return typing)。它允许子类可以直接使用这些builders而不需要做强制转化。

欢迎大家关注DataLearner官方微信,接受最新的AI技术推送
Back to Top