Effective Java 3rd 条目2 面临众多构造子参数时考虑builder

静态工厂和构造子都有一个限制:它们不能很好调节大量可选参数。考虑这个情况,一个类表示包装食品上的营养成分标签。这些标签有些是必需的域,食用份量、份数和每份卡路里数;还有二十多个可选域,总脂肪含量、饱和脂肪含量、反脂肪含量、胆固醇含量,钠含量等等。大多数产品中这些可选参数只有很少的非零值。

对于这样的类,你打算用什么类型的构造子或者静态工厂?习惯上,编程人员用重叠构造子(telescoping constructor)模式:提供仅仅必需参数的构造子,有单个可选参数的另外一个构造子,和有两个可选参数的第三个构造子,等等,最后有所有可选参数的构造子。这是实践中的样子。为了简单起见,只展示四个可选参数:

// Telescoping constructor pattern - does not scale well!
public class NutritionFacts {
    private final int servingSize;  // (mL)            required
    private final int servings;     // (per container) required
    private final int calories;     // (per serving)   optional
    private final int fat;          // (g/serving)     optional
    private final int sodium;       // (mg/serving)    optional
    private final int carbohydrate; // (g/serving)     optional

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

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

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

    public NutritionFacts(int servingSize, int servings,
           int calories, int fat, int sodium, int carbohydrate) {
        this.servingSize  = servingSize;
        this.servings     = servings;
        this.calories     = calories;
        this.fat          = fat;
        this.sodium       = sodium;
        this.carbohydrate = carbohydrate;
    }
}

当你想创建一个实例,用包含你想设置所有参数的最短参数列表的构造子:

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

通常,调用这样的构造子需要你不想设置的参数,但是你被迫为它们传递参数。这种情况下,你传递0这个值给fat。对于“只有”六个参数的情况,这个看上去不算很糟,但是当参数数量增加时,很快就会失控。

简而言之,重叠构造子模式奏效,但是当有很多参数的时候,写客户端代码是困难的,读代码更困难。阅读的人会想,这些值是什么,而且必须细心计算参数来弄明白。相同类型参数的长长的系列会造成微妙的bug。如果客户端不慎颠倒了两个这样的参数,编译器不会发现,但是程序在运行时异常(条目51)。

当面临许多可选参数的构造子时,第二个替代方案是JavaBean模式,这个模式中,你调用一个无参数的构造子来创建一个对象,然后调用设置方法来设置每个必需的参数和感兴趣的可选参数:

// JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
    // Parameters initialized to default values (if any)
    private int servingSize  = -1; // Required; no default value
    private int servings     = -1; // Required; no default value
    private int calories     = 0;
    private int fat          = 0;
    private int sodium       = 0;
    private int carbohydrate = 0;

    public NutritionFacts() { }
    // Setters
    public void setServingSize(int val)  { servingSize = val; }
    public void setServings(int val)    { servings = val; }
    public void setCalories(int val)    { calories = val; }
    public void setFat(int val)         { fat = val; }
    public void setSodium(int val)      { sodium = val; }
    public void setCarbohydrate(int val) { carbohydrate = val; }
}

这个模式没有重叠构造子模式的缺点。虽然有点啰嗦,但是容易创建实例,代码也容易阅读:

NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

不幸的是,JavaBean模式有它自己的严重缺点。因为构造被分成多个调用,一个JavaBean在构造的过程中有可能不一致的状态。这个类没有这个选择:检查构造子参数的有效性来实行一致性。尝试使用一个不一致状态的对象,会导致失败,这个失败与含有bug的代码大相径庭,所以难于调试。一个相应的缺点是,JavaBean模式限制了生成一个不变类的可能性,需要编码者花额外的功夫保证线性安全。
手动“冻结(freezing)”对象来减少这些缺点是可能的:当对象构造完成了,不允许使用直到冻结。但是这个变体是笨拙的,在实践中用的非常稀少。此外,在运行中会造成错误,因为在使用它之前,编译器不能保证编码者调用一个对象的冻结方法。
幸运的是,有第三种选择,可以结合重叠构造子模式的安全性和JavaBean模式的可读性。这个是Builder模式[Gamma95]的一种形式。不是直接生成对象,而是客户端调用有所有必需参数的一个构造子(或者静态工厂),获得一个builder对象。然后客户端在builder对象上调用类似设置方法,分别设置感兴趣的可选参数。最后,客户端调用无参数的build方法来生成对象,这个对象一般是不可变的。builder是一个它构建的类的静态成员类(条目24)。下面是在实践中的样子:

