閱讀本篇文章前,仔細想想看
類別繼承與介面綁定的差別在哪裡?能夠描述它們各自的優缺點嗎?
如果還沒理解完畢的話,可以先翻看前一篇的文章喔!
筆者本來沒有要寫這一篇,自己卻不小心挖了這個坑,所以想說算了,就寫吧~
正文開始~
首先,在介紹設計模式中的策略模式前,要先了解通常是什麼情形才會需要策略模式。
筆者就把前一篇所寫過的範例快速帶過。
其中,以上的程式碼有個地方很冗長,那就是類別 Character
的 attack
成員方法的實踐內容。
一個遊戲如果不停擴充各種不同的角色種類,勢必會造成該 switch
的敘述式越來越龐大,再加上這個 Character
類別也沒有描述 —— 譬如,被攻擊的角色生命值是如何被扣損的,亦或者可能也沒有被命中敵人 —— 但要是把這個功能加上去可能只會造就越來越多義大利麵程式碼。
觀察一下程式碼,會發現一些特點:
switch...case...
,亦可用 if...else if...else
敘述式switch...case...
敘述式中,新增該角色職業的情形本篇目的是 —— 除了會基本的 interface
與 class
語法外,還能夠善用它們。因此,筆者把昨天(很陽春)的 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 專案大致上應該會長這樣。(圖一)
圖一:專案裡面除了 tsconfig.json
檔案以外,也有 package.json
設定檔與一些被更改的資訊
如果下 npm start
就會同時監測專案裡 TS 檔案並且進行編譯與執行 JS 的動作。以下筆者簡單更改 index.ts
並且自動執行結果如圖二與圖三。
貼心小提示
如果第一次執行
npm start
出現錯誤,找不到build/index.js
,可以先用tsc
編譯一次後再重新執行npm start
,畢竟剛開始找不到build/index.js
檔案,所以node
也沒辦法執行編譯後的檔案。
圖二:第一次執行過後的結果
圖三:更改 index.ts
內容,不需要進行編譯即可自動動作
首先額外建立一個資料夾名為 characters
:你可以使用 mkdir characters
或直接在編輯器裡面新增資料夾也可以。
新增 characters/Role.ts
檔案,內容如下:
主要就是將職業內容使用列舉型別後,再 export
出去。讀者若不熟悉 import/export
與 default import/export
語法請記得上網查一下喔~)
然後新增 characters/Character.ts
檔案,內容如下:
Character
類別只有兩個成員變數以及一個成員方法:
name
是角色名稱,所以為 string
role
則是角色職業,為 Role
列舉型別;由於 Role
被定義在其他檔案,因此必須載入進來(這裡是用 default import
方式載入)筆者在 index.ts
裡,將兩個模組載入後,利用簡單的程式碼進行測試。(結果如圖四)
接下來才是進入問題的開始:角色職業有四種(筆者本篇會以其中兩種為例)—— 每種職業都有相似的模式,譬如:
health
、魔力值 mana
等等MeleeAttack
、魔法攻擊 MagicAttack
(當然也可以再細分)這裡筆者就先繼續寫下去,寫到有問題出現時 —— 進行分析後,再來看看如何解決。
首先,我們可以利用繼承 Character
的方式建構出角色們 —— 新增 characters/Swordsman.ts
以及 characters/Warlock.ts
,內容如下:
圖五是對程式碼進行簡單的檢測結果。
圖五:Swordsman
與 Warlock
是可以正常使用的類別
到這裡應該沒問題 —— 接下來,筆者先用不好的方式呈現 attack
方法的實踐過程。
大部分使用類別繼承的想法,不外乎是因為父類別跟子類別的關係是很緊密的,因此父類別定義新的成員,子類別也自動擁有父類別定義的該成員。
因此,父類別若新增 attack
方法,則子類別也會跟著擁有 attack
方法。(圖六為程式碼檢測成果)
圖六:子類別繼承了父類別的成員,因此可以使用 attack
方法
若希望每個子類別攻擊的方式不同,通常最直觀的作法就是 —— 直接在子類別內覆蓋父類別的方法。
因此筆者將 Warlock
的 attack
方法進行修改的動作。
(其實筆者後來重新看過,英文連接詞 and
前後文法應該要一致... 不過想說算了不改了 XD)
理所當然,測試 Warlock
類別的 attack
方法會出現不ㄧ樣的結果喔~(如圖七)
圖七:Warlock
攻擊時,變成使用魔法攻擊呢~
這裡就碰到了問題點:儘管利用繼承的方式避開了 switch...case...
這個冗長判斷式的解法,然而,取而代之的只是 —— 宣告更多子類別然後對父類別的方法進行覆蓋的動作。
這實在是不行啊!治標不治本,搞不好也有其他職業 —— 假設我們又有新的職業為 Occultist
(神秘學者 —— 筆者隨便舉的一個職業),它也會使用魔法攻擊,難道又得從 Warlock
裡面將 attack
方法照本宣科複製到 Occultist
類別嗎?於是這裡就違反了 DRY(Don't Repeat Yourself)的原則。
因此,筆者今天就要搬出今天的主角:策略模式 —— Strategy Pattern!
筆者上網查詢,通常會出現的一句話代表策略模式的意涵:
Changing algorithm during runtime.
英文到底是要用介系詞 During 還是 On,筆者覺得看完本篇文章再去自己查詢文法正不正確,反正在本系列,學到工具的用法與真諦最重要。
其實簡單來說:
策略模式的意義在於 —— 根據不同情形,在程式執行時可以靈活地轉換演算法(策略)而不需要再另訂新的類別與類別繼承的動作
另外,以下會用個人見解對策略模式進行描述,如果讀者看得懂就表示你可能早就學過亦或者是你是萬年以來的天才 —— 筆者也願意把膝蓋割下來跪在你面前!(好痛)
筆者想要試試看從概念上切入再一步步實踐策略模式,因此筆者認為讀者對以下重點剛開始看不懂是不意外的,讀者也可以選擇先跳過以下的重點直接往後面的步驟看,等熟悉策略模式的設計手法後再回頭看看筆者寫的重點也 Ok。
本篇唯一重點. 策略模式的意涵
如果要在眾多類別中實踐近似但相異的行為,與其直接實踐(implement)出功能並使用一連串的敘述式進行演算法的切換,不如在父類別裡建立一個行為演算法的參考點(reference point),任何符合該參考點的演算法必須遵照某介面(interface)進行實踐的動作;父類別可以藉由在該參考點切換演算法,不需要經過一連串判斷流程,就可以達到功能相異的結果。
而父類別的參考點切換演算法的過程,又被稱作為切換不同策略的行為,因此得名 —— 策略模式。
可能看完這一段,會很想吐槽筆者:“這個作者為何要那麼麻煩地把一個概念拐彎抹角的描述出來?”
筆者知道以上的意涵很模糊,但那是因為我們還沒討論到以下筆者提出來的問題:
其實筆者刻意留了一些線索進去,我們來仔細推斷。
首先,參考點的概念在第 2 點有被提示到 —— 由父類別去定義一個參考點,也就是說:
參考點(reference point)是父類別的成員之一
二來,第 3 點有說到:“符合該參考點的演算法 —— 也就是策略”;而後又有一句:“策略必須跟介面進行綁定的動作”,也就是說:
策略並不是函式或者是方法,因為要能夠和介面進行綁定;不過也從策略必須能夠綁定介面這個特點,得知 —— 策略是一個類別的宣告
所以再回到含糊的重點那一段,其中:“父類別可以藉由在該參考點切換演算法,不需要經過一連串判斷流程,就可以達到功能相異的結果”,代表父類別使用參考點進行演算法的切換,達到切換不同功能的目的。
以上推論過程不清楚也沒關係,這裡筆者就要開始以上面簡短推論出來的結果對 Character
類別系列進行新增角色攻擊的能力。
首先,筆者必須先宣告一連串的策略(Strategies,亦或者演算法)。其中,每一個策略必須綁定某介面以確保實踐出來的功能是固定,但內部的演算可以是不ㄧ樣的。
筆者先建立一個資料夾名為 abilities
。根據角色的攻擊能力這一項功能 —— 宣告 Attack
這個介面並且放在 abilities/Attack.ts
這個檔案,內容如下。
再來筆者可以開始定義不同的攻擊策略,以下就以 MeleeAttack
與 MagicAttack
為範例,各自為 abilities/MeleeAttack.ts
與 abilities/MagicAttack.ts
。
定義好策略後,接下來就是在父類別建立起參考點(Reference Point)—— 該參考點負責的任務就是進行策略的切換!
由以上程式碼得知,參考點其實只是一個類別屬性 attackRef
負責連結到 Attack
型別的物件;此外,筆者也屏棄掉原本在父類別的 attack
成員方法。
這個步驟很簡單就這樣被結束了 XD。
在父類別裡,因為我們的介面 Attack
絕對會有 attack
方法,儘管裡面的內容並不知道(可能會是 MeleeAttack
或 MagicAttack
)—— 但這根本不重要,我們只要知道角色有 Attack
的策略可以呼叫就夠了!因為每個策略綁定了介面,確保都有 attack
方法進行實踐。
因此,原本的 attack
方法可以藉由參考點 attackRef
指定到的若干策略進行呼叫 attack
方法的動作:
我們就快完成了!
接下來,可以為各種子類別進行策略的選擇喔!譬如:Swordsman
選擇的攻擊策略是 MeleeAttack
;相對的,Warlock
選擇的則是 MagicAttack
。
可以看到,範例程式碼裡面的子類別不需要進行覆蓋父類別方法的動作,而是藉由選擇策略(也就是演算法)的方式進行功能的實踐。
以下照常進行程式碼的測試(跟之前的測試程式碼一模ㄧ樣,沒有修改,結果如圖八)。
圖八:藉由策略模式,只要簡單地定義不同的策略演算法,子類別就只需要指定其中一種策略就可以被實踐出功能
如果將類別與介面的關係畫成圖的話,結構會如圖九。
圖九:使用策略模式後,類別與介面的關係圖~
筆者就把本日實踐策略模式的過程簡單的敘述出來:
Character
藉由參考點(Reference Point)連結 Attack
介面下的不同策略Character
實踐的 attack
方法會將角色的攻擊能力,由 attackRef
連結到的策略執行以上讀者可以慢慢吸收~
今天主要把整個策略模式的實踐過程,按照步驟地呈現給讀者看。
本篇章事實上還沒結束,有鑒於篇幅已經超過 10,000 字(網站的 Markdown 編輯器寫的XD,事實上應該快破 4,000 字而已),所以筆者將會以本篇的案例繼續延伸下去,讓讀者體會一下更多策略模式的優點喔~