iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 25
2
Modern Web

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

Day 25. 機動藍圖・類別與介面 X 終極的組合 - Ultimate Combo of Class & Interface

  • 分享至 

  • twitterImage
  •  

https://ithelp.ithome.com.tw/upload/images/20190924/20120614WN2kWgYmCd.png

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

  1. 試描述類別(Class)的型別推論機制與註記機制。
  2. 繼承過後的子類別,試描述其類別推論機制與註記機制。
  3. 子類別跟父類別的推論與註記機制交互錯用時的特殊規則是什麼?

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

昨天原本講到類別的型別推論與註記(Type Inference & Annotation)的機制,今天就來講一下類別跟介面的結合的種種情況。本篇也算是《機動藍圖》系列的大重點呢~

筆者寫到這裡也是覺得神奇 —— 我們才正開始要討論介面與類別的結合,但筆者在後續篇章會寫到簡單利用介面與類別結合一些 OOP 設計模式的應用。

以下正文開始

類別與介面的終極組合

類別實踐介面的規格

首先,筆者好像之前有在某篇章使用過 implements 這個關鍵字,負責將類別綁定介面的規格 —— 今天就是要講這個!

按照一貫的步伐,筆者一定是從最基本的案例淺入深出地講起。

平常我們會直接宣告類別後直接進行開發的動作,但今天筆者會好好按照標準程序(Standard Procedure)—— 先把規格定義出來後,再進行實踐的動作:

  1. 先把介面宣告出來,確認規格(Speculation,時常被簡短為 Spec.)
  2. 將類別對介面進行綁定
  3. 類別必須實踐介面規範的規格

https://ithelp.ithome.com.tw/upload/images/20190924/20120614e60fg8GUes.png

以上的程式碼,先從最簡單的 ICharacter 介面開始 —— 第一個步驟,規格的確認完成。

介面 ICharacter 很陽春,就是:

  • name 代表角色名稱
  • role 代表角色職業
  • attack 是一個函式,目前輸入參數是 target,代表角色可以攻擊的對象,其中 —— target 參數代表的值,只要是任何實踐 ICharacter 介面的物件都可以接受

接下來幾篇的主題就是陽春的 RPG 系統無誤!

第二步驟 —— 類別與介面進行綁定的動作。

如果讀者有看過別人的文章,有些人會把類別與介面的結合形容成 —— 簽訂契約的概念(Signing Contract)。也就是說,類別一但跟介面綁定了,就必須實現介面裡描述的內容,否則會被 TypeScript 認定為違約。(你不會被罰款,但你會遭受到 TypeScript 的指控!)

綁定介面很簡單,就是使用 implements 這個單字:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614MxcbPWO8II.png

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

https://ithelp.ithome.com.tw/upload/images/20190924/20120614cBBNExAhvQ.png
圖一:Character 明顯違反了契約

Character 類別缺少了三個東西:namerole 以及 attack 這三個成員們。

所以第三步驟:實踐介面的規格(也就是契約內容)。以下的程式碼就是符合契約內容簡單的實踐:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614WHcoDU37il.png

貼心小提示

OOP 經驗豐富的讀者一看就知道 —— 冗長的 switch...case... 敘述式的解法中 —— 其中一種就是使用 Strategy Pattern (策略模式)來解掉。筆者後續會寫這部分的設計模式篇章,畢竟這也會跟後面第 30 天的重頭戲 —— Object Composition 的概念有關~

以上筆者來測試看看使用結果。(以下程式碼編譯並使用 node 執行結果如圖二)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614L5WdHgSaOJ.png

https://ithelp.ithome.com.tw/upload/images/20190924/20120614grphqLszb0.png
圖二:根據不同的職業,呼叫 attack 時會有不同的結果

重點 1. 類別對介面進行綁定

若已宣告類別 C 與介面 I,其中 C 想要對 I 進行綁定的動作,必須使用 implements 關鍵字。

一但 C 綁定了 I,則類別 C 必須要實踐出介面 I 裡面的所有規格成員

https://ithelp.ithome.com.tw/upload/images/20190924/20120614OjwjWDWVl3.png

類別繼承與介面綁定最大的不同 Class Inheritance V.S. Interface Implementation

