iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 26
2
Modern Web

讓 TypeScript 成為你全端開發的 ACE!系列 第 26

Day 26. 機動藍圖・策略模式 X 選擇策略 - Strategy Pattern Using TypeScript. I

https://ithelp.ithome.com.tw/upload/images/20190925/20120614AF0nUfJLea.png

閱讀本篇文章前,仔細想想看

類別繼承與介面綁定的差別在哪裡?能夠描述它們各自的優缺點嗎?

如果還沒理解完畢的話,可以先翻看前一篇的文章喔!

筆者本來沒有要寫這一篇,自己卻不小心挖了這個坑,所以想說算了,就寫吧~

正文開始

策略模式 Strategy Pattern

先從問題的起點開始

首先,在介紹設計模式中的策略模式前,要先了解通常是什麼情形才會需要策略模式。

筆者就把前一篇所寫過的範例快速帶過。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614U6CPgbWcMy.png

其中,以上的程式碼有個地方很冗長,那就是類別 Characterattack 成員方法的實踐內容。

一個遊戲如果不停擴充各種不同的角色種類,勢必會造成該 switch 的敘述式越來越龐大,再加上這個 Character 類別也沒有描述 —— 譬如,被攻擊的角色生命值是如何被扣損的,亦或者可能也沒有被命中敵人 —— 但要是把這個功能加上去可能只會造就越來越多義大利麵程式碼。

觀察一下程式碼,會發現一些特點:

  • 角色類別眾多,但都有相似的行為或演算法
  • 要定義新的行為,最差的結果就是很冗長的 switch...case...,亦可用 if...else if...else 敘述式
  • 如果想要擴充角色職業,必須在每一個 switch...case... 敘述式中,新增該角色職業的情形

本篇目的是 —— 除了會基本的 interfaceclass 語法外,還能夠善用它們。因此,筆者把昨天(很陽春)的 RPG 系統重新設計過一遍。筆者是額外再建一個全新的環境進行開發的動作,如果想要看本篇實作過後的完整程式碼可以點這邊

前置作業

這裡就稍微帶過筆者建立簡單環境的過程與執行的指令:

// 到任何一個選擇的檔案資料夾位置
$ cd ./<PATH_TO_CHOSEN_DIR>

// 初始化專案
$ tsc --init

// 新建 index.ts 檔案
$ touch index.ts

// 新建 build 資料夾
$ mkdir build

另外,在 tsconfig.json 裡,更改某些項目:

{
  "compilerOptions": {
    // 略...
    "outDir": "./build",
    "rootDir": "./",
    // 略...
  }
}

以上的選項是為了使編譯過後的檔案集中放在 build 資料夾裡。(編譯器詳細資訊後續會在第三篇章《戰線擴張》進行解析,這也是 Day 31. 以後的文章了)

另外,每一次必須要手動編譯並且執行檔案實在是很辛苦,因此筆者額外初始化 package.json 並下載一些套件協助開發:

// 初始化 package.json
$ npm init -y

// 下載一些模組
$ npm install concurrently nodemon --save-dev
  • nodemon 偵測到 JS 檔案被修改的話,就會重複用 node 執行該 JS 檔案
  • concurrently 則是會同時執行 package.json 裡不同的 script 定義的指令

修改 package.json 裡的 scripts 選項:

{
  // 略 ...
  "scripts": {
    "start:watch": "tsc -w",
    "start:run": "nodemon build/index.js",
    "start": "concurrently npm:start:*"
  },
  // 略 ...
}
  • start:watch 執行的是 tsc -w —— 代表只要 TypeScript 編譯器偵測到 TS 檔案有變動,就會重新編譯
  • start:run 則是每一次 JS 檔案被產出來(所以檔案有被修改),就會重新用 node 執行該檔案
  • start 裡面的 concurrently 會同時執行以上兩個不同指令

到目前為止打開 VSCode 專案大致上應該會長這樣。(圖一)

https://ithelp.ithome.com.tw/upload/images/20190925/20120614kiUwtjsGE2.png
圖一:專案裡面除了 tsconfig.json 檔案以外,也有 package.json 設定檔與一些被更改的資訊

