設計模式之【建造者模式】

Gopher大威發表於2022-03-06

設計原則是指導我們程式碼設計的一些經驗總結,也就是“心法”;物件導向就是我們的“武器”;設計模式就是“招式”。

以心法為基礎,以武器運用招式應對複雜的程式設計問題。

為什麼麥當勞那麼受歡迎?

表妹:哥啊,我想吃麥當勞

我:你為啥那麼喜歡吃麥當勞呢?

表妹:因為它好吃呀,而且每個門店吃的味道都差不多,不像某縣小吃,每個地方吃的味道都有區別。

我:那你知道為什麼嘛?

表妹:因為麥當勞有一套非常完善的工作流程,每個門店都必須遵守這套規範...

這不就是我們設計模式中的【建造者模式】嘛?


將一個複雜物件的構造與它的表示分離,使同樣的構建過程可以建立不同的表示。

我們以煲湯為例子,我們知道,主湯料和水是煲湯必須的,配料是可放可不放的,有些人喜歡吃原汁原味,可以不放鹽,有些肉比較油,那麼,也可以不放油。

我們來看看具體的程式碼實現:

 1 public class Soup {
 2     private static final int MAX_INGREDIENTS = 10;
 3     private static final int MAX_OIL = 8;
 4     private static final int MAX_SALT = 5;
 5     
 6     private String mainIngredient;  // 主料必須
 7     private String water;           // 水必須
 8     private int ingredients;        // 配料可選
 9     private int oil;                // 油可選
10     private int salt;               // 鹽可選
11     
12     public Soup(String main, String water, Integer ingredients, Integer oil, Integer salt) {
13         if (StringUtils.isBlank(main)) {
14             throw new IllegalArgumentException("main should not be empty");
15         }
16         this.mainIngredient = main;
17     
18         if (StringUtils.isBlank(water)) {
19             throw new IllegalArgumentException("water should not be empty");
20         }
21         this.water = water;
22     
23         if (ingredients != null) {
24             if (ingredients < 0) {
25                 throw new IllegalArgumentException("ingredients should not be positive");
26             }
27             this.ingredients = ingredients;
28         }
29         
30         if (oil != null) {
31             if (oil < 0) {
32                 throw new IllegalArgumentException("oil should not be positive");
33             }
34             this.oil = oil;
35         }
36         
37         if (salt != null) {
38             if (salt < 0) {
39                 throw new IllegalArgumentException("salt should not be positive");
40             }
41             this.salt = salt;
42         }
43     }
44     
45     // 省略get方法
46 }
47 48 // 今天想吃魚頭豆腐湯
49 Soup fishHeadTofuSoup = new Soup("魚頭", "山泉水", 10, 6, 3);

大家可以看到,這個建構函式有5個引數,引數列表太長,導致程式碼在可讀性和易用性上都會變差。

而且這麼長的引數列表,引數型別一樣的都連在一起,在使用建構函式的時候,就很容易搞錯各引數的順序,傳遞進錯誤的引數值,導致非常隱蔽的bug。上面這個例子中,配料和鹽都是int型別,萬一我一不小心,把這兩個引數的位置互換了,變成放3克的配料,放10克的鹽,那還能吃嘛?

有些同學可能會說,你這個類在構造物件的時候,有些屬性是可選可不選的,這些屬性可以通過set()函式來設定。

