
閱讀本篇文章前,仔細想想看
- 試描述類別(Class)的型別推論機制與註記機制。
- 繼承過後的子類別,試描述其類別推論機制與註記機制。
- 子類別跟父類別的推論與註記機制交互錯用時的特殊規則是什麼?
如果還沒理解完畢的話,可以先翻看前一篇的文章喔!
昨天原本講到類別的型別推論與註記(Type Inference & Annotation)的機制,今天就來講一下類別跟介面的結合的種種情況。本篇也算是《機動藍圖》系列的大重點呢~
筆者寫到這裡也是覺得神奇 —— 我們才正開始要討論介面與類別的結合,但筆者在後續篇章會寫到簡單利用介面與類別結合一些 OOP 設計模式的應用。
以下正文開始!
首先,筆者好像之前有在某篇章使用過 implements 這個關鍵字,負責將類別綁定介面的規格 —— 今天就是要講這個!
按照一貫的步伐,筆者一定是從最基本的案例淺入深出地講起。
平常我們會直接宣告類別後直接進行開發的動作,但今天筆者會好好按照標準程序(Standard Procedure)—— 先把規格定義出來後,再進行實踐的動作:

以上的程式碼,先從最簡單的 ICharacter 介面開始 —— 第一個步驟,規格的確認完成。
介面 ICharacter 很陽春,就是:
name 代表角色名稱role 代表角色職業attack 是一個函式,目前輸入參數是 target,代表角色可以攻擊的對象,其中 —— target 參數代表的值,只要是任何實踐 ICharacter 介面的物件都可以接受
(接下來幾篇的主題就是陽春的 RPG 系統無誤!)
第二步驟 —— 類別與介面進行綁定的動作。
如果讀者有看過別人的文章,有些人會把類別與介面的結合形容成 —— 簽訂契約的概念(Signing Contract)。也就是說,類別一但跟介面綁定了,就必須實現介面裡描述的內容,否則會被 TypeScript 認定為違約。(你不會被罰款,但你會遭受到 TypeScript 的指控!)
綁定介面很簡單,就是使用 implements 這個單字:

筆者刻意在這裡貼出違約訊息。(如圖一)

圖一:Character 明顯違反了契約
Character 類別缺少了三個東西:name、role 以及 attack 這三個成員們。
所以第三步驟:實踐介面的規格(也就是契約內容)。以下的程式碼就是符合契約內容簡單的實踐:

貼心小提示
OOP 經驗豐富的讀者一看就知道 —— 冗長的
switch...case...敘述式的解法中 —— 其中一種就是使用 Strategy Pattern (策略模式)來解掉。筆者後續會寫這部分的設計模式篇章,畢竟這也會跟後面第 30 天的重頭戲 —— Object Composition 的概念有關~
以上筆者來測試看看使用結果。(以下程式碼編譯並使用 node 執行結果如圖二)


圖二:根據不同的職業,呼叫 attack 時會有不同的結果
重點 1. 類別對介面進行綁定
若已宣告類別
C與介面I,其中C想要對I進行綁定的動作,必須使用implements關鍵字。一但
C綁定了I,則類別C必須要實踐出介面I裡面的所有規格成員。
有些讀者肯定會對類別的繼承與介面的綁定感到迷糊 —— 這兩種到底差別差在哪?
其中,最大的不同就是:一個子類別一次只能繼承一個父類別;然而,一個類別可以跟多個介面進行綁定。
這也是介面的運用會比類別繼承還要更有彈性的主因。在軟體設計裡,時常討論到 —— 兩個系統的耦合程度(Coupling)中,使用類別繼承的耦合程度一定會比介面的綁定還來得高。
在父類別新增一個功能跟嵌入一個介面比起來,後者的難度會比較低。父類別要是新增一項功能,則必須確保所有的子類別能夠正常運作,否則會面臨到所有的子類別為了遷就父類別新增的功能必須進行覆寫的動作;另外,如果想要將父類別裡面的某些功能抽出來給其他程式碼或類別使用實在是不容易的事情。
嵌入介面是比較保險版本的新增功能方式,而且介面是可以被不同類別重複利用,不會像類別死死地把成員細節絕對綁定。除非類別跟父類別間的關係程度真的是很緊密,可以使用繼承,否則通常會使用介面來組出功能。
然而,OOP 設計模式裡,當然不侷限於使用介面的方式降低耦合程度,善用類別物件組織起來(Object Composition)而不使用類別繼承也可以達到降低相依的耦合度,這些都算是軟體江湖上流傳的招式 —— 筆者將在第 30 天揭曉。(不過講到策略模式時,就會讓讀者體會到不需經由類別繼承就可以達到耦合度的降低!
聽起來很好吃!)
假設 Character 除了實踐基本資料的介面 ICharacter 外,也還會有更多屬性,因此筆者再生出新的 IStats 介面。程式碼如下:

因此,如果想要同時讓 Character 跟 IStats 進行綁定的話,非常簡單 —— 就直接在 implements 後面再加上去就好 —— 如果至少有兩個介面以上,不同的介面就用逗號分隔。