如果下 npm start 就會同時監測專案裡 TS 檔案並且進行編譯與執行 JS 的動作。以下筆者簡單更改 index.ts 並且自動執行結果如圖二與圖三。

貼心小提示

如果第一次執行 npm start 出現錯誤,找不到 build/index.js,可以先用 tsc 編譯一次後再重新執行 npm start畢竟剛開始找不到 build/index.js 檔案,所以 node 也沒辦法執行編譯後的檔案

https://ithelp.ithome.com.tw/upload/images/20190925/20120614mBC7BwDz6S.png
圖二:第一次執行過後的結果

https://ithelp.ithome.com.tw/upload/images/20190925/20120614VhqEF555gg.png
圖三:更改 index.ts 內容,不需要進行編譯即可自動動作

從設計過程中找出問題所在

首先額外建立一個資料夾名為 characters:你可以使用 mkdir characters 或直接在編輯器裡面新增資料夾也可以。

新增 characters/Role.ts 檔案,內容如下:

https://ithelp.ithome.com.tw/upload/images/20190925/20120614f2ipWTAkvp.png

主要就是將職業內容使用列舉型別後,再 export 出去。讀者若不熟悉 import/exportdefault import/export 語法請記得上網查一下喔~)

然後新增 characters/Character.ts 檔案,內容如下:

https://ithelp.ithome.com.tw/upload/images/20190925/201206146u8eZHoYNm.png

Character 類別只有兩個成員變數以及一個成員方法:

  • name 是角色名稱,所以為 string
  • role 則是角色職業,為 Role 列舉型別;由於 Role 被定義在其他檔案,因此必須載入進來(這裡是用 default import 方式載入)

筆者在 index.ts 裡,將兩個模組載入後,利用簡單的程式碼進行測試。(結果如圖四)

https://ithelp.ithome.com.tw/upload/images/20190925/20120614YZShkcQ98c.png

接下來才是進入問題的開始:角色職業有四種(筆者本篇會以其中兩種為例)—— 每種職業都有相似的模式,譬如:

  • 屬性與能力值,如生命值 health、魔力值 mana 等等
  • 會攻擊之外,也會被攻擊,但攻擊方法可能又分很多種,譬如:直接攻擊 MeleeAttack、魔法攻擊 MagicAttack(當然也可以再細分)
  • 各種職類可能也會有特殊技能

這裡筆者就先繼續寫下去,寫到有問題出現時 —— 進行分析後,再來看看如何解決。

首先,我們可以利用繼承 Character 的方式建構出角色們 —— 新增 characters/Swordsman.ts 以及 characters/Warlock.ts,內容如下:

https://ithelp.ithome.com.tw/upload/images/20190925/20120614Kd2I8LEYVT.png

https://ithelp.ithome.com.tw/upload/images/20190925/20120614lDqGoWcb85.png

圖五是對程式碼進行簡單的檢測結果。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614fjb9WVT58k.png
圖五:SwordsmanWarlock 是可以正常使用的類別

到這裡應該沒問題 —— 接下來,筆者先用不好的方式呈現 attack 方法的實踐過程。

大部分使用類別繼承的想法,不外乎是因為父類別跟子類別的關係是很緊密的,因此父類別定義新的成員,子類別也自動擁有父類別定義的該成員。

https://ithelp.ithome.com.tw/upload/images/20190925/201206143dF7vVgteJ.png

因此,父類別若新增 attack 方法,則子類別也會跟著擁有 attack 方法。(圖六為程式碼檢測成果)

https://ithelp.ithome.com.tw/upload/images/20190925/201206149lNI7bWPuJ.png
圖六:子類別繼承了父類別的成員,因此可以使用 attack 方法

若希望每個子類別攻擊的方式不同,通常最直觀的作法就是 —— 直接在子類別內覆蓋父類別的方法。

因此筆者將 Warlockattack 方法進行修改的動作。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614X9rg7X2Vab.png

