今天來說說 Builder 建造者模式
我們來看看原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Builder is a creational design pattern that lets you construct complex objects step by step. The pattern allows you to produce different types and representations of an object using the same construction code.
可以解釋成
抑或是
當一個物件太複雜、參數太多時,傳統建構方式會讓程式碼變得難以維護與容易出錯
所以主要是要解決
你是不是在想,每個字我都知道,怎麼湊在一起我就不知道了...沒關係朋友我也是...
用簡單個例子來說明,我很喜歡吃漢堡,那就拿漢堡來舉例
public class Burger
{
public string Bread { get; }
public string Patty { get; }
public bool HasCheese { get; }
public bool HasBacon { get; }
public bool HasLettuce { get; }
public bool HasTomato { get; }
public Burger(string bread, string patty, bool hasCheese, bool hasBacon, bool hasLettuce, bool hasTomato)
{
Bread = bread;
Patty = patty;
HasCheese = hasCheese;
HasBacon = hasBacon;
HasLettuce = hasLettuce;
HasTomato = hasTomato;
}
}
接下來我想要點一個漢堡,麻煩就來了
// 我想點一個「只要加起司和生菜」的漢堡
var myBurger = new Burger("麵包", "牛肉", true, false, true, false);
這時候帶來三個痛點
恨不恨? 討不討厭?
那接下來,我們使用 Builder 進行簡化
我們"額外"在做一個 BurgerBuilder,讓它專門負責「幫忙點餐」:
public class BurgerBuilder
{
// 必要項目
public string Bread { get; } // 麵包
public string Patty { get; } // 漢堡徘
// 選填項目(預設都是 false)
public bool HasCheese { get; private set; }
public bool HasBacon { get; private set; }
public bool HasLettuce { get; private set; }
public bool HasTomato { get; private set; }
// 1. 建構子只放「一定要有」的東西
public BurgerBuilder(string bread, string patty)
{
Bread = bread; // 麵包
Patty = patty; // 漢堡排
}
// 2. 選填項目:要加什麼就呼叫對應的方法,每次都 return this
public BurgerBuilder AddCheese()
{
HasCheese = true;
return this; // 這裡 this 代表整個 BurgerBuilder 實體
}
public BurgerBuilder AddBacon()
{
HasBacon = true;
return this;
}
public BurgerBuilder AddTomato()
{
HasTomato = true;
return this;
}
// 3. 最後送出訂單,產出做好的漢堡
public Burger Build()
{
return new Burger(Bread, Patty, HasCheese, HasBacon, HasLettuce, HasTomato);
}
}
透過 builder 之後,那我們該怎麼再點相同漢堡呢?(只要起司跟番茄)
var myBurger = new BurgerBuilder("麵包", "牛肉")
.AddCheese()
.AddTomato()
.Build();
回到我們最初的 Builder 定義
是不是慢慢理解,Builder 改變了什麼?
不需要傳一堆 false: 沒點的東西就不用寫
可讀性提高: 看這行程式碼,想必都能理解我點了「麵包、牛肉、加起司、加番茄」
順序隨便你點: 先寫 .AddTomato() 再寫 .AddCheese() 結果完全一樣,不再被參數順序綁死
這裡就會有 class Burger(配方) 跟 class BurgerBuilder(服務生)
透過跟服務生點餐,所以也可以客製化(點飲料最愛客製了)
如果團隊更嚴謹的話,會直接將 Builder 寫在 Burger 裡面,Burger 建構子用 private 去處理
只能透過 public Burger Build() 去生成物件,外部沒辦法擅自生成 Burger
改用 var myBurger = new Burger.Builder("芝麻麵包", "牛肉")....呼叫
好多了嗎朋友,那我們帶這著感覺回到柴咖啡
前兩天用 Factory Method 跟 Abstract Factory,解決的都是「該生出哪一種(或哪一組)產品」的問題
也陸續解決小黑的問題,但隨著來客數提高,相對客人的問題也會複雜化
「半糖就好」、「去冰喔」、「可以換燕麥奶嗎」、「去冰微微」...
同一杯拿鐵,客製化選項一多,麻煩的已經不是「要生哪一種飲料」,而是「這一杯飲料,內部細節到底該怎麼一步步組合出來」
阿柴打開程式碼
public class Latte
{
public string Size { get; }
public string IceLevel { get; }
public string SugarLevel { get; }
public string MilkType { get; }
// 是不是跟漢堡一樣熟悉
public Latte(string size, string iceLevel, string sugarLevel, string milkType)
{
Size = size;
IceLevel = iceLevel;
SugarLevel = sugarLevel;
MilkType = milkType;
}
}
呼叫端要點一杯「大杯、少冰、半糖」,其他用預設就好,但建構子只有一種版本,四個參數一個都不能少
var latte = new Latte("大杯", "少冰", "半糖", "鮮奶");
小黑後來反應:「很多客人不在乎鮮奶的種類,幹嘛每次都要打鮮奶」
阿柴沒想太多,於是又加了幾個建構子,讓沒特別交代的選項可以吃預設值
public Latte(string size, string iceLevel, string sugarLevel)
: this(size, iceLevel, sugarLevel, "鮮奶") { }
public Latte(string size, string iceLevel)
: this(size, iceLevel, "全糖", "鮮奶") { }
public Latte(string size)
: this(size, "正常冰", "全糖", "鮮奶") { }
這種「一個建構子疊一個建構子」的寫法,通常叫做telescoping constructor
阿柴光看就覺得讀起來不太容易,先不論之後選項只會越加越多,建構子的排列組合永遠補不完對吧
細心的你也會發現,iceLevel、sugarLevel、milkType 全部都是 string,型別上也無法分辨誰是誰
這時候,客人點了一杯大杯拿鐵,半糖少冰
// 發現了嗎,順序放反了
var latte = new Latte("大杯", "半糖", "少冰");
// IceLevel 存進去的是「半糖」,SugarLevel 存進去的是「少冰」,欄位對調,但也看不出來
點餐的工讀生一忙、順序一背錯,錯的訂單就這樣被送進廚房,也沒有機制告訴他錯了(先單純討論這段的機制所帶來的風險)
阿柴這次使用 Fluent Builder 的寫法,讓每個設定方法都回傳自己(this),方便串接呼叫
他也發現,大小、甜度、冰塊、牛奶種類,本來只會有幾個固定選項
「有限選項」的情境,阿柴把 string 換成 enum,讓編譯器直接幫忙擋掉不合的內容
// ==========================================
// 0. 客製化選項改用 enum,而不是自由字串
// ==========================================
public enum CupSize { Medium, Large } // 中杯、大杯
public enum IceLevel { Normal, Less, None } // 正常冰、少冰、去冰
public enum SugarLevel { Full, Half, None } // 全糖、半糖、無糖
public enum MilkType { Regular, Oat } // 鮮奶、燕麥奶
// ==========================================
// 1. 產品層(Product):組好後就不再變動
// ==========================================
public class Latte
{
public CupSize Size { get; }
public IceLevel IceLevel { get; }
public SugarLevel SugarLevel { get; }
public MilkType MilkType { get; }
// 只有 Builder 會呼叫這個建構子,客戶端不再直接 new
public Latte(CupSize size, IceLevel iceLevel, SugarLevel sugarLevel, MilkType milkType)
{
Size = size;
IceLevel = iceLevel;
SugarLevel = sugarLevel;
MilkType = milkType;
}
public override string ToString()
=> $"{Size}拿鐵,{IceLevel}、{SugarLevel}、{MilkType}";
}
// ==========================================
// 2. 建造者(Builder):一步一步設定客製化選項
// ==========================================
public class LatteBuilder
{
private CupSize _size = CupSize.Medium; // 預設中杯
private IceLevel _iceLevel = IceLevel.Normal; // 預設正常冰
private SugarLevel _sugarLevel = SugarLevel.Full; // 預設正常甜
private MilkType _milkType = MilkType.Regular; // 預設鮮奶
public LatteBuilder SetSize(CupSize size) { _size = size; return this; }
public LatteBuilder SetIce(IceLevel iceLevel) { _iceLevel = iceLevel; return this; }
public LatteBuilder SetSugar(SugarLevel sugarLevel) { _sugarLevel = sugarLevel; return this; }
public LatteBuilder SetMilk(MilkType milkType) { _milkType = milkType; return this; }
// 統一在這組裝,也是集中放驗證邏輯的好地方
public Latte Build()
{
return new Latte(_size, _iceLevel, _sugarLevel, _milkType);
}
}
呼叫端長這樣
// 客人只想要「大杯、少冰、半糖」,方法名稱就是說明,不用記順序
var latte = new LatteBuilder()
.SetSize(CupSize.Large)
.SetIce(IceLevel.Less)
.SetSugar(SugarLevel.Half)
.Build();
Console.WriteLine(latte);
// 輸出:Large拿鐵,Less、Half、Regular
// 另一位客人要無糖,燕麥拿鐵
var oatLatte = new LatteBuilder()
.SetSugar(SugarLevel.None)
.SetMilk(MilkType.Oat)
.Build();
到這裡,我們再次回顧 Builder 的定義
是不是越來越熟悉了朋友
如果柴咖啡的客製化選項本來就只有一個,只有「要不要去冰」這種單一開關
使用 C# 的具名引數跟預設參數就綽綽有餘,不用多開一個 LatteBuilder 類別
// 只有一兩個可選項時,具名引數 + 預設值其實就夠用,不必上 Builder
public class SimpleLatte
{
public SimpleLatte(string size = "中杯", string iceLevel = "正常冰")
{
Size = size;
IceLevel = iceLevel;
}
public string Size { get; }
public string IceLevel { get; }
}
var latte = new SimpleLatte(iceLevel: "少冰"); // 只改冰量,可讀性也不差
多開一個 Builder 類別,等於多一層物件、多一層維護成本,當選項不夠多時,這一層反而是多繞的路
明天要來聊聊柴咖啡的咖啡機連線的問題,一起聊聊Singleton狀態
如果看不懂我寫的,可以看看延伸的資料,希望可以幫助到你
Refactoring Guru - Builder
設計模式—建造者模式 (Builder Design Pattern)
Design Pattern: Creational Patterns — Builder Pattern (建構者模式)
Java 設計模式 建造者模式 Builder Pattern