1. 程式人生 > >effective java 3th item2:考慮 builder 模式,當構造器引數過多的時候

effective java 3th item2:考慮 builder 模式,當構造器引數過多的時候

yiaz 讀書筆記,翻譯於 effective java 3th 英文版,可能有些地方有錯誤。歡迎指正。

靜態工廠方法和構造器都有一個限制:當有許多引數的時候,它們不能很好的擴充套件。

比如試想下如下場景:考慮使用一個類表示食品包裝袋上的營養成分標籤。這些標籤只有幾個是必須的——每份的含量、每罐的含量、每份的卡路里,除了這幾個必選的,還有超過 20 個可選的標籤——總脂肪量、飽和脂肪量等等。對於這些可選的標籤,大部分產品一般都只有幾個標籤的有值,不是每一個標籤都用到。

  1. telescoping constructor)重疊構造器模式

    對於這種情況,你應該選擇哪種構造器或者靜態工廠方法。一般程式設計師的習慣是採用 (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, 0, 0, 35, 27);

    其中,第 1,2 個可選引數,我們是不需要的,但是程式中沒有提供直接賦值第 3,4個可選引數的構造器,因此,我們只能選擇包含了 1,2,3,4 個引數的構造器。這裡面要求了許多你不想設定的引數,但是你又被迫的設定它們,在這裡,傳入對應的屬性的預設值 0。並且這種模式,隨著引數的增加,將變得越來越難以忍受,無論是編寫程式的人,還是呼叫程式的人。

    總而言之,(telescoping constructor

    )重疊構造器模式,可以使用,但是它對客戶端來說,很不友好,寫和讀都是一件困難的事情。它們很難搞懂那些引數對應的到底是什麼屬性,必須好好的比對構造器程式碼。並且當引數很多的時候,很容易出 bug,如果使用的時候,無意間顛倒了兩個引數的位置,編譯器是不會出現警告的,因為這裡的型別一樣,都是 int ,直到執行的時候才會暴露出。

  2. Javabeans 模式

    我們還有一種選擇,使用 Javabeans 模式 。

    在此模式中,我們提供一個 無參構造器 建立例項,然後利用 setXXX 方法,設定每一個必須的屬性和每一個需要的可選屬性。

    示例程式碼:

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

    Javabeans 模式,沒有 重疊構造器模式 的缺點,對於冗長的引數,使用它建立物件,會很容易,同時讀起來也是容易。正如下面看到的,我們可以清晰的看到,每一個屬性的值。

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

    不幸運的是,Javabeans模式 本身有著嚴重的缺點:因為,建立物件被分割為多個步驟,先是利用無參構造器建立物件,然後再依次設定屬性。這導致一個問題: Javabean 在其建立過程中,可能處於不一致1的狀態。 類不能通過檢查構造器的引數,來保證物件的一致性。

    另外一個缺點是,將建立一個可變的類的難度提高了好幾個級別,因為有 setXXX 方法的存在。

    可以通過一些手段來減少不一致的問題,通過一些手段 凍結 物件,在物件被建立完成之前。並且不允許使用該物件,直到 解凍 。但是這種方式非常笨拙,在實踐中很少使用。因為,編譯器無法確認程式設計師在使用一個物件之前,該物件是否已經 解凍 。

  3. Builder 模式

    幸運的是,這裡還有一種方法 Builder 模式,兼顧 重疊構造器 的安全以及 Javabean模式 的可讀性。

    客戶端先通過呼叫構造器或者靜態工廠方法,傳入必須的引數,獲得一個 builder 物件,代替直接獲取目標物件。然後客戶端在該 builder 物件上呼叫 setXXX 方法,為每一個感興趣的可選屬性賦值,最後客戶端呼叫一個 無參構造器 生成最終的目標物件,該物件一般是不可變的。其中 Builder 類是目標類的靜態內部類

    示例程式碼:

        // 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 類為不可變類,類的成員變數全部被 final 修飾,引數的預設值被放在一個地方。BuildersetXXX 方法返回 Builder 本身,這種寫法,可以將設定變成一個鏈,一直點下去(fluent APIs):

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

    這樣的客戶端程式碼,容易編寫,更容易閱讀。

    示例程式碼中,為了簡潔,省去了有效性的檢查。一般,為了儘快的檢查到非法引數,我們在 builder 的構造器和方法中,對其引數進行檢查。

    還需要檢查 build 方法中呼叫的構造器的多個不可變引數2。這次檢查延遲到 object 中,為了確保這些不可變引數不受到攻擊,在 builder 將屬性複製到 object 中的時候,再做一次檢查。如果檢驗失敗,則丟擲 IllegalArgumentException 異常,異常資訊中提示哪些引數不合法。

    Bulider 模式很適合類的層次結構。可以使用一個 builder 的平行結構,即每一個 builder 巢狀在一個對應的類中,抽象類中有抽象的 builder ,具體類中有具體的 builder 。像下面的程式碼所示:

        // Builder pattern for class hierarchies
        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
            }
        }
    
        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;
            }
        }
    
        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;
            }
        }

    注意,這裡的 Pizza.Builder 是類屬性,被 static 修飾的,並且泛型引數,是一個 遞迴 的泛型引數,繼承本身。和返回自身的抽象方法 self ,搭配一起,可以鏈式的呼叫下去,不需要進行型別的轉換,這樣做的原因是,java 不直接支援 自型別 3,可以模擬自型別 4

    如果不使用模擬自型別的話,呼叫 addTopping方法,返回的其實就是抽象類中的 Builder ,這樣就導致無法呼叫子類擴充套件方法,無法使用 fluent APIS。其中 build 方法,使用了 1.5 新增的 協變型別 ,它可以不用 cast 轉換,就直接使用具體的型別,否則子類接收父類,是需要強轉的 。

    builder 模式另外一個小優點:builder 可以有多個 可變引數,因為,可以將多個可變引數,放到各自對應的方法中5。另外 build 可以將多個引數合併到一個欄位上,就如上面程式碼中 addTopping 的那樣6

    builder 模式是非常靈活的。一個單一的 builder 多次呼叫,可以創建出不同的物件7builder 的引數,可以在呼叫 build 方法的時候進行細微調整,以便修改創建出的物件8builder 模式還可以自動的填充 object域的欄位在建立物件的時候。比如為每個新建立的物件設定編號,只需要在 builder 中維護一個類變數即可。

    builder 模式也是有缺點的。為了建立一個物件,我們首先需要建立它的 builder 物件。雖然,建立 builder 物件的開銷,在實踐中不是很明顯,但是在對效能要求很嚴格的場景下,這種開銷能會成為一個問題。同時,builder 模式是非常冗雜的,對於比 重疊構造器 ,所以,builder 模式應該僅僅被用在構造器引數足夠多的情況下,比如三個、四個或者更多,只有這樣,使用 builder 模式才是值得的。但是,你要時刻記住,類在將來可能會新增新的引數,如果你一開始使用了構造器或者靜態工廠方法,隨著類的變化,類的屬性引數變得足夠多,這時候你再切換到 builder 模式,那麼一開始的構造器和靜態工廠方法就會被廢棄,這些廢棄的方法看起來很凸出,你還不能刪除它們,需要儲存相容性。因此,一般一開始就選擇 builder 模式是一個不錯的選擇。

    總結,builder 模式是一個好的選擇,當設計一個類的時候,該類的構造器引數或者靜態工廠引數不止幾個引數,尤其是許多引數是可選的或者同一個型別(可變引數)。這樣設計的類,客戶端程式碼,與靜態工廠方法和重疊構造器比起來更加容易閱讀和編寫,和 Javabeans 模式比起來更加安全。



  1. 不一致的意思:正常物件的建立應該是一個完整的過程,這個過程控制在構造器中,可以看做是一個 原子性 的操作。它在物件創建出來以後,物件的各項屬性已經被正確的初始化。但是 Javabean 模式,天生的背棄了這個原則,它的建立物件,不是一個 原子性 的操作,在構造器執行完畢以後,還有一些列的屬性賦值,在這期間任何引用該物件的地方,都將獲得一個不正確的物件,直到物件建立完畢。可以參考下 JavaBean disadvantage - inconsistent during construction 這裡還提到了重複錯誤物件的建立。↩

  2. 我理解為構造器所在類的不可變屬性,在 builder 中的檢查類似於前臺頁面欄位的合法性檢查,最後後臺(Object)都要再次檢查一遍。↩

  3. 自型別 。在支援自型別的語言中,this 或者 self 的語義,誰呼叫該方法,則 this 代表誰。但是在 java 中,方法中的 this 指代的是定義該方法的型別,與呼叫無關,導致無法很好的使用 fluent API。可參考 java 的 self 型別。你可以驗證下,列印控制檯看,型別確實是呼叫它的型別,但是你等號左邊用這個型別去接收,會提示你發現父型別,不能賦值給子型別,不知道 java 在這裡面做了什麼。↩

  4. 模擬自型別,這裡在抽象類中,使用泛型指定,避免使用指定的型別,導致 this 被繫結為具體的。↩

  5. 構造器在建立物件的時候,構造器和普通方法一樣,只能接受一個 可變引數 。但是 builder 模式,可以多次呼叫不同的方法,新增 可變引數,直到所有的可變引數全部新增完畢,再 build 建立物件。↩

  6. 同樣的,構造器無法做的原因是,構造器一經呼叫,物件就會被建立,也就是建立物件的過程中,只可以呼叫一次構造器。但是 builder 模式可以多次呼叫方法,設定引數,直到最後全部新增完畢,呼叫 build 建立物件。↩

  7. 還是因為 builder 模式,只有在呼叫 build 方法,物件才會被建立,在建立之前,可以在呼叫 builder 模式的方法,修改引數,創建出不同的物件。 可以參考下 StackOverflow 的回答: A single builder can be used repeatedly to build multiple objects↩

  8. 還是因為 builder 模式,只有在呼叫 build 方法,物件才會被建立,在建立之前,可以在呼叫 builder 模式的方法,修改引數,創建出不同的物件。 可以參考下 StackOverflow 的回答: A single builder can be used repeatedly to build multiple objects↩