(其實筆者後來重新看過,英文連接詞 and 前後文法應該要一致... 不過想說算了不改了 XD

理所當然,測試 Warlock 類別的 attack 方法會出現不ㄧ樣的結果喔~(如圖七)

https://ithelp.ithome.com.tw/upload/images/20190925/20120614iayKb8Vbzb.png
圖七:Warlock 攻擊時,變成使用魔法攻擊呢~

這裡就碰到了問題點:儘管利用繼承的方式避開了 switch...case... 這個冗長判斷式的解法,然而,取而代之的只是 —— 宣告更多子類別然後對父類別的方法進行覆蓋的動作

這實在是不行啊!治標不治本,搞不好也有其他職業 —— 假設我們又有新的職業為 Occultist(神秘學者 —— 筆者隨便舉的一個職業),它也會使用魔法攻擊,難道又得從 Warlock 裡面將 attack 方法照本宣科複製到 Occultist 類別嗎?於是這裡就違反了 DRY(Don't Repeat Yourself)的原則。

因此,筆者今天就要搬出今天的主角:策略模式 —— Strategy Pattern

策略模式 Strategy Pattern

筆者上網查詢,通常會出現的一句話代表策略模式的意涵:

Changing algorithm during runtime.

英文到底是要用介系詞 During 還是 On,筆者覺得看完本篇文章再去自己查詢文法正不正確,反正在本系列,學到工具的用法與真諦最重要。

其實簡單來說:

策略模式的意義在於 —— 根據不同情形,在程式執行時可以靈活地轉換演算法(策略)而不需要再另訂新的類別與類別繼承的動作

另外,以下會用個人見解對策略模式進行描述,如果讀者看得懂就表示你可能早就學過亦或者是你是萬年以來的天才 —— 筆者也願意把膝蓋割下來跪在你面前!(好痛

筆者想要試試看從概念上切入再一步步實踐策略模式,因此筆者認為讀者對以下重點剛開始看不懂是不意外的,讀者也可以選擇先跳過以下的重點直接往後面的步驟看,等熟悉策略模式的設計手法後再回頭看看筆者寫的重點也 Ok

本篇唯一重點. 策略模式的意涵

如果要在眾多類別中實踐近似但相異的行為,與其直接實踐(implement)出功能並使用一連串的敘述式進行演算法的切換,不如在父類別裡建立一個行為演算法的參考點(reference point),任何符合該參考點的演算法必須遵照某介面(interface)進行實踐的動作;父類別可以藉由在該參考點切換演算法,不需要經過一連串判斷流程,就可以達到功能相異的結果。

而父類別的參考點切換演算法的過程,又被稱作為切換不同策略的行為,因此得名 —— 策略模式。

可能看完這一段,會很想吐槽筆者:“這個作者為何要那麼麻煩地把一個概念拐彎抹角的描述出來?”

筆者知道以上的意涵很模糊,但那是因為我們還沒討論到以下筆者提出來的問題:

  1. 直接實踐參考點差別到底在哪?
  2. 由父類別定義參考點(reference point)進行策略的切換意思是什麼?
  3. 而符合該參考點的演算法 —— 也就是策略,策略本身是什麼?必須遵照某介面進行實作,也就是說策略必須跟介面進行綁定的動作?

其實筆者刻意留了一些線索進去,我們來仔細推斷。

首先,參考點的概念在第 2 點有被提示到 —— 由父類別去定義一個參考點,也就是說:

參考點(reference point)是父類別的成員之一

二來,第 3 點有說到:“符合該參考點的演算法 —— 也就是策略”;而後又有一句:“策略必須跟介面進行綁定的動作”,也就是說:

策略並不是函式或者是方法,因為要能夠和介面進行綁定;不過也從策略必須能夠綁定介面這個特點,得知 —— 策略是一個類別的宣告

所以再回到含糊的重點那一段,其中:“父類別可以藉由在該參考點切換演算法,不需要經過一連串判斷流程,就可以達到功能相異的結果”,代表父類別使用參考點進行演算法的切換,達到切換不同功能的目的。

以上推論過程不清楚也沒關係,這裡筆者就要開始以上面簡短推論出來的結果對 Character 類別系列進行新增角色攻擊的能力。

步驟 1. 策略的介面綁定與宣告

首先,筆者必須先宣告一連串的策略(Strategies,亦或者演算法)。其中,每一個策略必須綁定某介面以確保實踐出來的功能是固定,但內部的演算可以是不ㄧ樣的。

筆者先建立一個資料夾名為 abilities。根據角色的攻擊能力這一項功能 —— 宣告 Attack 這個介面並且放在 abilities/Attack.ts 這個檔案,內容如下。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614z9k04grs4J.png

再來筆者可以開始定義不同的攻擊策略,以下就以 MeleeAttackMagicAttack 為範例,各自為 abilities/MeleeAttack.tsabilities/MagicAttack.ts

https://ithelp.ithome.com.tw/upload/images/20190925/20120614uLgqwkvwi6.png

https://ithelp.ithome.com.tw/upload/images/20190925/20120614MJ0Izavi0N.png

步驟 2. 父類別建立策略參考點

定義好策略後,接下來就是在父類別建立起參考點(Reference Point)—— 該參考點負責的任務就是進行策略的切換

https://ithelp.ithome.com.tw/upload/images/20190925/20120614Cy7Qbn0GHe.png

由以上程式碼得知,參考點其實只是一個類別屬性 attackRef 負責連結到 Attack 型別的物件;此外,筆者也屏棄掉原本在父類別的 attack 成員方法。

這個步驟很簡單就這樣被結束了 XD。

步驟 3. 藉由參考點進行功能傳遞的動作

在父類別裡,因為我們的介面 Attack 絕對會有 attack 方法,儘管裡面的內容並不知道(可能會是 MeleeAttackMagicAttack)—— 但這根本不重要我們只要知道角色有 Attack 的策略可以呼叫就夠了!因為每個策略綁定了介面,確保都有 attack 方法進行實踐。

因此,原本的 attack 方法可以藉由參考點 attackRef 指定到的若干策略進行呼叫 attack 方法的動作:

https://ithelp.ithome.com.tw/upload/images/20190925/20120614KZm6FdRP8U.png

步驟 4. 子類別可以選擇策略

我們就快完成了!

接下來,可以為各種子類別進行策略的選擇喔!譬如:Swordsman 選擇的攻擊策略是 MeleeAttack;相對的,Warlock 選擇的則是 MagicAttack

https://ithelp.ithome.com.tw/upload/images/20190925/20120614Xmg9NyQI7t.png

https://ithelp.ithome.com.tw/upload/images/20190925/20120614PhBNDyv8xG.png

可以看到,範例程式碼裡面的子類別不需要進行覆蓋父類別方法的動作,而是藉由選擇策略(也就是演算法)的方式進行功能的實踐

以下照常進行程式碼的測試(跟之前的測試程式碼一模ㄧ樣,沒有修改,結果如圖八)。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614PPQDgiTfQa.png
圖八:藉由策略模式,只要簡單地定義不同的策略演算法,子類別就只需要指定其中一種策略就可以被實踐出功能

如果將類別與介面的關係畫成圖的話,結構會如圖九。

https://ithelp.ithome.com.tw/upload/images/20190925/20120614wp2iXaFmJp.png
圖九:使用策略模式後,類別與介面的關係圖~

筆者就把本日實踐策略模式的過程簡單的敘述出來:

  • 父類別 Character 藉由參考點(Reference Point)連結 Attack 介面下的不同策略
  • 父類別 Character 實踐的 attack 方法會將角色的攻擊能力,由 attackRef 連結到的策略執行
  • 子類別可以自由選擇要使用的策略,並且將該策略指派到父類別早就定義好的參考點

以上讀者可以慢慢吸收~

小結

今天主要把整個策略模式的實踐過程,按照步驟地呈現給讀者看。

本篇章事實上還沒結束,有鑒於篇幅已經超過 10,000 字(網站的 Markdown 編輯器寫的XD,事實上應該快破 4,000 字而已),所以筆者將會以本篇的案例繼續延伸下去,讓讀者體會一下更多策略模式的優點喔~


上一篇
Day 25. 機動藍圖・類別與介面 X 終極的組合 - Ultimate Combo of Class & Interface
下一篇
Day 27. 機動藍圖・策略模式 X 臨機應變 - Strategy Pattern Using TypeScript. II
系列文
讓 TypeScript 成為你全端開發的 ACE!51

尚未有邦友留言

立即登入留言