在計算機的案例中套用策略模式適合嗎?用 Forces 來證明模式適用性

水球潘
11 min readJun 5, 2022

--

大家好,我是水球潘。

今天要和大家聊聊軟體設計模式中的一些基本精神,並且我們來檢視一下,「計算機 x 策略模式 (Strategy Pattern)」是否符合這些精神。藉由放大檢視這些去蕪存菁的簡單案例,我們總能帶走一些最核心的思想。

策略模式是最有親和力的設計模式,因此許多人在初學設計模式時,往往會聽到許多例子是以簡易計算機作為出發點,並且我們能從中學到,使用策略模式可以有效地化減 if-else。

如果你沒有聽過策略模式的話,那麼你能從這篇文章中感受到學習設計模式之後能帶給你的思維轉換;如果你聽過策略模式的話,你仍然可能從這篇文章中刷新你對策略模式的理解。

直接上需求:「簡易計算機」

你收到了一份需求:你要開發一個簡易計算機,初版簡易計算機只有加減乘除四個不同的運算子,未來至多會擴充到將近十種不同的運算子。而這個計算機簡易到他一次只能負責一種運算子的計算,意即它不能同時計算多達一個運算子的式子。

於是你就畫出了以下初版的類別圖:

V1 OOA

以及此類別圖的 OOP:

在業界水深火熱的我們寫程式寫久了,就會養成一個警覺心:看到 if-else 或是 switch case 就如同聞到某種 Bad smell,我們會感覺 if-else / switch case 是某種暴力的窮舉陳述,而導致程式碼不夠優雅

因此此時,我們會有一個直覺:「套用策略模式來優化這段程式碼」。

套完策略模式

套完策略模式之後你的類別圖長這樣:

你會把各個「運算子行為」:Plus, Multiply, DivideMinus,分別封裝進各個類別之中。並且這些運算子類別會共同實作 CalculationStrategy 介面。

此時你的 OOP 如下:

然後你的 Calculator 類別程式碼就能被化解為以下形式:

原本充斥著 switch case 的 calculate 方法已經被縮減為短短一行,僅僅是一句「委派」就將運算工作交給 Calculation 執行。而在 Main method 中透過依賴注入 (Dependency Injection, DI) 不同的計算策略實作 (如第17, 19行) ,就能抽換 Calculator 的計算行為。

套用完之後,我們能清晰看見「程式碼原有的 switch case」已經完全消失不見,可以說是策略模式成功化解了程式碼原有的 if-else / switch case。

案例深度探討

對於新手來說,這是非常良好的「物件導向技巧」展現,讓初次接觸設計模式的夥伴們多感受一下我們如何藉由「組合物件」來改變物件執行期行為

但對於有意精進「軟體設計」的朋友們,我們可得再看深一點了。如果你在心裡一直把策略模式當成是有效化解 if-else / switch-case 的設計的話,可得多注意一下,因為你可能有著過度設計 (Over-design) 的傾向——具備著某種偏執使你一直無法解決真正的設計問題。

可以用以下兩句問題來考考自己:

  1. 「程式碼中存在著這麼多處的條件式,幾十條、幾百條,你要怎麼知道哪些 if-else / switch 要化解? 哪些不用?」
  2. 策略模式和責任鏈模式一樣,都能夠非常有效地化解 if-else / switch case,那到底兩者適用情境差在哪?你是如何證明你應該要套用策略模式而非責任鏈模式的?

如果回答不出這些問題的話,可能你在大多數時候是憑靠直覺來做設計決策,而非從設計決策的根據上去證明。而有太多工程師夥伴太常把自己泡進「程式碼」之中,如果以程式碼的視角來作為出發點的話,是很不容易將軟體設計的核心元素看清的,程式碼有幾千幾萬幾十萬行的時候,你目光所及之處,僅能容納幾十行而已啊。

四人幫的 23 個設計模式中有九成的模式都是高階的,並不是程式碼層級的設計模式,而是物件導向層級的設計模式。我們並不是邊寫程式邊思考著要套用什麼模式,而是先把視角鎖定在較高階的物件導向模型 (Object-Oriented Model) 上來對焦需求,並察覺需求是否在模型中形成了設計問題 (Design Problem),將問題的組成元素用某種具體的方式描述,然後才在模型上套用適用的設計模式來解決問題 (Solve the Problem)