TypeScript 會照常幫我們追蹤類別綁定介面時違約的部分。(如圖三)

圖三:很明顯,剛綁定 IStats 上去一定會出現錯誤呢 —— health、mana、strength 以及 defense 這幾個值都沒被實踐進去。
以下的程式碼進行簡單的實踐。(其實就是很懶惰的把值給丟上去 XD)

此外,繼承跟介面的綁定可以同時進行 —— 你可以在宣告類別時,使用 extends 進行繼承外,同時也對介面進行綁定喔!這部分筆者認為讀者可以去試試看,因此就放在重點ㄧ併整理起來吧。
重點 2. 類別的繼承與介面的綁定 Class Inheritance & Interface Implementation
類別繼承與介面綁定的最大差異是:
- 類別一次只能繼承一個父類別
- 類別可以同時實踐多個介面
若已宣告過某類別
C以及介面I1、I2、...In。其中,想要再宣告一個繼承父類別C的子類別D,並且與介面I1、I2、...In進行綁定的動作,程式寫法如下:
其中,
D類別的宣告因為繼承自C,因此D擁有C的所有public與protected模式下的成員。另外,由於D類別也有對介面I1、I2、...In進行綁定,因此必須實踐所有I1、I2、...In融合過後的結果之規格 —— 可以參見介面融合篇章。另外,類別繼承通常不容易將功能拆出來再利用,因此耦合程度較高;然而,因為介面的實踐是可以拆卸又裝到不同的類別上去,因此介面與類別的耦合程度較低以外,可再利用度較高。
前一篇已經講過單純的類別建構出來的物件之型別推論與註記機制,今天就順便把類別與介面綁定的案例討論完畢!
我們ㄧ樣使用剛剛的 Character 範例進行驗證的動作,其中 Character 同時有 ICharacter 與 IStats 這兩種介面的實踐。首先從最簡單的程式碼開始:

其中,character 的推論結果如圖四。

圖四:相信讀者感到不意外,推論結果就是 Character,如果熟悉前一篇討論的類別的型別推論機制就會覺得正常
然而,這裡真正要問的問題是 —— 變數若被註記為介面時,可以把實踐該介面的類別建立的物件指派進去嗎?
筆者試了一下底下的程式碼。(檢測結果如圖五)


圖五:檢測的結果是可以的,不過因為是被註記的變數,因此被推論為註記之介面 ICharacter
以下比較有註記跟沒註記的差別。(以下程式碼檢測結果如圖六;錯誤訊息如圖七)


圖六:很明顯地,被特別註記為介面 ICharacter 的變數不能夠呼叫 health 屬性的理由則是因為 health 不存在 ICharacter 介面

圖七:ICharacter 介面裡並不存在 health 屬性
這裡我們得到很重要的結論:儘管類別可能擁有多種不同的介面,若變數被註記到類別有實踐過的介面,該類別建構的物件可以被指派到該變數去。
重點 3. 類別綁定介面的推論與註記機制
任何類別
C—— 儘管有綁定介面I1、I2、...In,建構出來的物件之型別推論結果一律都是指向該類別C。若變數被積極註記為
I1、I2、...In中的任一介面 —— 該變數依然可以被指派類別C建構出來的物件。主要原因是 —— 被註記為介面型別的變數,只要該物件至少符合介面的實作,就算通過。變數被推論為類別
C或者是被積極註記為介面型別I1、I2、...In的差別在於:
- 如果變數被推論亦或者註記為
C,則變數除了可以呼叫類別裡自定義的public成員外,也可以呼叫介面I1、I2、...In融合過後的規格之屬性與方法。- 如果變數被註記為
I1、I2、...In介面裡其中一個介面Im,儘管變數可以被指派有實踐介面Im類別建構出來的物件,卻只能呼叫Im介面裡面的規格之屬性與方法。
通常會需要積極註記為介面而非讓 TypeScript 自動推論為類別的情形其實沒有想像中的少 —— 重點是這個特性:如果將變數積極註記為介面 I 時,任何類別如果有實踐 I,則該類別產出的物件就算是 I 介面可以接受的範疇。
譬如除了 Character 類別外,筆者還可以再宣告 Monster 這個類別為範例。(順便在 Role 的列舉型別內再塞一個 Monster,當然這不算是好的寫法,不過這裡的程式碼只是在展示重點 3 延伸出來的應用 XD)

以上的程式碼,筆者完整地給大家看到:Character 與 Monster 同時有實踐 ICharacter 介面。
筆者將焦點放在兩個類別裡實踐出來的 attack 成員方法:

讀者會發現,attack 方法的參數 —— target 並不是 Character 或者是 Monster 類別,而是被註記為 ICharacter 介面;這代表任何實踐過 ICharacter 介面的類別所建構的物件都可以被代入到 attack 方法作為 target 參數的值。(以下程式碼編譯並使用 node 執行結果如圖八)