是的,我們來看一下,這種方法的實現效果:

 1 public class Soup {
 2     private static final int MAX_INGREDIENTS = 10;
 3     private static final int MAX_OIL = 8;
 4     private static final int MAX_SALT = 5;
 5     
 6     private String mainIngredient;  // 主料必須
 7     private String water;           // 水必須
 8     private int ingredients;        // 配料可選
 9     private int oil;                // 油可選
10     private int salt;               // 鹽可選
11     
12     public Soup(String main, String water) {
13         if (StringUtils.isBlank(main)) {
14             throw new IllegalArgumentException("main should not be empty");
15         }
16         this.mainIngredient = main;
17     
18         if (StringUtils.isBlank(water)) {
19             throw new IllegalArgumentException("water should not be empty");
20         }
21         this.water = water;
22     }
23     
24     public void setIngredients(int ingredients) {
25         if (ingredients != null) {
26             if (ingredients < 0) {
27                 throw new IllegalArgumentException("ingredients should not be positive");
28             }
29             this.ingredients = ingredients;
30         }
31     }
32      
33     public void setOil(int oil) {
34         if (oil != null) {
35             if (oil < 0) {
36                 throw new IllegalArgumentException("oil should not be positive");
37             }
38             this.oil = oil;
39         }
40     }
41     
42     public void setSalt(int salt) {
43         if (salt != null) {
44             if (salt < 0) {
45                 throw new IllegalArgumentException("salt should not be positive");
46             }
47             this.salt = salt;
48         }
49     }
50     
51     // 省略get方法
52 }
53 54 // 今天想吃菌菇烏雞湯
55 Soup blackChickenSoup = new Soup("烏雞", "水");
56 blackChickenSoup.setIngredients(8);
57 blackChickenSoup.setOil(5);
58 blackChickenSoup.setSalt(3);

這樣一看,確實建構函式的引數列表變短了,也能夠煲出美味的湯。但是,還是存在下面幾個問題:

1、上面的例子中,只有兩個必填的屬性,但是如果必填的屬性有很多呢?把這些必填屬性都放到建構函式中設定,那豈不是又是一個很長的引數列表呢?

2、如果我們把必填屬性也通過set()方法設定,那校驗這些必填屬性是否已經填寫的邏輯就無處安放了。

3、如果屬性之間有一定的依賴關係,比如,如果煲湯者放了配料ingredients,那麼就一定要放鹽salt;或者屬性之間有一定的約束條件,比如,配料的克數要大於鹽的克數。如果我們繼續使用現在的設計思路,那這些屬性之間的依賴關係或者約束關係的校驗邏輯就無處安放了。

4、如果我們希望Soup類物件是不可變物件,也就是說,物件在建立好之後,就不能再修改內部的屬性值了。那麼,我們就不能在類中暴露set()方法了。

5、如果這些屬性不是一起初始化的話,就會導致物件的無效狀態。何為無效狀態呢?一起看看下面的程式碼:

1 Rectangle r = new Rectangle();  // 此時r是無效物件,因為長方形必須有長寬值。
2 r.setWidth(2);  // 此時r還是無效狀態
3 r.setHeight(4); // 此時的r設定好了長寬值,才是有效狀態

這時候,建造者模式就派上用場啦~

建造者模式

1、在Soup類中建立一個靜態內部類Builder,然後將Soup類中的引數都複製到Builder類中。

2、在Soup類中建立一個private的建構函式,引數為Builder型別。

3、在Builder中建立一個public的建構函式,引數為Soup中必填的引數,mainIngredient和water。

4、在Builder中建立set()方法,對Soup中那些可選引數進行賦值,返回值為Builder型別的例項。