如果我們連問題的組成元素都無法具體拆解出來,那該如何證明問題存在?無法證明問題存在的話,又要如何證明模式適用?

回到我們的簡易計算機案例上來看,我們來證明策略模式並不是此案例的適用模式吧!

證明策略模式並不適用於簡易計算機案例

先展開策略模式的定義,參考 GoF 四人幫設計模式中對策略模式的描述,但我偏好使用 Christopher Alexander 提出的六大模式元素(Name, Context, Forces, Problem, Form (Solution), Resulting Context)來定義它。

先不提 Form 和 Resulting Context (也就是模式提出解決方案的形式,通常以類別圖為主、循序圖為輔,和套用完模式之後會得到的結果,可視為是將程式帶入了另一種 Context,故稱 Resulting Context)。

用早起早睡的例子來複習 Problem 和 Forces

大家可以把 Forces 直接當作是「組成 Problem 的元素」。你之所以會將某一道難題視為是一道難題,正是因為你受到了多方的阻力 (Forces)。

舉例來說:難題 (Problem) 可能是「我已經晚睡晚起好一陣子了,我到底該如何早睡早起?」是一道難題,但是經過拆解分析之後你會發現,這道難題中存在著各項元素:

  1. 你時常有很多工作做不完,導致你必須動用睡眠時間來工作,如果睡眠時間不足,就無法早起。
  2. 如果晚起了,那麼你就會報復性失眠,總覺得今天沒有過好過滿,因此身體不自覺地拖延上床時間,促成惡性循環。
  3. 我習慣性地把鬧鐘關掉之後再回過頭繼續睡覺,每次一關就接連關掉了十幾項鬧鐘。

每一道難題背後都充斥著好幾項根本元素,使這個問題特別的難,因此才需要人們發明相關模式來解決這道難題。

  1. 如果沒有這些 Forces,就不存在此 Problem。
  2. 模式解決這道 Problem 的方式就是解決了組成此 Problem 的所有 Forces。

簡單來說,如果你的生活中不存在或是你透過某種方式解決了「加班工作晚睡」、「報復性失眠導致的惡性循環」和「連續關鬧鐘的惡習」的話,那你不就能「早睡早起了?」。除非還有其他道 Force 沒有被你分析列舉出來(像是你可能有另外一半,常常要陪另外一半講電話講到凌晨,這,也是一道 Force。雖然,我們好像沒有這道 Force⋯⋯)。

從 Forces 來證明模式的適用性

因此要看一個模式的適用性,只要看該案例情境是否存在著符合模式的 Forces 就行了;以下為策略模式的 Name, Context 和 Forces:

  • Name:策略模式 (Strategy Pattern)
  • Context:你的程式中定義著一組行為(或是演算法)。而隨著需求的發展,你可能需要在系統中添加新的行為。
  • Forces:
  1. 類別的某一個動作 (Operation) 中存在著不同的行為變種 (Behavioral Variants)。
  2. 你希望能夠讓 Client 從外部抽換此物件方法執行期的行為,或是擴充新的行為而不用修改類別的內部程式碼,遵守著開閉原則 (Open-Closed Principle, OCP)
  3. 由於該類別程式的維護性或是內聚力明顯衰退,為此你想要封裝這些行為變種的細節,以讓類別的維護者能專注在該類別的其餘行為上。

我們使用 Forces 來定義 Problem(是一道邏輯式):1 ∧ (2 ∨ 3),Problem 是 Forces 的一個邏輯組成,意思是 Force 1 一定要存在,而在 Force 1 存在的情況下, Force 2 或 3 只要存在一道就構成這道 Problem。只要你的 Context 中存在 Force 1 & 2 或是 Force 1 & 3 就算是構成了這道難題,適合套用策略模式。

再看一眼我們的需求,將我們的需求視為是我們實際面對的 Context,然後一起檢視在這 Context 中是否存在這三道 Forces。