有些讀者肯定會對類別的繼承介面的綁定感到迷糊 —— 這兩種到底差別差在哪?

其中,最大的不同就是:一個子類別一次只能繼承一個父類別;然而,一個類別可以跟多個介面進行綁定

這也是介面的運用會比類別繼承還要更有彈性的主因。在軟體設計裡,時常討論到 —— 兩個系統的耦合程度(Coupling)中,使用類別繼承的耦合程度一定會比介面的綁定還來得高

在父類別新增一個功能跟嵌入一個介面比起來,後者的難度會比較低。父類別要是新增一項功能,則必須確保所有的子類別能夠正常運作,否則會面臨到所有的子類別為了遷就父類別新增的功能必須進行覆寫的動作;另外,如果想要將父類別裡面的某些功能抽出來給其他程式碼或類別使用實在是不容易的事情。

嵌入介面是比較保險版本的新增功能方式,而且介面是可以被不同類別重複利用,不會像類別死死地把成員細節絕對綁定。除非類別跟父類別間的關係程度真的是很緊密,可以使用繼承,否則通常會使用介面來組出功能。

然而,OOP 設計模式裡,當然不侷限於使用介面的方式降低耦合程度,善用類別物件組織起來(Object Composition)而不使用類別繼承也可以達到降低相依的耦合度,這些都算是軟體江湖上流傳的招式 —— 筆者將在第 30 天揭曉。(不過講到策略模式時,就會讓讀者體會到不需經由類別繼承就可以達到耦合度的降低!聽起來很好吃!

假設 Character 除了實踐基本資料的介面 ICharacter 外,也還會有更多屬性,因此筆者再生出新的 IStats 介面。程式碼如下:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614715D6FDv4h.png

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

https://ithelp.ithome.com.tw/upload/images/20190924/20120614W5kNoWxOmZ.png

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

https://ithelp.ithome.com.tw/upload/images/20191005/2012061447mg07ushG.png
圖三:很明顯,剛綁定 IStats 上去一定會出現錯誤呢 —— healthmanastrength 以及 defense 這幾個值都沒被實踐進去。

以下的程式碼進行簡單的實踐。(其實就是很懶惰的把值給丟上去 XD)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614adLR07MviA.png

此外,繼承跟介面的綁定可以同時進行 —— 你可以在宣告類別時,使用 extends 進行繼承外,同時也對介面進行綁定喔!這部分筆者認為讀者可以去試試看,因此就放在重點ㄧ併整理起來吧。

重點 2. 類別的繼承與介面的綁定 Class Inheritance & Interface Implementation

類別繼承與介面綁定的最大差異是:

  • 類別一次只能繼承一個父類別
  • 類別可以同時實踐多個介面

若已宣告過某類別 C 以及介面 I1I2、...In。其中,想要再宣告一個繼承父類別 C 的子類別 D,並且與介面 I1I2、...In 進行綁定的動作,程式寫法如下:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614hPZbp7XNcn.png

其中,D 類別的宣告因為繼承自 C,因此 D 擁有 C 的所有 publicprotected 模式下的成員。另外,由於 D 類別也有對介面 I1I2、...In 進行綁定,因此必須實踐所有 I1I2、...In 融合過後的結果之規格 —— 可以參見介面融合篇章

另外,類別繼承通常不容易將功能拆出來再利用,因此耦合程度較高;然而,因為介面的實踐是可以拆卸又裝到不同的類別上去,因此介面與類別的耦合程度較低以外,可再利用度較高。

類別綁定介面後的型別推論與註記機制

前一篇已經講過單純的類別建構出來的物件之型別推論與註記機制,今天就順便把類別與介面綁定的案例討論完畢!

我們ㄧ樣使用剛剛的 Character 範例進行驗證的動作,其中 Character 同時有 ICharacterIStats 這兩種介面的實踐。首先從最簡單的程式碼開始:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614MMWoO88CUX.png

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

https://ithelp.ithome.com.tw/upload/images/20190924/20120614fP5S7mX0Pg.png
圖四:相信讀者感到不意外,推論結果就是 Character,如果熟悉前一篇討論的類別的型別推論機制就會覺得正常

然而,這裡真正要問的問題是 —— 變數若被註記為介面時,可以把實踐該介面的類別建立的物件指派進去嗎

筆者試了一下底下的程式碼。(檢測結果如圖五)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614032pbKbSyn.png

https://ithelp.ithome.com.tw/upload/images/20190924/20120614euM5yWSvpL.png
圖五:檢測的結果是可以的,不過因為是被註記的變數,因此被推論為註記之介面 ICharacter

以下比較有註記跟沒註記的差別。(以下程式碼檢測結果如圖六;錯誤訊息如圖七)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614oCeQg4l1AO.png

https://ithelp.ithome.com.tw/upload/images/20190924/20120614Nv69rJHn5d.png
圖六:很明顯地,被特別註記為介面 ICharacter 的變數不能夠呼叫 health 屬性的理由則是因為 health 不存在 ICharacter 介面

https://ithelp.ithome.com.tw/upload/images/20190924/20120614oA9CNiYm9G.png
圖七:ICharacter 介面裡並不存在 health 屬性

這裡我們得到很重要的結論:儘管類別可能擁有多種不同的介面,若變數被註記到類別有實踐過的介面,該類別建構的物件可以被指派到該變數去。

重點 3. 類別綁定介面的推論與註記機制

任何類別 C —— 儘管有綁定介面 I1I2、... In建構出來的物件之型別推論結果一律都是指向該類別 C

若變數被積極註記為 I1I2、... In 中的任一介面 —— 該變數依然可以被指派類別 C 建構出來的物件。主要原因是 —— 被註記為介面型別的變數,只要該物件至少符合介面的實作,就算通過。

變數被推論為類別 C 或者是被積極註記為介面型別 I1I2、... In 的差別在於:

  • 如果變數被推論亦或者註記為 C,則變數除了可以呼叫類別裡自定義的 public 成員外,也可以呼叫介面 I1I2、... In 融合過後的規格之屬性與方法。
  • 如果變數被註記為 I1I2、... In 介面裡其中一個介面 Im,儘管變數可以被指派有實踐介面 Im 類別建構出來的物件,卻只能呼叫 Im 介面裡面的規格之屬性與方法

通常會需要積極註記為介面而非讓 TypeScript 自動推論為類別的情形其實沒有想像中的少 —— 重點是這個特性:如果將變數積極註記為介面 I 時,任何類別如果有實踐 I,則該類別產出的物件就算是 I 介面可以接受的範疇。

譬如除了 Character 類別外,筆者還可以再宣告 Monster 這個類別為範例。(順便在 Role 的列舉型別內再塞一個 Monster,當然這不算是好的寫法,不過這裡的程式碼只是在展示重點 3 延伸出來的應用 XD)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614UAcOAXpDqR.png

以上的程式碼,筆者完整地給大家看到:CharacterMonster 同時有實踐 ICharacter 介面。

筆者將焦點放在兩個類別裡實踐出來的 attack 成員方法:

https://ithelp.ithome.com.tw/upload/images/20190924/20120614DLcDMbWe11.png

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

https://ithelp.ithome.com.tw/upload/images/20190924/20120614EoiWa5R9kz.png

https://ithelp.ithome.com.tw/upload/images/20190924/20120614yY8fMKYJLt.png
圖八:對象只要是實踐過 ICharacter 的類別 —— 該類別創建出來的物件都可以被套入 attack 方法裡

重點 4. 積極註記介面型別的好處

任何被註記為介面 I 的變數 A 或函式的參數 P,只要有類別實踐過介面 I,該類別建構出來的物件可以被代入到變數 A 或函式裡的參數 P

繼承後的子類別同時綁定介面後的型別推論與註記機制

這一小節的名稱雖然又臭又長,筆者還是得說明一下同時繼承以及實踐介面的類別的型別推論與註記機制。其實只要熟悉前一篇提到的重點結合今天提出的重點,基本上不需要再為了本節的案例進行深入討論。

筆者乾脆再從剛剛的 Character —— 將它作為父類別,宣告其他類別對其繼承,這裡以 BountyHunter 作為子類別。

https://ithelp.ithome.com.tw/upload/images/20190924/20120614h8n1cj0KIL.png

其中,BountyHunter 除了繼承父類別 Character 以外,還有額外的類別成員:

  • hostages 為成員變數,型別為 ICharacter[] 陣列型別,代表賞金獵人獵取到的人質(Hostage)
  • capture 為成員方法,參數分別為 targetthreshold,分別代表賞金獵人獵取到的目標物件以及機率
  • sellHostages 也是成員方法,沒有任何輸入,負責賣掉人質賺取 $$。

在實際測試前,運用今天學到的東西 —— 筆者把本篇章重點 3 開頭第一句話原封不動貼下來

任何類別 C —— 儘管有綁定介面 I1I2、... In建構出來的物件之型別推論結果一律都是指向該類別 C

BountyHunter 甚至沒有實踐任何介面,因此 new BountyHunter(...) 被建造出來後之型別推論結果絕對是 BountyHunter,這一點請讀者自行驗證。

另外,運用本篇學到的重點 4 :

任何被註記為介面 I 的變數 A 或函式的參數 P,只要有類別實踐過介面 I,該類別建構出來的物件可以被代入到變數 A 或函式裡的參數 P

可以推斷:BountyHuntercapture 成員方法 —— 第一個參數 target 絕對可以代入 CharacterMonster 類別建造的物件。因為 CharacterMonster 都有實踐 ICharacter 這個介面,而 target 參數對應的型別就是 ICharacter 介面。

所以以下的程式碼 TypeScript 不會亂叫,編譯過後並且執行的結果如圖九。

https://ithelp.ithome.com.tw/upload/images/20190924/20120614ljVra82Fpj.png

https://ithelp.ithome.com.tw/upload/images/20190924/20120614gsPfq2jyNf.png
圖九:結果這個賞金獵人連怪物都沒抓到

BountyHunter 沒有實踐介面 ICharacter,但它的父類別有,那 BountyHunter 型別的物件能不能夠代表 ICharacter 的值呢?

要測試這個其實不用再額外定義變數,直接用早已藉由 Character 建構的物件對 BountyHunter 建構的物件呼叫 attack 方法。不過筆者這邊直接貼出 TypeScript 判定結果(如圖十),理所當然是可以被接受的喔!

https://ithelp.ithome.com.tw/upload/images/20190924/20120614uYhfZLva25.png
圖十:角色 Character 可以回擊 BountyHunter 呢!

那麼就算不是父類別但也有實踐 ICharacterMonster 類別呢?(如圖十一)

https://ithelp.ithome.com.tw/upload/images/20190924/20120614v98QsNPo4y.png
圖十一:怪物 Monster 也可以回擊 BountyHunter 呢!

所以筆者得出結論:

重點 5. 類別繼承有實踐介面的父類別

子類別繼承父類別,除了擁有父類別 publicprotected 模式的成員外,也同時繼承父類別實踐之介面的性質

讀者試試看

https://ithelp.ithome.com.tw/upload/images/20190924/20120614eXMBfjhnSA.png

這邊筆者認為不需要再討論的主要原因是 —— 可以藉由前一篇以及今天學到的重點推出這邊的程式碼的行為,因此才會放到讀者試試看這個單元。(不過以上的程式碼驗證,相信讀者應該也會推斷出來,非常簡單)

小結

今天又是莫名超長篇,不過把介面跟類別的結合寫完之後,筆者頓時神清氣爽。

筆者原本沒有想要把策略模式放到系列文的,但既然自己都挖洞了。那就心甘情願跳下去吧

讀者能夠從中學到東西的話,筆者就已經感到值得~


上一篇
Day 24. 機動藍圖・類別推論 X 註記類別 - Class Type Inference & Annotation
下一篇
Day 26. 機動藍圖・策略模式 X 選擇策略 - Strategy Pattern Using TypeScript. I
系列文
讓 TypeScript 成為你全端開發的 ACE!51
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
flier268
iT邦新手 5 級 ‧ 2020-12-24 15:15:42

讀者能夠從中學到東西的話,筆者就已經感到值得~

學到很多,感謝你

0
WILL.I.AM
iT邦新手 3 級 ‧ 2022-02-14 09:41:30

文章中的這句:
先把介面宣告出來,確認規格(Speculation,時常被簡短為 Spec.)

上句中, 規格的單字應為specification

0
iT邦新手 2 級 ‧ 2022-10-06 15:16:36

嗨~這部分看完後,有一些不同的想法:

我認為並非從「哪個 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)

我要留言

立即登入留言