5、在Builder中建立一個build()方法,在其中建立Soup的例項並返回。

  1 public class Soup {
  2     private String mainIngredient;
  3     private String water;
  4     private int ingredient;
  5     private int oil;
  6     private int salt;
  7     
  8     private Soup(Builder builder) {
  9         this.mainIngredient = builder.mainIgredient;
 10         this.water = builder.water;
 11         this.ingredients = builder.ingredients;
 12         this.oil = builder.oil;
 13         this.salt = builder.salt;
 14     }
 15     // 省略get方法
 16     
 17     // 將Builder類設計成Soup的內部類
 18     // 也可以將BUilder類設計成獨立的非內部類SoupBuilder
 19     public static class Builder {
 20         private static final int MAX_INGREDIENTS = 10;
 21         private static final int MAX_OIL = 8;
 22         private static final int MAX_SALT = 5;
 23         
 24         private String mainIngredient;
 25         private String water;
 26         private int ingredient;
 27         private int oil;
 28         private int salt;
 29         
 30         public Soup build() {
 31             // 校驗邏輯放到這裡做,包括必填項校驗,依賴關係校驗,約束條件校驗等
 32             // 主料必填
 33             if (StringUtils.isBlank(mainIngredient)) {
 34                 throw new IllegalArgumentException("...");
 35             }
 36             // 水必填
 37             if (StringUtils.isBlank(water)) {
 38                 throw new IllegalArgumentException("...");
 39             }
 40             // 依賴關係:如果放了配料,就一定要放鹽
 41             if (ingredients > 0 && salt <= 0) {
 42                 throw new IllegalArgumentException("...");
 43             }
 44             // 約束條件:配料的克數不能小於等於鹽的克數
 45             if (ingredients <= salt) {
 46                 throw new IllegalArgumentException("...");
 47             }
 48             
 49             return new Soup(this);
 50         }
 51         
 52         public Builder setMainIngredients(String mainIngredient) {
 53             if (StringUtils.isBlank(mainIngredient)) {
 54                 throw new IllegalArgumentException("...");
 55             }
 56             this.mainIngredient = mainIngredient;
 57             return this;
 58         }
 59  
 60         public Builder setWater(String water) {
 61             if (StringUtils.isBlank(water)) {
 62                 throw new IllegalArgumentException("...");
 63             }
 64             this.water = water;
 65             return this;
 66         }
 67         
 68         public Builder setIngredients(int ingredients) {
 69             if (ingredients != null) {
 70                 if (ingredients < 0) {
 71                     throw new IllegalArgumentException("ingredients should not be positive");
 72                 }
 73                 this.ingredients = ingredients;
 74             }
 75             return this;
 76         }
 77      
 78         public Builder setOil(int oil) {
 79             if (oil != null) {
 80                 if (oil < 0) {
 81                     throw new IllegalArgumentException("oil should not be positive");
 82                 }
 83                 this.oil = oil;
 84             }
 85             return this;
 86         }
 87  88         public Builder setSalt(int salt) {
 89             if (salt != null) {
 90                 if (salt < 0) {
 91                     throw new IllegalArgumentException("salt should not be positive");
 92                 }
 93                 this.salt = salt;
 94             }
 95             return this;
 96         }
 97     }
 98 }
 99 100 // 今天吃冬瓜排骨湯
101 Soup winterMelonRibSoup = new Soup.Builder()
102     .setMainIngredients("排骨")
103     .setWater("山泉水")
104     .setIngredients(8)   // 冬瓜8克
105     .setOil(2)
106     .setSalt(3)
107     .builder();

你看,這樣設計的話,上面的5個問題都解決了。

大家發現沒有,一份美味的湯是由主料、水、配料、油和鹽多個簡單的物件組成的,然後一步一步構建而成。而建造者模式將變與不變分離,即湯的組成部分是不變的,但每一部分是可以靈活選擇的。

像上面煲的冬瓜排骨湯,如果我忘記放油了:

1 Soup winterMelonRibSoup = new Soup.Builder()
2     .setMainIngredients("排骨")   // 第一步
3     .setWater("山泉水")
4     .setIngredients(8)   // 冬瓜8克
5     // .setOil(2)        // 不放油
6     .setSalt(3)       
7     .builder();

這樣並不會導致狀態的無效狀態,沒有顯示設定會自動初始化為預設值。那麼,煲出來的味道就不一樣了。

可能有人會問,上面定義說“將一個複雜物件的構建與其表示分離,使得同樣的構建過程可以建立不同的表示”,但你這也沒有實現將構建與表示分離,而且是不同的構建過程建立出不同的表示。

是的,同樣的構建過程就相當於麥當勞規範的製作流程,如果按照這套構建過程來構建物件,就不會忘記“放油”了。如下圖所示:

 

其實,上面是Builder在Java中一種簡化的使用方式,經典的Builder模式還是有點不同的。