圖八:對象只要是實踐過 ICharacter 的類別 —— 該類別創建出來的物件都可以被套入 attack 方法裡
重點 4. 積極註記介面型別的好處
任何被註記為介面
I的變數A或函式的參數P,只要有類別實踐過介面I,該類別建構出來的物件可以被代入到變數A或函式裡的參數P。
這一小節的名稱雖然又臭又長,筆者還是得說明一下同時繼承以及實踐介面的類別的型別推論與註記機制。其實只要熟悉前一篇提到的重點結合今天提出的重點,基本上不需要再為了本節的案例進行深入討論。
筆者乾脆再從剛剛的 Character —— 將它作為父類別,宣告其他類別對其繼承,這裡以 BountyHunter 作為子類別。

其中,BountyHunter 除了繼承父類別 Character 以外,還有額外的類別成員:
hostages 為成員變數,型別為 ICharacter[] 陣列型別,代表賞金獵人獵取到的人質(Hostage)capture 為成員方法,參數分別為 target 與 threshold,分別代表賞金獵人獵取到的目標物件以及機率sellHostages 也是成員方法,沒有任何輸入,負責賣掉人質賺取 $$。在實際測試前,運用今天學到的東西 —— 筆者把本篇章重點 3 開頭第一句話原封不動貼下來:
任何類別
C—— 儘管有綁定介面I1、I2、...In,建構出來的物件之型別推論結果一律都是指向該類別C。
BountyHunter 甚至沒有實踐任何介面,因此 new BountyHunter(...) 被建造出來後之型別推論結果絕對是 BountyHunter,這一點請讀者自行驗證。
另外,運用本篇學到的重點 4 :
任何被註記為介面
I的變數A或函式的參數P,只要有類別實踐過介面I,該類別建構出來的物件可以被代入到變數A或函式裡的參數P。
可以推斷:BountyHunter 的 capture 成員方法 —— 第一個參數 target 絕對可以代入 Character 或 Monster 類別建造的物件。因為 Character 與 Monster 都有實踐 ICharacter 這個介面,而 target 參數對應的型別就是 ICharacter 介面。
所以以下的程式碼 TypeScript 不會亂叫,編譯過後並且執行的結果如圖九。


圖九:結果這個賞金獵人連怪物都沒抓到
BountyHunter 沒有實踐介面 ICharacter,但它的父類別有,那 BountyHunter 型別的物件能不能夠代表 ICharacter 的值呢?
要測試這個其實不用再額外定義變數,直接用早已藉由 Character 建構的物件對 BountyHunter 建構的物件呼叫 attack 方法。不過筆者這邊直接貼出 TypeScript 判定結果(如圖十),理所當然是可以被接受的喔!

圖十:角色 Character 可以回擊 BountyHunter 呢!
那麼就算不是父類別但也有實踐 ICharacter 的 Monster 類別呢?(如圖十一)

圖十一:怪物 Monster 也可以回擊 BountyHunter 呢!
所以筆者得出結論:
重點 5. 類別繼承有實踐介面的父類別
子類別繼承父類別,除了擁有父類別
public與protected模式的成員外,也同時繼承父類別實踐之介面的性質
讀者試試看
這邊筆者認為不需要再討論的主要原因是 —— 可以藉由前一篇以及今天學到的重點推出這邊的程式碼的行為,因此才會放到讀者試試看這個單元。(不過以上的程式碼驗證,相信讀者應該也會推斷出來,非常簡單)
今天又是莫名超長篇,不過把介面跟類別的結合寫完之後,筆者頓時神清氣爽。
筆者原本沒有想要把策略模式放到系列文的,但既然自己都挖洞了。那就心甘情願跳下去吧
讀者能夠從中學到東西的話,筆者就已經感到值得~
文章中的這句:
先把介面宣告出來,確認規格(Speculation,時常被簡短為 Spec.)
上句中, 規格的單字應為specification
嗨~這部分看完後,有一些不同的想法:
我認為並非從「哪個 class 創建出 instance」來做判斷,
而只是單純從「該 instance 本身」來做判斷。
(而 method 與前面別篇文章提及的 function 的 object 參數,情況相同)
原因:
因為即便你不需從有 implements I1 的 class 來創建 instance,
只需該 instance 符合 I1 規定即可(包含主動註記 instance 的情況)。
舉例:
  interface I1 {
    x: number
  }
  
  class C1 implements I1 {
    constructor(public x: number) {}
    public m1(p1: I1) {}
  }
  class C2 {
    constructor(public x: number) {}
  }
  
  const instance1 = new C1(1)
  
  // Error: 不能有 y:1
  instance1.m1({ x: 1, y: 1 })
  
  // 以下皆非由 implements `I1` 的 class 所創建,但依然不會有 Error
  const p1 = { x: 1 }
  const p2 = { x: 1, y: 1 }
  const p3: I1 = { x: 1 }
  const p4: C2 = { x: 1 }
  instance1.m1(p1)
  instance1.m1(p2)
  instance1.m1(p3)
  instance1.m1(p4)