// Builder Pattern
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values
        private int calories      = 0;
        private int fat           = 0;
        private int sodium        = 0;
        private int carbohydrate  = 0;

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

        public Builder calories(int val)
            { calories = val;      return this; }
        public Builder fat(int val)
            { fat = val;           return this; }
        public Builder sodium(int val)
            { sodium = val;        return this; }
        public Builder carbohydrate(int val)
            { carbohydrate = val;  return this; }
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

NutritionFacts类是不变的,所有参数的默认值在一个地方。builder的设置方法返回builder自身,可以链式调用,所以有流畅的API。下面是客户端代码的样子:

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

客户端代码容易写,更重要的是也容易通读。builder模式模仿了在Python和Scala中有名字的可选参数

为了简单,省略了有效性检查。为了尽快发现无效的参数,在builder构造子和方法中检查参数的有效性。build方法调用的有多个参数的构造子,检查这些不变量。为了保证这些不变量不受攻击,在拷贝builder的参数后(条目50),务必对对象的域进行检查。如果检查失败,抛出IllegalArgumentException,它的具体信息表示哪些参数是无效的(条目75)。

builder模式非常适合类继承。使用并行的builder的层级,每个builder嵌套在对应的类中。抽象的类有抽象的builder;具体的类有具体的builder。比如,考虑一个抽象类是代表各种种类披萨的层级的根类:

// Builder pattern for class hierarchies
public abstract class Pizza {
   public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
   final Set<Topping> toppings;

   abstract static class Builder<T extends Builder<T>> {
      EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
      public T addTopping(Topping topping) {
         toppings.add(Objects.requireNonNull(topping));
         return self();
      }

      abstract Pizza build();

      // Subclasses must override this method to return "this"
      protected abstract T self();
   }
   Pizza(Builder<?> builder) {
      toppings = builder.toppings.clone(); // See Item  50
   }
}

注意,Pizza.Builder是一个有递归类型参数(recursive type parameter)(条目30)的泛型(generic type)。这个和抽象的self方法一起,可以在子类中使得链式方法奏效,而不需要强行转换。Java缺少self类型,这个变通方案被认为是模拟self类型的惯例。

Pizza有两个具体的子类,一个是纽约类型的比萨,另外一个是半月比萨。前者有指定大小的参数,而后者需要指定酱汁是在外面还是里面。

public class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        @Override public NyPizza build() {
            return new NyPizza(this);
        }

        @Override protected Builder self() { return this; }
    }

    private NyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}

public class Calzone extends Pizza {
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder<Builder> {
        private boolean sauceInside = false; // Default

        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }

        @Override public Calzone build() {
            return new Calzone(this);
        }

        @Override protected Builder self() { return this; }
    }

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }
}

注意, 每个子类builder中的build方法是声明返回正确的子类的:NyPizza.Builderde的build方法返回NyPizza,而Calzone.Builder的build方法返回Calzone。这个技巧,一个子类方法被声明返回一个声明在超类中返回类型的子类型,叫做协变返回类型(covariant return typing)。这些层级builder的客户端代码,本质上与简单的NutritionFacts builder代码是等同的。

客户端的代码例子如下,为了简洁,假设enum常量是静态导入:

NyPizza pizza = new NyPizza.Builder(SMALL)
        .addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
        .addTopping(HAM).sauceInside().build();

相对于构造子,builder有个小优点是,builder可以有多个可变参数,因为每个参数是它自己方法中指定的。或者,build可以把传入到多次调用的方法的参数聚合到单个域,就像前面addTopping方法展示的一样。

builder模式相当灵活。单个builder可以重复使用到构建多个对象。随着创建对象的不同,builder的参数在build方法的调用之间可以改变。对象创建时builder可以自动填充到域中,比如随着每次创建时一个增加的系列数。

builder模式也有缺点,为了创建一个对象,必须首先创建它的builder。创建builder的代价在实践中虽然是不显而易见,但是在性能要求苛刻的情形中是一个问题。而且,builder模式相对于重叠构造子模式更加啰嗦,所以如果有足够的参数,比如四个或者更多,才值得使用。但是记住,未来你有可能添加更多的参数。但是,如果你开始使用构造子或者静态工厂,然后当类演变到参数个数失控的时候,切换到builder,这时废弃的构造子或者静态工厂一直存在,这是个尴尬的事情。

总之,在设计类时,当它的构造子或者静态工厂有多过一些参数的时候,builder模式是一个好的选择,特别是当多个参数是可选的或者是同个类型的时候。和重叠构造子相比,客户端代码更容易阅读和编写,而且builder比JavaBeans更安全。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容