經典Builder模式

它有4個角色:

  • Product:最終要生成的物件,例如Soup例項。

  • Builder:建設者的抽象基類(有時會使用介面代替)。其定義了構建Product的抽象步驟,其實體類需要實現這些步驟。它會包含一個用來返回最終產品的方法Product getProduct()。

  • ConcreteBuilder:Builder的實現類。

  • Director:決定如何構建最終產品的步驟,其會包含一個負責組裝的方法void Construct(Builder builder),在這個方法中通過呼叫builder的方法,就可以設定builder,等設定完成後,就可以通過builder的getProduct()方法獲得最終的產品。

接下來我們看一下具體的程式碼實現:

第一步:我們的目標Soup類:

 1 public class Soup {
 2     private static final int MAX_INGREDIENTS = 10;
 3     private static final int MAX_OIL = 8;
 4     private static final int MAX_SALT = 5;
 5     
 6     private String mainIngredient;  // 主料必須
 7     private String water;           // 水必須
 8     private int ingredients;        // 配料可選
 9     private int oil;                // 油可選
10     private int salt;               // 鹽可選
11     
12     public Soup(String main, String water) {
13         if (StringUtils.isBlank(main)) {
14             throw new IllegalArgumentException("main should not be empty");
15         }
16         this.mainIngredient = main;
17     
18         if (StringUtils.isBlank(water)) {
19             throw new IllegalArgumentException("water should not be empty");
20         }
21         this.water = water;
22     }
23     
24     public void setIngredients(int ingredients) {
25         if (ingredients != null) {
26             if (ingredients < 0) {
27                 throw new IllegalArgumentException("ingredients should not be positive");
28             }
29             this.ingredients = ingredients;
30         }
31     }
32      
33     public void setOil(int oil) {
34         if (oil != null) {
35             if (oil < 0) {
36                 throw new IllegalArgumentException("oil should not be positive");
37             }
38             this.oil = oil;
39         }
40     }
41     
42     public void setSalt(int salt) {
43         if (salt != null) {
44             if (salt < 0) {
45                 throw new IllegalArgumentException("salt should not be positive");
46             }
47             this.salt = salt;
48         }
49     }
50     
51     // 省略get方法
52 }

第二步:抽象建造者類

1 public abstract class SoupBuilder {
2     public abstract void setIngredients();
3     public abstract void setSalt();
4     public abstract void setOil();
5     
6     public abstract Soup getSoup();
7 }

第三步:實體建造者類,我們可以根據要構建的產品種類產生多個實體建造者類,這裡我們構建兩種湯,魚頭豆腐湯和冬瓜排骨湯。所以,我們生成了兩個實體建造者類。

魚頭豆腐湯建造者類:

 1 public class FishHeadTofuSoupBuilder extends SoupBuilder {
 2     private Soup soup;
 3     public (String mainIngredient, String water) {
 4         soup = new Soup(mainIngredient, water);
 5     }
 6     @Override
 7     public void setIngredients() {
 8         soup.setIngredients(10);
 9     }
10     @Override 
11     public void setSalt() {
12         soup.setSalt(3);
13     }
14     @Override 
15     public void setOil() {
16         soup.setOil(4);
17     }
18     
19     @Override 
20     public Soup getSoup() {
21         return soup;
22     }
23 }

冬瓜排骨湯建造者類:

 1 public class WinterMelonRibSoupBuilder extends SoupBuilder {
 2     private Soup soup;
 3     public (String mainIngredient, String water) {
 4         soup = new Soup(mainIngredient, water);
 5     }
 6     @Override
 7     public void setIngredients() {
 8         soup.setIngredients(7);
 9     }
10     @Override 
11     public void setSalt() {
12         soup.setSalt(2);
13     }
14     @Override 
15     public void setOil() {
16         soup.setOil(3);
17     }
18     
19     @Override 
20     public Soup getSoup() {
21         return soup;
22     }
23 }

第四步:指導者類(Director)