你要開發一個簡易計算機,初版簡易計算機只有加減乘除四個不同的運算子,未來至多會擴充到將近十種不同的運算子。而這個計算機簡易到他一次只能負責一種運算子的計算,意即它不能同時計算多達一個運算子的式子。

Force 1 的意思是:你是否有一個類別,他的某方法中的行為不只一種?以我們的簡易計算機 Calculator 來看的話,Calculatorcalculate 方法中存在著四種行為——加減乘除——是的,簡易計算機具備 Force 1。

Force 2 的意思則是:延續 Force 1,你是否希望 Client (i.e., 在我們的案例下的話就是 Main Method) 能夠在外部透過某種方式(可能是 setter)來抽換此Calculator 物件執行期的 calculate 行為。

Force 3 的意思則是:延續 Force 1,你是否認為 Calculator 類別中涵蓋的行為過多導致維護性和內聚力逐漸衰退,導致你想要把運算子行為從中封裝抽離出來?

仔細想清楚唷!我們到底有沒有 Force 2 或 3?

你真的「希望」Client 能在外部抽換方法行為嗎?我們的 Calculator 類別程式有要被編譯起來作為外部套件不給其他工程師改寫內容以至於必須只能在外部抽換行為嗎?Calculator 中是否複雜到如果要進入到類別裡頭添加新運算子行為時會付出昂貴的成本?

你真的「希望」封裝各項運算子的行為細節嗎?每個運算子的運算行為僅僅只有短短一行 a+b, a-b, a*b, a/b 這真的足以降低維護性嗎?Calculator 只有一個 calculate 方法,內聚力極高,真的有必要抽出運算子行為,來讓 Calculator 最終一件事都不做嗎?

這樣思考過後,我們會發現,Force 2 和 3 都不存在,因此在簡易計算機的 Context 中,不存在策略模式欲解決的 Forces 和 Problem,沒有必要套用策略模式。

如果你再細細檢視 Force 1 (行為變動性)的話,你還會發現:「其實計算機最終也就那十幾個運算子而已,需求形成的行為變動性的 Force 力道太小」—— 可能連 Force 1 都不算是稱得上具備,因為力道太小足以忽略。

在沒有必要套用模式的時候,套用了模式,就是一種過度設計(Overdesign)。過度設計反而會帶來額外的成本,以本例來看,你為了套用模式多開了四個類別和一個介面,但卻沒有解決實質問題。

結語

策略模式和各種設計模式都不是為了化解「If-else」而被發明出來的,而是為了解決一道實際的 Problem,而這道 Problem 由多道明確的 Forces 以某種邏輯組成。

簡易計算機的 Context 下不具備策略模式欲解決的 Forces,不適用策略模式。如果套了策略模式只會帶來額外的成本,純屬過度設計 (Overdesign)。

學習軟體設計只有這一種方法

學習軟體設計的目的就是徹底轉換自己的思維,並熟悉這一種新的思維,來使自己做設計決策的品質越來越高。支撐著公司大型專案長期發展的,往往是一道因果脈絡清楚的設計決策。

而要訓練自己的設計能力,或是要真的精通軟體設計模式的話,你需要的不是大量的閱讀!!!而是需要大量的「題目」!!!這些題目絕不是什麼「課後複習」等級的題目,複習用的題目是很重要沒錯,但是含金量高到能有效逼迫你「內化設計模式思維」的題目更重要也更稀有

題目的設計者必須非常理解模式元素以致於他有辦法在題目中「放入滿滿的惡意」,迫使你你必須使用他預想的某種設計模式或手段才有辦法化解題目中的這些「惡意」。是的,題目中必須刻意設計 Forces,來考驗你對於 Forces 的察覺和化解能力。

筆者在主修軟體設計的這六年全靠跌跌撞撞,浪費了許多年的時間,如果有優秀的題目的話,至少可以把整段變強的旅程縮短至至少一年。

於是我精心製作了許多道題目,也在多年的教育實踐中獲得了許多心得。如果你想要試試看自己的水平的話,歡迎加入我用來分享心得、知識和活動的臉書社團,並且私訊我和我取得題目,我隨時都很樂意和你分享唷!

水球軟體學院臉書社團:https://pse.is/47kmlr

--

--

水球潘

看待事物的方式都能構成一幅藝術的創作