設計原則是指導我們程式碼設計的一些經驗總結,也就是“心法”;物件導向就是我們的“武器”;設計模式就是“招式”。
為什麼麥當勞那麼受歡迎?
表妹:哥啊,我想吃麥當勞
我:你為啥那麼喜歡吃麥當勞呢?
表妹:因為它好吃呀,而且每個門店吃的味道都差不多,不像某縣小吃,每個地方吃的味道都有區別。
我:那你知道為什麼嘛?
表妹:因為麥當勞有一套非常完善的工作流程,每個門店都必須遵守這套規範...
這不就是我們設計模式中的【建造者模式】嘛?
將一個複雜物件的構造與它的表示分離,使同樣的構建過程可以建立不同的表示。
我們以煲湯為例子,我們知道,主湯料和水是煲湯必須的,配料是可放可不放的,有些人喜歡吃原汁原味,可以不放鹽,有些肉比較油,那麼,也可以不放油。
我們來看看具體的程式碼實現:
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(),而建造者模式建立的則是一個複合產品,它由各個部件複合而成,部件不同產品物件當然不同。這不是說工廠方法模式建立的物件簡單,而是指它們的粒度大小不同。一般來說,工廠方法模式的物件粒度比較粗,建造者模式的產品物件粒度比較細。
建造者模式的應用場景
當需要建立的產品具備複雜建立過程時,可以抽取出共性建立過程,然後交由具體實現類自定義建立流程,使得同樣的建立行為可以生產出不同的產品,分離了建立與表示,使建立產品的靈活性大大增加。
建造者模式主要適用於以下場景:
-
相同的方法,不同的執行順序,產生不同的結果。
-
多個部件或零件,都可以裝配到一個物件中,但是產生的結果又不同。
-
產品類非常複雜,或者產品類中不同的呼叫順序產生不同的作用。
-
初始化一個物件比較複雜,引數多,而且很多引數都具有預設值。
總結
建造者模式用來建立複雜物件,可以通過設定不同的可選引數,“定製化”地建立不同的物件。
參考
極客時間專欄《設計模式之美》