1 public class SoupDirector {
2     public void makeSoup(SoupBuilder builder) {
3         // 一套規範的流程
4         builder.setIngredients();
5         builder.setSalt();
6         builder.setOil();
7     }
8 }

指導者類SoupDirector在Builder模式中具有很重要的作用,它用於指導具體構建者如何構建產品,控制呼叫先後順序。這也就是定義中所說的“使用同樣的構建過程”。

那麼,我們來看一下,客戶端如何構建物件呢?

 1 public static void main(String[] args) {
 2     SoupDirector director = new SoupDirector();
 3     SoupBuilder builder = new FishHeadTofuSoupBuilder("魚頭", "水");
 4     // 按照那套規範流程來煲湯
 5     director.makeSoup(builder);
 6     // 魚頭豆腐湯出鍋
 7     Soup fishHeadTofuSoup = builder.getSoup();
 8     
 9     // 現在我想喝冬瓜排骨湯
10     SoupBuilder winterMelonRibSoupBuilder = new WinterMelonRibSoupBuilder("排骨", "山泉水");
11     // 按照那套規範流程來煲湯
12     director.makeSoup(winterMelonRibSoupBuilder);
13     // 冬瓜排骨湯出鍋
14     Soup winterMelonRibSoup = winterMelonRibSoupBuilder.getSoup();
15 }

建造者模式的優點

  • 使用建造者模式可以使客戶端不必知道產品內部組成的細節。

  • 具體的建造者類之間是相互獨立的,這有利於系統的擴充套件。

  • 具體的建造者相互獨立,因此可以對建造的過程逐步細化,而不會對其他模組產生任何影響。

建造者模式的缺點

  • 建造者模式所建立的產品一般具有較多的共同點,其組成部分相似;如果產品之間的差異性很大,則不適合使用建造者模式,因此,其使用範圍受到一定的限制。

  • 如果產品的內部變化複雜,可能會導致需要定義很多具體建造者類來實現這種變化,導致系統變得很龐大,可維護性降低。

建造者模式與工廠模式的區別

通過前面的學習,我們一起來看看,建造者模式和工廠方法模式有什麼區別:

  • 意圖不一樣

在工廠方法模式中,我們關注的是一個產品整體,比如tank整體,無需關心tank的各個部分是如何建立出來的;但在建造者模式中,一個具體產品的產生,是依賴各個部件的產生以及裝配順序的,它關注的是“由零件一步一步地組裝出產品物件”。簡單地說,工廠模式是一個物件建立的粗線條應用,建造者模式則是通過細線條勾勒出一個複雜物件,關注的是產品組成部分的建立過程。

  • 產品的複雜度不同

工廠方法模式建立的產品一般都是單一性質的產品,比如tank,都具有一個方法attack(),而建造者模式建立的則是一個複合產品,它由各個部件複合而成,部件不同產品物件當然不同。這不是說工廠方法模式建立的物件簡單,而是指它們的粒度大小不同。一般來說,工廠方法模式的物件粒度比較粗,建造者模式的產品物件粒度比較細。

建造者模式的應用場景

當需要建立的產品具備複雜建立過程時,可以抽取出共性建立過程,然後交由具體實現類自定義建立流程,使得同樣的建立行為可以生產出不同的產品,分離了建立與表示,使建立產品的靈活性大大增加。

建造者模式主要適用於以下場景:

  • 相同的方法,不同的執行順序,產生不同的結果。

  • 多個部件或零件,都可以裝配到一個物件中,但是產生的結果又不同。

  • 產品類非常複雜,或者產品類中不同的呼叫順序產生不同的作用。

  • 初始化一個物件比較複雜,引數多,而且很多引數都具有預設值。

總結

建造者模式用來建立複雜物件,可以通過設定不同的可選引數,“定製化”地建立不同的物件。

參考

極客時間專欄《設計模式之美》

https://www.cnblogs.com/ChinaHook/p/7471470.html

https://zhuanlan.zhihu.com/p/58093669

相關文章