在開發過程中,總會遇到一些類別在性質上很相像,但就還是有一點點的差異的時候。例如:一樣都是要建造車子,但可能就有分「一般轎車」、「休旅車」 or 「越野車」等等,各種本質上都叫做車子,但又其實不太一樣。
如果這時候我們試著把這些很像的類別往往上抽一層,可能就會有一個 base class(因為基本的生產過程&基本的屬性正常都會相同),然後讓各種不同 features 的車子類型在繼承後,繼續往上補所需要的功能。
在一般 happy flow 的情況下可能不會有問題,但如果要生產的車子類型有高達數佰種,這時候所繼承的子類別就會像葡萄一樣一大串。
或是像一些建構式參數很多,然後這些一堆參數就是為了解決情境1所提到的太多子類別的問題。在這些參數裡,有些是「必要」,有些是「非必要(Optional)」的,因為要藉此來區分要實作哪一種類型的車子。這時候對 clinet 來說,使用上就會變得很幸苦,因為他還要去區分各個參數要怎麼填。
在這些情境下,也許考慮來套用 Builder Pattern 就會是個不錯的選項。build pattern 可以把這些繁瑣的建造過程或參數給抽出來,讓 client 可以不用管那麼多的細節,即可拿到想要的類別。

如果是建構式參數過多的話,可以做一個 Builder,然後在 new 這個 Builder 的時候建構式傳入必要的參數,剩下的 optional 參數,可以做成方法讓 client 來自己進行設定,最後再透過這個 Builder 的 GetResult 或 CreateProduct 方法(名稱沒差,看想叫什麼名字),來拿到實際需要的類別。
在 after 的範例裡,其實可以看自己想怎麼設計 Car 這個類別。如果是既有專案,建構式已經一大坨並且跟其他的 modules 相依性也已經很高了。也可以考慮把 Builder 建構式所收到的參數先 private 儲存起來,然後在 Build() 的時候再代入 Car 的建構式,這樣可以避免對既有 Car 類別做太大的修改。
public class Car
{
// 必要
private string _model { get; set; }
private int _year { get; set; }
private string _color { get; set; }
//非必要(optional)
private bool _isTurbo { get; set; } = false;
private int _wheelSize { get; set; } = 16;
public Car(string model, int year, string color, bool isTurbo, int wheelSize)
{
this._model = model;
this._year = year;
this._color = color;
this._isTurbo = isTurbo;
this._wheelSize = wheelSize;
}
}
public class CarBuilder
{
private Car _car;
public CarBuilder(string model, int year, string color)
{
_car = new Car(model, year, color);
}
//非必要(optional)
public CarBuilder SetTurbo(bool isTurbo)
{
_car._isTurbo = isTurbo;
return this;
}
public CarBuilder SetWheelSize(int wheelSize)
{
_car._wheelSize = wheelSize;
return this;
}
public Car Build()
{
return _car;
}
}
以建造汽車的範例來說,可以把種類較相同並且建造動作較相似的車子類型往上抽(介面或抽象類別都可以),然後做一個專屬的 Builder 類別。
比如說,「越野車」類型的,就可以做一個 OffRoadCarBuilder 類別,然後把生產越野車固定會有的動作都定義完。例如:四輪傳動、安裝越野輪胎、安裝防撞桿等等。接著把一些 optional 選項的動作做成方法,例:有窗=True、有導航=True 等等。讓 client 可以透過這些方法來決定要生產出哪種越野車。
這些動作當然也可以實作成把各種「越野車」合起來變成只有一個類別(因為安裝四輪傳動、安裝越野輪胎、安裝防撞桿這些動作都一定會有),接著透過設定各屬性來決定要生產出各種越野車的細微差異。例如:有窗=True、有導航=True等等。但這樣的實作會有一些後遺症,尤其是當系統越來越肥的時候。例:
屬性或是放在建構式才有機會讓 client 來調整。如果是放在 建構式,那就跟 solution 1 要解的問題一樣,參數過多。如果是放在屬性,就有可能還沒設定完,class 就先被拿去用了。建構式,要嘛就是抽成設定的方法再加實作去卡。但這會讓這個類別的複雜度變高。跟上面提到的一樣。當一個類別的建構式參數已經太多,多到讓用的人每次都要花時間去細看各個參數要不要送,其實這個類別的建構式算是相當不健康了。
之前在專案裡看過的,就是一個類別的建構式送了好幾個 null, 0 or false 這種沒營養的參數。如果當初寫的人又沒有在參數加上說明,並且變數名稱又不清不楚的時候,可以想像要用的人心裡會有多少的 os。
Builder Pattern 可以把一些 client 非必要送入的參數抽出來,讓有想要用的人再自己透過方法來給值,最後只留下必要的參數在建構式就好。簡單講,就是把那個很髒的建構式交給 builder 來幫忙處理。
如果是使用 Builder Pattern 來建立物件,在建立的過程中,其實是用人類相對較好閱讀的方法來逐步執行跟設定。這個是比一開始在建構式裡的那一堆 flase, 0……的參數語義上要更清楚。
而那些建構式裡必要的參數,則可以放在 new builder 的時候讓 client 從外面來傳入,確保不會漏掉。
decimal salePrice = 50000m;
var pc = new ComputerBuilder(salePrice)
.SetCPU("Intel i9")
.SetGPU("RTX 4090")
.Build();
在維護一些既有專案並且又沒有所謂的 builder pattern 時,如果要在建構式加入新參數,通常我的選擇都是用風險較低也較好實作的多載。
選擇多載的原因是,既有的這個類別可能已經散落各地在被使用了。直接動既有的建構式接口,可能會影響到某一個你不懂的 module。而通常較折衷的方式,就是再多一個多載,然後把原有的建構方式再往後呼叫。
這個方法看似可行,但假如這個類別經過了一、二十年不斷的被加需求或修改,然後又一直沒機會抽成 builder pattern 時,這時候就會換原有一陀的多載方式在那裡了……(對 builder pattern 而言,理論上只要再多加一個方法,就能做到一樣的效果)
public Computer(decimal salePrice, string cpu){
return this.Computer(salePrice, cpu, string.empty);
}
//new method
public Computer(decimal salePrice, string cpu, string gpu){
this._salePrice = salePrice;
this._cpu = cpu;
this._gpu = gpu;
}
透過 builder 所做出來的類別,裡面的屬性設定都能確保是「乾淨」的了。也就是不可能存在著像 new 完 class,然後有些屬性手殘或是漏加,就把這個類別丟出去開始使用的情形。
在我自己維護過的專案中,如果撇除像 API 站台或服務類程式的 Program.cs 裡的那些初始設定外,可算是沒有實際在自家專案套用過這個 pattern。硬要提的話,就是在使用第三方套件時,別人家有用這個 pattern 來設計他們的類別建構。
在使用上來講,就開始會發現每個人設計軟體的思路真的有所不同。
像是之前對接某一間公司產品的套件時,他一樣是先 new 出一個 builder,然後再透過各個 set 方法來進行參數的設定(可參考底下示意程式)。
這間公司設計的方向是不把必要的參數放在 builder 建構式裡,而是讓 client 在真正使用的時候再噴錯(假設 salePrice 不能為 0)。如果用我底下的示意程式來看,就是在 pc.Produce() 那行的時候噴出 exception。
說實話,我不確定這樣的設計是好還是不好。但一樣的 builder 模式,對我而言,我就會選擇把一些確定是必要的參數直接放在 builder 的建構式裡。原因是既然這些參數是必要的,那就不用等到 client 都已經要使用的時候,再跟人家說:「抱歉,你剛才設定的參數有少。」
一模一樣的情境,就像是我打電話去信用卡客服中心,我已經在線上等了十分鐘,終於有客服人員接起我的電話,但他聽完我的問題後,卻跟我說我剛才選的服務類別是錯的(也就是選錯數字),所以他不能處理,要我再重新打電話進客服中心一次。就個人來說,似乎不是一個好的設計。
var pc = new ComputerBuilder()
.SetCPU("Intel i9")
.SetGPU("RTX 4090")
.Build();
var r = pc.Produce();
引用一下 golang 大師 William Kennedy 的一個概念:一個好的設計,除了要好用之外,更重要的是,要讓別人「不容易」用錯。
所以一個類別的建別式已經走到這麼複雜的時候,我認為算是一種不健康的訊息了。因為 client 在使用時,很有可能會搞不清楚參數的用途 or 眼花送錯參數等等。這些其實都是在增加用錯的機率而已。
在 study builder pattern 的時候,在 UML 圖裡其實還有一個 director 的類別,主要是用來確保 builder 在被設定時能按照一定的順序來呼叫方法,否則可能會有問題。但這對我來說感覺也是個設計上的問題,如果設定的方法有些是有一定的相依順序,是不是改換個角度來思考,有沒有機會在讓 client 做用 builder 設定時就直接把這個問題解決掉,不要讓 client 有順序錯的機會。
Design Patterns: Builder
軟體就該是軟的:設計模式思維實踐
大話設計模式