Effective Java 第三版——2. 當構造方法參數過多時使用builder模式

Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼裏方法是基於Java 9 API中的,因此JDK 最好下載 JDK 9以上的版本。java

Effective Java, Third Edition

條目2:當構造方法參數過多時使用builder模式

靜態工廠和構造方法都有一個限制:它們不能很好地擴展到不少可選參數的情景。請考慮一個表明包裝食品上的養分成分標籤的例子。這些標籤有幾個必需的屬性——每次建議的攝入量,每罐的分量和每份卡路里 ,以及超過20個可選的屬性——總脂肪、飽和脂肪、反式脂肪、膽固醇、鈉等等。大多數產品都有非零值,只有少數幾個可選屬性。git

應該爲這樣的類編寫什麼樣的構造方法或靜態工廠?傳統上,程序員使用了可伸縮(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;
    }
}

當想要建立一個實例時,可使用包含全部要設置的參數的最短參數列表的構造方法:github

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

一般狀況下,這個構造方法的調用須要許多你不想設置的參數,可是你不得不爲它們傳遞一個值。 在這種狀況下,咱們爲fat屬性傳遞了0值。 『只有』六個參數可能看起來並不那麼糟糕,但隨着參數數量的增長,它會很快失控。安全

簡而言之,可伸縮構造方法模式是有效的,可是當有不少參數時,很難編寫客戶端代碼,並且很難讀懂它。讀者不知道這些值是什麼意思,而且必須仔細地計算參數才能找到答案。一長串相同類型的參數可能會致使一些細微的bug。若是客戶端意外地顛倒了兩個這樣的參數,編譯器並不會抱怨,可是程序在運行時會出現錯誤行爲(條目51)。ide

當在構造方法中遇到許多可選參數時,另外一種選擇是JavaBeans模式,在這種模式中,調用一個無參數的構造函數來建立對象,而後調用setter方法來設置每一個必需的參數和可選參數:函數

// 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);

不幸的是,JavaBeans模式自己有嚴重的缺陷。因爲構造方法在屢次調用中被分割,因此在構造過程當中JavaBean可能處於不一致的狀態。該類沒有經過檢查構造參數參數的有效性來執行一致性的選項。在不一致的狀態下嘗試使用對象可能會致使與包含bug的代碼截然不同的錯誤,所以很難調試。一個相關的缺點是,JavaBeans模式排除了讓類不可變的可能性(條目17),而且須要在程序員的部分增長工做以確保線程安全。ui

當它的構造完成時,手動「凍結」對象,而且不容許它在解凍以前使用,能夠減小這些缺點,可是這種變體在實踐中很難使用而且不多使用。 並且,在運行時會致使錯誤,由於編譯器沒法確保程序員在使用對象以前調用freeze方法。this

幸運的是,還有第三種選擇,它結合了可伸縮構造方法模式的安全性和javabean模式的可讀性。 它是Builder模式[Gamma95]的一種形式。客戶端不直接調用所需的對象,而是調用構造方法(或靜態工廠),並使用全部必需的參數,並得到一個builder對象。而後,客戶端調用builder對象的setter類似方法來設置每一個可選參數。最後,客戶端調用一個無參的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的setter方法返回builder自己,這樣調用就能夠被連接起來,從而生成一個流暢的API。下面是客戶端代碼的示例:

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

這個客戶端代碼很容易編寫,更重要的是易於閱讀。 Builder模式模擬Python和Scala中的命名可選參數。

爲了簡潔起見,省略了有效性檢查。 要儘快檢測無效參數,檢查builder的構造方法和方法中的參數有效性。 在build方法調用的構造方法中檢查包含多個參數的不變性。爲了確保這些不變性不受攻擊,在從builder複製參數後對對象屬性進行檢查(條目 50)。 若是檢查失敗,則拋出IllegalArgumentException異常(條目 72),其詳細消息指示哪些參數無效(條目 75)。

Builder模式很是適合類層次結構。 使用平行層次的builder,每一個嵌套在相應的類中。 抽象類有抽象的builder; 具體的類有具體的builder。 例如,考慮表明各類比薩餅的根層次結構的抽象類:

// Builder pattern for class hierarchies

import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

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)的泛型類型。 這與抽象的self方法一塊兒,容許方法鏈在子類中正常工做,而不須要強制轉換。 Java缺少自我類型的這種變通解決方法被稱爲模擬自我類型(simulated self-type)的習慣用法。

這裏有兩個具體的Pizza的子類,其中一個表明標準的紐約風格的披薩,另外一個是半圓形烤乳酪餡餅。前者有一個所需的尺寸參數,然後者則容許指定醬汁是否應該在裏面或在外面:

import java.util.Objects;

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.Builderbuild方法返回NyPizza,而Calzone.Builder中的build方法返回Calzone。 這種技術,其一個子類的方法被聲明爲返回在超類中聲明的返回類型的子類型,稱爲協變返回類型( covariant return typing)。 它容許客戶端使用這些builder,而不須要強制轉換。

這些「分層builder」的客戶端代碼基本上與簡單的NutritionFacts builder的代碼相同。爲了簡潔起見,下面顯示的示例客戶端代碼假設枚舉常量的靜態導入:

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

builder對構造方法的一個微小的優點是,builder能夠有多個可變參數,由於每一個參數都是在它本身的方法中指定的。或者,builder能夠將傳遞給多個調用的參數聚合到單個屬性中,如前面的addTopping方法所演示的那樣。

Builder模式很是靈活。 單個builder能夠重複使用來構建多個對象。 builder的參數能夠在構建方法的調用之間進行調整,以改變建立的對象。 builder能夠在建立對象時自動填充一些屬性,例如每次建立對象時增長的序列號。

Builder模式也有缺點。爲了建立對象,首先必須建立它的builder。雖然建立這個builder的成本在實踐中不太可能被注意到,但在性能關鍵的狀況下可能會出現問題。並且,builder模式比伸縮構造方法模式更冗長,所以只有在有足夠的參數時才值得使用它,好比四個或更多。可是請記住,若是但願在未來添加更多的參數。可是,若是從構造方法或靜態工廠開始,並切換到builder,當類演化到參數數量失控的時候,過期的構造方法或靜態工廠就會面臨尷尬的處境。所以,因此,最好從一開始就建立一個builder。

總而言之,當設計類的構造方法或靜態工廠的參數超過幾個時,Builder模式是一個不錯的選擇,特別是若是許多參數是可選的或相同類型的。客戶端代碼比使用伸縮構造方法(telescoping constructors)更容易讀寫,而且builder比JavaBeans更安全。

相關文章
相關標籤/搜索