Joshua Bloch錯了? ——適當改變你的Builder模式實現
注:這一系列都是小品文。它們偏重的并不是如何實現模式,而是一系列在模式實現,使用等眾多方面絕對值得思考的問題。如果您僅僅希望知道一個模式該如何實現,那么整個系列都會讓您失望。如果您希望更深入地了解各個模式的常用法,并對各個模式進行深入地思考,那么希望您能喜歡這一系列文章。
在昏黃的燈光下,我開始了晚間閱讀。之所以有這個習慣的主要原因還是因為我的睡眠一直不是很好。所以我逐漸養成了在晚九點以后看一會兒技術書籍以輔助睡眠的習慣。
今天隨手拿起的是Effective Java的英文第二版。說實話,由于已經看過了Effective Java的第一版,因此我一直沒有將它的第二版放在心上。
這是Builder么?
在看到第二個條目的時候,我就產生了一個大大的疑惑。該條目說如果一個構造函數或工廠模式擁有太多的可選參數,那么Builder模式是一個很好的選擇。但是該條目所給出的Builder模式實現卻非常奇怪(Java代碼):
// JAVA代碼 // 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 carbohydrate = 0; private int sodium = 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 carbohydrate(int val) { carbohydrate = val; return this; } public Builder sodium(int val) { sodium = 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; } }
或許您腦中的疑問和我一樣,這是Builder么?
標準的Builder實現
既然有了這個疑問,我就開始在腦中回憶起Builder模式標準實現的類圖:

