設計模式其實是程式語言的缺陷?

fcamel
fcamel的程式開發心得
11 min readDec 14, 2019

記得剛接觸到設計模式 (Design Patterns) 時,有許多誤解,像是:

  • 軟體工程的重點在設計模式,所以要好好研讀各種模式。真實情況是專案和團隊管理更重要
  • 設計模式好棒棒,想辦法找適合用的機會 (手裡是槌子,眼中都是釘子)。實際情況是它們是解法之一,更重要的是釐清問題然後因事制宜

多年前偶然讀到 Paul Graham 的文章,提到設計模式也許反應了語言不足之處:

If you try to solve a hard problem, the question is not whether you will use a powerful enough language, but whether you will (a) use a powerful language, (b) write a de facto interpreter for one, or (c) yourself become a human compiler for one. We see this already begining to happen in the Python example, where we are in effect simulating the code that a compiler would generate to implement a lexical variable.

This practice is not only common, but institutionalized. For example, in the OO world you hear a good deal about “patterns”. I wonder if these patterns are not sometimes evidence of case (c), the human compiler, at work. When I see patterns in my programs, I consider it a sign of trouble. The shape of a program should reflect only the problem it needs to solve. Any other regularity in the code is a sign, to me at least, that I’m using abstractions that aren’t powerful enough — often that I’m generating by hand the expansions of some macro that I need to write.

這個想法像個種子萌芽生長,逐漸明白背後的含意。以下舉幾個例子,說明為何設計模式反映出語言的不足之處,或是語言設計取捨下衍生的問題。

Strategy Pattern

https://en.wikipedia.org/wiki/Strategy_pattern

目的: 在執行期間動態決定使用的方法。

比方說給定一個 list,要從中選出一個元素,作法很多種,可以是選第一個、選最大的、隨機選一個等。用 Java 實作時,會訂個 interface,然後讓不同 class 實作它。

但在有 first-class function 的語言裡,傳遞函式即可,像 Go 可以這麼寫:

type Selector func([]*Foo) *Foofunc First(fs []*Foo) *Foo {
if len(fs) == 0 {
return nil
}
return fs[0]
}
var s Selector
s = First

C、 C++ 可以傳函式指標,用法一樣。對這類語言來說,不會意識到 Strategy Pattern 是個模式。

Builder Pattern

https://en.wikipedia.org/wiki/Builder_pattern

目的: 隔離複雜的物件生成過程。

有時會需要多種不同的設定產生同一類型的物件,比方說車子規格很複雜,有很多選配,生成方式可以寫成:

var cb CarBuilder
cb.SetColor(Red).
SetType(Hatchback).
EnableActiveSecurity(Security.All)
car := cb.Build()

日後需要加功能時,不用改舊有生成 Car 的程式碼,只需在 CarBuilder 加新方法 (例如 UseHybridEngine() ),需要新功能的程式使用 CarBuilder 新方法建立 Car。反之,若全部生成是透過一個固定參數數量的 constructor,加一個參數後要更改全部先前呼叫 constructor 的程式,是很累人的事。

若是使用像 C++、Python 這類有選擇性參數的語言,就沒那麼需要 Builder,在 constructor 或 factory method 加上選擇性參數即可。或是在 C++、Java 這類有 function overloading 的語言,可以同名函式帶不同參數,也沒那麼需要 Builder。

除了提供不同客制化參數外,Builder 還有其它好處。只是當語言有提供選擇性參數或 function overloading 時,需要用的機會就較少了。

Command Pattern

https://en.wikipedia.org/wiki/Command_pattern

目的: 封裝執行動作必要的資訊,交給另一個物件之後執行。

若只需要單一操作不需要延伸功能 (例如支援 Undo),對有支援 closure 的語言來說,用 closure 可以寫得很簡潔。

例如 Go 的寫法如下:

func (fh* FooHandler) Handle(...) (func(), error) {
...
return func() {
// 封裝要執行的操作
// 善用 closure 特性可存取 fh、Handle 的參數、區域變數
// ...
}, nil
}
if handler, err := fh.Handle(...); err == nil {
// 可以當下執行 (即 handler() ), 或傳到另一個 channel 稍後執行
ch <- handler
}

省下制定 interface Command、實作 class ConcreteCommand 的程式碼,而且比用 class 封裝更彈性。

Null Object Pattern

目的: 避免存取 null pointer (null object) 導致程式終止。