在該類圖中主要有兩部分組成:Director以及Builder。Director用來制定產品的創建步驟,而Builder則用來為Director提供產品的各個組件。而在這兩部分組成中,Director表示的是產品組裝步驟,是Builder模式中的不變。而Builder類則是一個基類。各個ConcreteBuilder從它派生并定義組成產品的各個組成,是Builder模式中變化的部分,也是Builder模式中可以擴展的部分。
因此,其標準實現應如下所示:
// C++代碼 #include <iostream> using namespace std; class Builder; class Director; class Product; class ConcreteBuilder; // Builder的公共接口,提供接口給Director以允許調用Builder類的各成員以控制流程 class Builder { // 由于各個build*函數需要按照一定次序調用才能成功地創建產品,因此為了避免 // 由于外部誤調用而影響狀態機,因此將Builder的各個build*函數設置為私有 // 并聲明Director為其友元類 friend class Director; private: // Builder中的各個build*函數一般無返回值。這是因為每次build*的結果實際上 // 與所創建的產品相關。如果將其作為返回值返回,那么就會強制要求所有的 // ConcreteBuilder返回同一類型數據,而且Director也需要知道并使用這些數據, // 進而造成了Director,Builder以及產品之間的耦合 virtual void buildPartA() = 0; virtual void buildPartB() = 0; public: virtual Product* GetResult() = 0; }; // 控制產品的創建流程,是Builder模式中的不變 class Director { Builder* m_pBuilder; public: Director(Builder* pBuilder) { m_pBuilder = pBuilder; } // 啟動Builder模式的產品創建流程,而具體創建方式則由Builder類自行決定 void Construct() { m_pBuilder->buildPartA(); m_pBuilder->buildPartB(); } }; class Product { // 由于產品的創建都是通過ConcreteBuilder來完成的,因此聲明產品類的各個 // 成員為私有,并聲明ConcreteBuilder為其友元,從而達到只允許通過 // ConcreteBuilder創建產品實例的目的 friend class ConcreteBuilder; private: struct PartA {}; struct PartB {}; // 傳入指針,而不是引用,以允許某些part為空的情況 Product(PartA* pPartA, PartB* pPartB) { …… } public: void printInfo(); }; // Builder的實際實現 class ConcreteBuilder : public Builder { Product::PartA* m_pPartA; Product::PartB* m_pPartB; private: // 重寫私有虛函數以提供實際的組成的實際創建邏輯。私有并不會阻止虛函數的 // 調用及重寫。這是兩個完全不相干的特性,彼此不會相互影響,也不會由于私有 // 函數無法被派生類訪問而無法被重寫 virtual void buildPartA(); virtual void buildPartB(); public: virtual Product* GetResult(); }; void ConcreteBuilder::buildPartA() { m_pPartA = new Product::PartA(); }; void ConcreteBuilder::buildPartB() { m_pPartB = new Product::PartB(); }; Product* ConcreteBuilder::GetResult() { return new Product(m_pPartA, m_pPartB); }; void Product::printInfo() { cout << "Product constructed by builder pattern." << endl; }; int _tmain(int argc, _TCHAR* argv[]) { // 創建Builder及Director,并通過調用Director的Construct()函數來創建實例 Builder* pBuilder = new ConcreteBuilder(); Director* pDirector = new Director(pBuilder); pDirector->Construct(); // 通過調用Builder的GetResult()函數得到產品實例 Product* pProduct = pBuilder->GetResult(); pProduct->printInfo(); return 0; }
Joshua沒有錯
“標準實現和Joshua所提供的Builder模式實現竟然有如此大的差別,難道是Joshua錯了嗎?”我躺在床上想到。仔細地查看了Joshua所提供的Builder模式實現,發現其和標準的Builder模式有以下一系列不同:
- 沒有Director類,對產品的創建是通過Builder的build()函數來完成的。
- 沒有基類Builder,而每個ConcreteBuilder都被實現為產品的嵌套類。
那省略掉的這兩個組成在Builder模式中都是用來做什么的呢?在Builder模式中,Director用來表示一個產品的固定的創建步驟,它操作的是基類Builder所定義的接口。該接口定義了Director和各個ConcreteBuilder進行溝通的契約,而各個ConcreteBuilder都需要按照這些接口來組織自己的產品創建邏輯。
也就是說,Director和各個Builder之間的關系實際上就是對產品創建這一個任務執行開閉原則(Open-Close Principle)所產生的結果:Director和基類Builder定義了產品創建的“閉”,即固定的不應被修改的邏輯。而各個ConcreteBuilder則通過從基類Builder派生來自行定義產品中的各個組成的創建邏輯,也即是Builder模式中的“開”。這樣Director中所定義的產品創建步驟可以被各個產品的創建過程重用了。
而對Director和基類Builder的省略實際上就是將Builder中固定的產品創建步驟省略了,剩下的僅僅是開放的用來創建產品的實際邏輯。這實際上就是Builder模式中產品創建步驟退化所產生的效果。
“既然Builder模式已經退化成了單個的彼此不再相關的類,那它還叫Builder模式么?”我問自己。顯然,從開閉原則的角度來解釋僅僅能說明這種使用方法可以被認為是從Builder模式演化過來的,卻不能說服我這是一個Builder模式。
我再次拿起了書,想從書中尋找一些線索。在讀到這節中間的時候,我便有了答案。該條目所說的實際上是在利用Builder模式中各個ConcreteBuilder的一個特性:如果將Builder中的各個ConcreteBuilder當作是一個Context,那么其將在可選值方面提供較大的靈活度。
所有的一切都是從一個非常復雜的構造函數開始說起的。如果創建一個對象需要向構造函數中傳入非常多的參數,而且有些參數是可選的,那么為了使用方便,我們需要提供一個包含了所有參數的構造函數:
// Java代碼 public class NutritionFacts { public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) { …… } }
在這種情況下,我們就需要按照如下的方法對該構造函數進行調用:
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
但這種方法對可選的營養成分而言并不友好。因此另一種選擇被提出了,那就是JavaBean模式:
// Java代碼 public class NutritionFacts { public void setServingSize(int servingSize) …... public void setServings(int servings) …… public void setCalories(int calories) …… …… }
但這種解決方案還是有問題,那就是各個參數之間的關聯關系。例如食物中所有的卡路里實際上是與該食物的重量以及單位重量中所包含的卡路里相關的。因此我們還需要在setCalories(),setServings()以及setServingSize()中執行輸入數據是否正確的檢查。而這些檢查需要放在哪里呢?setCalories()等函數中?那么這些檢查邏輯需要考慮到calories,servings以及servingSize等參數還沒有被設置的情況,而且每次對這些數據的更改都會導致該檢查的執行。
Joshua提出的解決方案則是Builder模式。該方案所利用的就是Builder模式中的ConcreteBuilder可以很好地處理可選組成并支持數據檢查的特性:
// Java代碼 NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) .calories(100) .sodium(35) .carbohydrate(27) .build();
上面的代碼主要分為三個部分:對NutritionFacts.Builder的創建,通過.calories()等函數對可選組成的設置,以及通過.build()函數創建NutritionFacts實例。其中在創建NutritionFacts.Builder時我們需要為該類型的構造函數指定構造函數所需要的參數,實際上也就是在指定各個必選組成。接下來,我們就可以根據需要調用.calories()等函數完成對可選參數的設置。這兩部分代碼實際上就是在對各個必選組成和可選組成進行處理。而最后對.build()函數的調用則用來創建NutritionFacts實例,也是在該解決方案中執行各設置檢查的地方。
簡單地說,在Builder模式中,ConcreteBuilder具有如下兩個特點:
- 非常適合處理一個實例具有一系列可選組成的情況
- 可以在創建產品實例前執行額外的自定義邏輯
這些特點實際上在Gang of Four的設計模式一書中并沒有被顯式提及,而Joshua卻對這些特征好好地加以了利用。
“啊”,我恍然大悟。實際上并不是Joshua不知道一個標準的Builder模式是如何實現的。只是因為這個條目中所需要處理的情況實際上可以通過Builder模式中的ConcreteBuilder一個組成就能夠解決這種問題,因此他提供了一個簡化的,或者說是退化的Builder模式實現,從而更清楚地表明自己的想法。反過來,如果各個產品的創建步驟相同,我們仍然可以很容易地抽象出一個基類Builder,并為公有的創建步驟添加相應的Director。
Fluent Interface
但是Joshua給出的Builder模式中,另一處實現引起了我的注意。在Builder類中,他使用了Fluent Interface模式:
// Java代碼 public Builder sodium(int val) { sodium = val; return this; }
這是在Martin Fowler的一篇文章中所列出的一種模式。該模式的最大優點就是大大地提高了代碼的可讀性。在一個標準的Fluent Interface模式實現的幫助下,軟件開發人員可以編寫出非常易懂的代碼。但是從Joshua給出的示例來看,似乎這種可讀性的提高并不明顯:
// Java代碼 NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) .calories(100).sodium(35).carbohydrate(27).build();
當其它軟件開發人員遇到該段代碼的時候,他立刻理解函數調用calories(),sodium(),carbohydrate()等函數的意義么?
“如果是我,我會使用一個’with-’前綴吧”,我想到:
// Java代碼 NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) .withCalories(100).withSodium(35).withCarbohydrate(27).build();
這樣這些函數中所使用的小小的前綴“with-”就能讓其他軟件開發人員在閱讀食品的營養成分時在腦中所形成相應的語義:這份營養成分表中有100卡路里,35毫克鈉,以及27克碳水化合物。
當然,這只是一部分人在使用Fluent Interface模式時一種常用的命名規范。由于我們在日常生活中所使用的語言則不僅僅有“XX包含什么”這種表述,更需要表達“在什么情況下”,“什么時候”等一系列條件。因此像“where-”,“when-”等前綴也是常常用到的。
當然,計算機語言和自然語言之間還是有一定的差距的。確切來說,是很大的差距。這種差距的根源主要是由于我們每天所說的語言很多時候都沒有編程語言那么嚴謹。因此在實現Fluent Interface模式的時候,要盡量平衡使用Fluent Interface模式組織代碼所帶來的額外負擔以及從Fluent Interface模式所帶來的可讀性以及可維護性的提高。
“拿使用Fluent Interface模式后有沒有什么損失呢?”我躺在床上自己問自己。由于Fluent Interface模式是使用在各個Builder之上的,因此首先我就開始思考它的擴展性是否會受到影響。
雖然說Fluent Interface模式并不要求返回的都是當前實例,但是在Builder模式中,Fluent Interface中的各個接口所返回的常常是Builder類實例自身:
// Java代碼 public Builder withSodium(int val) { sodium = val; return this; }
這顯示了Fluent Interface模式的另一個問題,那就是對派生并不友好。從上面的代碼可以看到,該函數所返回的是一個Builder類實例。如果我們希望從Builder類派生,那么對Builder類實例所提供的函數的調用就需要放到最后:
// Java代碼 AllNutritionFacts.Builder(240, 8).withTotalEnergy(1400) .withNote(“飲料內若有部分沉淀為果肉,并不影響飲用”) .withCalories(100).withSodium(35).withCarbohydrate(27).build();
這似乎就不太合常理了:由NutritionFacts.Builder類所提供的最主要營養成分竟然被放到了最后。這實際上并沒有提高什么可讀性,反而會使得其它軟件開發人員看到這段代碼時感到困惑。
當然,我們還可以嘗試利用C++中有關虛函數的一個特殊性質:如果一個派生類中重寫了基類中的虛函數,那么該虛函數的返回值可以適當發生改變。例如在基類中的虛函數返回基類指針或引用的時候,派生類中的相應的虛函數可以返回派生類的指針或引用。
class Base { public: // 基類中定義一個虛函數,返回類型是Base類的引用 virtual Base& self() { return *this; } }; class Derive : public Base { public: // 派生類中重寫虛函數,返回類型是Derive類的引用 virtual Derive& self() { return *this; } };
這樣,我們可以通過重寫基類中的虛函數,使其返回派生類實例來部分解決Fluent Interface模式對派生不友好的情況。這種技術也被稱為Covariant Return Type。
另一種解決方案就是盡量使用組合,而不是派生。也就是說,如果Builder模式中的產品類可以由組合來完成,而不是派生,那么它就可以通過各個組成的Builder 來完成對各個組成的生產,再通過自身的Builder來產生最后的產品:
// Java代碼 Benz.Builder() .withBody(BenzBody.Builder() .withColor() .withDoorCount() .build()) .withEngine(Engine.Builder() .withPower() .build()) .withWheel(Wheel.Builder() .withSize() .build()) .build();
這樣,各個子組成通過定義自己的Builder一方面可以提高重用性,另一方面也可以通過組合的方式避免使用繼承,進而在按照Fluent Interface組織接口時遇到麻煩。
同系列其它文章:http://www.rzrgm.cn/loveis715/category/672735.html
轉載請注明原文地址并標明轉載:http://www.rzrgm.cn/loveis715/p/4539505.html
商業轉載請事先與我聯系:silverfox715@sina.com

浙公網安備 33010602011771號