這種 bug 很蠢,結果卻很嚴重。如果可以包一個 “null object” 但不是語言內建的 nil/null,讓它所有操作都是 NOP。在宣告物件初始值時只指定自訂的 “null object”,藉此避免用到真的 null。如此一來,可避免程式不小心用到 null pointer 終止。有時也可簡化呼叫程式,減少冗人的 null check。

既然這個問題如此重要,有些語言直接提供解法。例如 Objective C 的 nil 所有操作都是 NOP。避免沒用到自訂的 null object。不過有時你可能希望程式掛掉,第一時間找到問題。相較於產生不正確的資料,比較好除錯。我寫 Objective C 時遇過這種雷,有點惱人。

另一個例子是 Go 讓開發者自行決定。比方說:

func (c *Car) NumWheels() {
if c == nil {
return 0
}
return len(c.wheels)
}

存取 nil 的 member fields 一樣會掛掉,但用 nil 呼叫 method 時,可以作 nil check 擋掉。 定義物件時,開發者要決定是否幫使用者作 null check,這是介面規範的一部份。

Adapter Pattern

https://en.wikipedia.org/wiki/Adapter_pattern

目的: 使用不同的介面重用既有的介面,藉此重用程式碼或是減少模組間的相依性。

電器的轉接插座是不錯的類比,用來轉接成不同介面 (例如三孔接兩孔)。

另一種情況是為了減少和其它套件 (例如第三方函式庫) 的相依性。比方說模組 X 需要用模組 Y 的 Foo,但想用介面 Bar。控制流程是:

X.Bar → X.A → Y.Foo

A 是 adapter,實作內容只有呼叫 X,沒多作其它事。日後 X 有變動時 (例如回傳格式有變),可以在 A 處理,而不用動到全部使用 Y 的程式。

缺點是要多寫 X.Bar 和 A,有點麻煩。但像 Go 有 implicit interface,針對隔離其它套件的情境來說,定義介面 X.Bar 即可,不用寫 A。implicit interface 讓開發者輕鬆獲得隔離效果,會讓開發者更頻繁在需要用 Adapter 時使用它,減少偷懶不用的機會。

Observer Pattern

https://en.wikipedia.org/wiki/Observer_pattern

目的: 事件發生時可通知關切此事件的物件。反應即時且比 polling 有效率。

若事先知道關注那些物件會關注事件,寫 Go 時可用 channel 接起來:

ch := make(chan int)for i := 0; i < 3; i++ {
// 產生三個 consumer (=Observer)
go func(idx int, numbers chan int) {
for n := range numbers {
fmt.Printf("%d receives %d\n", idx, n)
}
}(i, ch)
}
// 產生一個 producer (=Subject)
go func() {
for i := 0; i < 100; i++ {
ch <- i
}
}()

有人會認為任何語言都可以用 synchronized queue 作到一樣的事,為什麼特別提 Go 呢?因為 Go 內建 channel 的語法,讓它用起來很方便。例如 for … range 是 iterator 的語法 (有些語言用 foreach 表示)。Go 將它和 channel 結合在一起,寫起來較簡潔。

若需要動態增減 observers,可用 channel 表示 observer,事件發生時,subject 將事件寫入所有註冊的 channel。這樣可降低 subject 和 observers 之間的相依性。比方說原始的 Observer Pattern 需要考慮 observers 收到通知時在哪個 thread 執行。若會占用 observer 的 thread,執行太久會延遲其它 observer 反應時間。之所以有這問題,是因為這個解法要求 subject 呼叫 observer 的方法,所以 subject 必須決定在那個 thread 執行 observer。

使用 channel 當 observer 的媒介避開了執行 thread 的設計問題,因為 subject 將事件寫入 channel 就結束份內的工作了,變成 observer 自行決定何時在哪個 goroutine (thread) 讀 channel。observer 可以用 blocking read 也可以用 non-blocking read,結果使用 channel 的版本提供比原版更大的彈性。

若過於挶泥原本 Observer Pattern 的用法,不會留意到 Go 提供更多設計的選擇,或是以為其它變化是錯的。

結語

這篇文章舉幾個例子作為引子,說明不要僵化地使用設計模式 (Design Patterns) 固有的解法,而是理解需求,在使用設計模式時,一併考慮程式語言的特性因事制宜。

像 Go 在表現Null Object Pattern、Adapter Pattern 和 Observer Pattern 有更多的彈性,但 Builder Pattern 則否,這是 Go 在語言階段設計取捨後的結果。

更進一步地說,以設計模式為引子,拆解它們背後精神融入自己設計的思維中,如此才能無招勝有招,甚至處理先前沒人遇過的情境。

相關文章

--

--