iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 20
2
Modern Web

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

Day 20. 機動藍圖・類別繼承 X 延用設計 - TypeScript Class Inheritance

https://ithelp.ithome.com.tw/upload/images/20190921/20120614q5XdfWeFee.png

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

  1. 描述類別存取修飾子(Access Modifiers)的功能與意義。
  2. 為何類別要實踐某介面時,介面裡的所有規格在類別裡會直接綁定為 public 模式呢?

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

上一篇的筆者的例子裡提到:類別如果想要根據某個介面的設計進行實作的話,可以使用 implements 這個關鍵字 —— 使類別進行與介面的規格進行綁定的行為。詳細的類別與介面之間的協作過程會在 Day 25. 以後ㄧ併介紹,並且也會教讀者實踐簡單的設計模式,讓讀者認識 OOP 可以寫出多實用的程式碼!

回過頭來,本篇要講到前兩篇不停出現的東西 —— 繼承(Class Inheritance)的概念 —— 內容有點多,初學的讀者斟酌分次服用也可以,學習路上不勉強一定得跟上步伐,最終目的就是會理解然後會應用就好了。

繼承的語法跟介面的延伸(Extension)很像 —— 都是使用 extends 關鍵字,不過請讀者注意這兩個功能與意義是完全不同的:一個是對於類別的延伸(也就是繼承);另一個則是對於介面的延伸,所以才會除了有 Interface Extension 的說法外,也有介面的繼承 Interface Inheritance 的說法,但筆者傾向前者說法,因為 TypeScript 針對介面的延伸是用 extends 這個關鍵字。

而類別的繼承與類別實踐(Implements)介面的用法,兩者也是有相似的地方,但也有各自需要注意的使用情境。

不過本篇就先探討繼承的概念以及使用情境,否則一下子又在介面與類別之間切換來切換去 —— 筆者也被搞得很亂 XD。

貼心小提示

讀者可以到本系列的 GitHub Repo. 索取範例程式碼

廢話不多說,正文開始

類別的繼承 Class Inheritance

交通票務範例

這一次筆者以設計陽春的交通票務系統為例子,不過從本篇開始變得稍微複雜一些!

筆者立馬把本日主角類別 TicketSystem 變出來,程式碼如下。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614D4TmGJtjJq.png

簡單地描述一下類別 TicketSystem 裡面的內容:

  • 宣告 TransportTicketType列舉型別(Enumerated Type),代表的是交通票券的種類 —— 因為資料的獨特性(Uniqueness)與主觀認知的資料相關性(Similarity)皆符合,因此判斷採用列舉
  • 宣告 TimeFormat元組型別(Tuple),代表的是時間的格式,依順序分別代表小時、分鐘與秒鐘
  • 類別 TicketSystem 內含的成員變數 Member Variables:
    • startingPointdeparture 分別代表啟程點與終點,皆為 string 型別與 private 模式
    • departureTime 代表啟程時間,為 Date 型別以及 private 模式
  • 類別 TicketSystem 內含的成員方法 Member Methods:
    • deriveArrivalTime 負責計算抵達時間,因此回傳的結果是 Date —— 但由於是 private 模式,所以只能在類別內部使用
    • deriveDuration 負責計算通車過程所需要經過的時間,為 TimeFormat 型別以及 private 模式;其中,因為交通方式有三種,因此我們選擇這裡先寫死回傳的時間,固定為 1 小時,也就是 [1, 0, 0]
    • getTicketInfo 則是將票券的資料都列印出來,為 public 模式,因此可以在任何地方使用

貼心小提示

有進階 OOP 概念的讀者,可能會選擇使用抽象類別將 deriveDuration 或視情形,對其他成員轉換成抽象成員。

看不懂這個小提示的讀者們可以選擇跳過,不會影響本篇文章的內容。

抽象類別會在 Day 28. 介紹。

讀者應該發現,幾乎所有的成員變數弄成 private 模式,其中一個原因是:OOP 模式很直觀,每次定義出新物件就有它的屬性(Properties)以及可做出的行為 —— 呼叫方法(Methods)。然而,屬性很容易被竄改,這就是所謂的物件的變異(Mutation)

但 OOP 令人詬病的地方也是因為太自由,開發者隨隨便便一行就可以更改物件內部屬性或者是給它型別錯誤的值,呼叫該物件的方法出錯的機率會提高;光是要去找到 Bug 可能得逐行檢查。如果是數個物件這樣呼叫來呼叫去,這樣子要找到 Bug 所花費的時間一定更多,因此不太建議讓開發者擅自竄改物件的屬性 —— 也就是讓類別的成員變數設定為 public 模式。

為了防止開發者亂動物件的屬性 —— 設計類別時,盡量將成員的變數們設定為 private 模式,這就是將功能包裝(Encapsulation)的意義

通常我們會提供成員方法供開發者外部使用,此時就會用 public 模式。例如:如果想要讓開發者知道該物件的內容,與其讓開發者可以接觸成員變數們,可以選擇寫個公用方法(Public Method)讓開發者去呼叫,裡面的方法可能就包含該物件的內容,而這裡的 TicketSystem 中的成員方法 getTicketInfo 就是一個例子。

筆者可以開始使用這個票務系統的類別建立簡單的火車票券,程式碼如下。

https://ithelp.ithome.com.tw/upload/images/20190920/201206144Kn8ld49j1.png

(這邊有一點需要注意:Date 物件裡,月份部分的值是從 0 開始計算(代表一月),也就是說範例裡的 new Date(2019, 8, 1) 代表的是 2019 年的 9 月 1 日)

因此我們經過 TypeScript 編譯器編譯過後並且由 node 執行之結果(如圖一)。

https://ithelp.ithome.com.tw/upload/images/20190920/201206142cVgdbncW8.png

以上的範例建立了一張簡單的火車票!但這時筆者必須提出幾個疑點:

  1. 每一次建立票券時,必須將票券的種類標示出來(好長一串的 enum
  2. 火車、捷運以及航空計算站點的方式不同,難道要直接在 deriveDurationif...else... 根據票根交通種類進行站點與站點間的行車時間運算嗎?
  3. 應該會有一個表亦或是根據站點路線圖 —— 也就是根據站與站之間的間隔,自動換算出啟程站與終點站的間隔時間,不可能手動打進程式碼(這樣的票務系統未免太白癡了,用紙本搞不好比較快?)

本篇將示範:使用類別的繼承(Inheritance),創造出好用一點的火車票的票務系統。(當然也可以選擇設計其他交通種類的票券,但筆者擇其一作為示範)

火車票券類別的前置作業

本節目標是設計出一個 TrainTicket 類別,其中的部分功能來自於 TicketSystem 類別

首先,把 TrainTicket 類別宣告出來,並且制訂站點間的路線對應與間隔時間的表,請看以下程式碼。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614ch9yNAD8P4.png

筆者一樣先定義站點的靜態格式 TrainStation —— 使用的是型別 type 而不是介面 interface 喔!

其中 TrainStation 有三個屬性:

  • name 代表站點名稱,為 string 型態
  • nextStop 代表下一站名稱,為 string 型態
  • duration 代表本站點跟下一站之間的間隔時間(筆者這邊亂填,舉個例子而已XD),為 TimeFormat 型態

另外,在 TrainTicket 類別裡面,筆者宣告一個簡單的車站資訊對應表 stationsDetail,並標記為 private 模式,為 TrainStation[] 型別—— 代表的是一系列的車站站點的資訊。

貼心小提示

此範例舉的是臺灣北向行的車票,實際上要設計一個票務系統,你可能還會遇到南向行亦或者是來回票這些狀況。但為了減少本例子的複雜度,筆者就簡化為北向行的車票作為範例。另外,實際上規劃路線時,更正確的資料結構應該是採用圖(Graph)會比較適合喔!

另外,stops 代表所有的火車站站點,這裡也是設為 private 模式,型別為 string[]

我們還需要一個方法專門判斷火車票券的站點名稱沒有錯誤,因此筆者在 TrainTicket 內定義新的函式:isStopExist,此函式為 (stop: string): boolean 型態 —— 專門檢查站點是不是存在。如果不存在就回傳 false

https://ithelp.ithome.com.tw/upload/images/20190920/20120614j3E5UjtfIz.png

下一個項目對讀者來說比較複雜一些。

原本 TicketSystem 類別有 deriveDuration:專門計算票券之起點站與抵達站之間所需耗費的交通時間的方法。

我們也可以在 TrainTicket 裡實踐類似的機制(取代父類別的 deriveDuration 方法)—— 專門為火車站點這個應用做特別設計,因此筆者將實踐結果直接貼出來如下:

https://ithelp.ithome.com.tw/upload/images/20190920/20120614vjdc1FLx3x.png

筆者簡單描述設定為 private 模式的 deriveDurationTrainTicket 類別裡面會怎麼跑,以下舉兩種狀況:

  • 從台南 Tainan 到新竹 Hsinchu 會經過 Tainan -> Taichung -> Hsinchu,根據定義過的 stationsDetail,交通時間結果必須累加兩種 TimeFormat[3, 20, 0][2, 30, 30],累加結果為 [5, 50, 30]
  • 從高雄 Kaohsiung 到台北 Taipei 會經過 Kaohsiung -> Tainan -> Taichung -> Hsinchu -> Taipei,根據定義過的 stationsDetail,交通時間必須累加四種 TimeFormat[1, 45, 30][3, 20, 0][2, 30, 30] 以及 [1, 30, 30],加總結果為 [7, 120, 90] —— 然而,必須遵守分鐘跟秒鐘是六十進制的特性,轉換成合理的時間格式,因此結果為 [9, 6, 30]

知道運作流程後,這裡筆者得先指出 deriveDuration 方法裡:

https://ithelp.ithome.com.tw/upload/images/20190920/20120614uOdPVUFVvk.png

我們有使用到 destination 以及 startingPoint 這兩個值,其為 TicketSystem 類別早就有宣告過的成員變數。

如果想要讓 TrainTicket 可以使用 TickeySystem 的功能,因此必須進行類別繼承的動作

使用類別繼承 Inheritance

重點 1. 類別的繼承 Class Inheritance

若宣告類別 C ,其程式碼實踐結果如下:

https://ithelp.ithome.com.tw/upload/images/20190919/20120614ef95r4hoeN.png

另外宣告類別 D,然後 DC 進行類別的繼承(Inheritance),因此必須用 extends 語法,其寫法如下:

https://ithelp.ithome.com.tw/upload/images/20190930/201206148EduEvp7NO.png

我們稱 D 類別繼承了 C 類別

其中,CD父類別(Parent Class/Superclass);相對地,DC子類別(Child Class/Subclass)。

D 類別具有以下的特性:

  1. D 類別可以使用 C 類別非 private 模式的成員變數與方法們(D 除了 PprivateMprivate 外,其他成員都可以使用)
  2. D 類別建造出來的物件(使用 new),該物件的型別除了屬於 D 類別以外 —— 由於繼承的關係,該物件的型別也同時屬於類別 C
  3. 相對地,C 類別所建造出來的物件型別為 C 類別,但不屬於 D 類別

貼心小提示

類別的型別推論與註記的機制將會在 Day 24. 介紹。然而,筆者倒是可以先劇透一下:

類別(Class)與 TypeScript 介面(Interface)本身也是型別化名(Type Alias)的一種~”

另外也要注意一點,介面的擴充(Extension)以及類別的繼承(Inheritance)用的都是 extends 關鍵字,但這兩個東西是兩回事,不能相提並論,只能說機制很像,但對象不同罷了。

以下開始使用類別的繼承。

首先將 TrainTicketTicketSystem 進行繼承的動作。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614sZXk6eMl6v.png

筆者就先貼上到目前為止的程式碼的狀況。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614kXqyylHKwX.png

以上的程式碼一定會被 TypeScript 發出警訊。筆者是故意要讓錯誤出來的,為的是要講解類別繼承的一些重要規則 —— 從檢視錯誤訊息的過程可以學到很多東西

以下幾節就是介紹運用類別繼承時,通常會遇到的問題。

protected 存取模式的應用

首先,第一個錯誤出現就在進行繼承的那一剎那 —— TrainTicket 類別馬上被 TypeScript 警告。(錯誤狀態如圖二所示;而錯誤訊息如圖三)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614uHPs1hHKnW.png
圖二:進行繼承的那一剎那,我們就被 TypeScript 警告

https://ithelp.ithome.com.tw/upload/images/20190920/20120614bkvv5dO9Bc.png
圖三:TypeScript 告訴我們,類別 TrainTicket 繼承時出現的錯誤原因是 —— deriveDuration 這個模式是 private 模式

讀者看到圖三的訊息,TypeScript 告訴我們:“deriveDuration 在父類別被設定成 private 模式,因此不能被使用。” 這一點在本篇重點ㄧ就有提到:

D 繼承 C 類別的情形下)D 具有以下的特性:

  1. D 類別可以使用 C 類別非 private 模式的成員變數與方法們(D 除了 PprivateMprivate 外,其他成員都可以使用)
  2. (...略)

繼承過後的子類別不能使用父類別的 private 成員

於是這裡就產生一個問題了:

我們要如何能夠同時封裝好類別成員們,不被外部使用,但又能夠給繼承的子類別們定義(或是覆寫父類別)的成員呢

這裡的答案就是使用 protected 存取模式

重點 2. protected 存取模式

當類別成員被標示為 protected 模式下,該成員儘管不能被外部取用,但可以在當前類別以及子類別的範圍內使用

protected 存取模式跟 private 的差別就只有一個:能不能在繼承的子類別裡使用該成員 —— 標示為 protected 存取模式即代表可以。

因此筆者將 TicketSystem 以及 TrainTicket 裡的 deriveDuration 成員方法改成 protected 模式。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614YhkU3YIaPt.png

可以看到剛剛標示在 TrainTicket 底下的錯誤警告消失了。(如圖四)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614knbC2DNGl1.png

父類別建構子函式 Parent Class Constructor Function

不過我們還沒完全解決完畢!筆者提出另一個問題:

若從 TrainTicket 類別(它是本案例中的子類別)建立出物件,由於 TrainTicket 擁有其父類別 TicketSystem 的成員變數與方法們。

但如果藉由子類別去創建物件時,如何將成員變數們進行初始化的動作

畢竟 TrainTicket 的成員變數是被繼承過來的,然而那些初始化的邏輯都是被寫在父類別裡面,也就是 TicketSystem

因此這裡必須要有一個管道專門從子類別去連結父類別的建構子函式進行物件成員變數初始化的動作

而這個管道就是 —— 名為 super 的關鍵字。

重點 3. 類別繼承中的 super

由於子類別繼承了父類別的成員,通常也會需要進行初始化物件的動作。其中,與父類別進行溝通的管道就是 super

在子類別裡,super 可以等效於父類別的建構子函式

還記得一開始使用的 TicketSystem 類別,光是要初始化一張交通票券,會這樣寫:

https://ithelp.ithome.com.tw/upload/images/20190920/20120614bAaapOA1ai.png

其中,new TicketSystem(...) 這段是負責從 TicketSystem 建立票券的物件。而 TicketSystem 本身就是類別建構子函式

那麼我們如何使用 super 來連結到父類別並填入物件初始化資料呢?

根據重點 3. 的描述:我們可以在子類別裡的建構子函式內,使用 super並且將 super 看成父類別的建構子函式

https://ithelp.ithome.com.tw/upload/images/20190920/20120614HQCqX9b4i2.png

運用 super 來呼叫父類別的建構子函式時,必須按照父類別建構子函式的型別,填入正確順序的參數

資深開發者可能會問:“有時父類別會被放置在其他的檔案,想要在龐大的專案找父類別的建構子函式的內容豈不是很痛苦?更何況這裡的參數有四個,也是很容易被搞錯的。”

我們可以依靠簡單的技巧取得父類別建構子的型別資訊 —— 請將鼠標指向 super 這個關鍵字就會出現如圖四的畫面。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614V9kzpEwIHz.png
圖四:super 關鍵字被鼠標指的時後,它會提供父類別建構子的資訊

你可以看到 super 關鍵字彈出的視窗很明確地說 —— constructor TicketSystem後面接的是初始化物件所需要的參數,型別對照也寫得非常清楚。

因此這裡筆者提醒:重點是要會利用工具帶來的便利性省去翻程式碼的麻煩

其他的小錯誤

最後還有一個小 Bug 還沒有解決 —— 在 deriveDuration 函式裡有使用到父類別的 private 模式下的成員變數:startingPoint 以及 destination

如果我們查看 TrainTicket 裡面的函式,TypeScript 自動地幫我們標註錯誤。(錯誤警告如圖五;訊息如圖六)

https://ithelp.ithome.com.tw/upload/images/20190921/20120614F9LtgFc9Y4.png
圖五:TypeScript 自動幫我們標註 startingPointdestination 部分有潛在錯誤

https://ithelp.ithome.com.tw/upload/images/20190921/20120614asYG0SLLmK.png
圖六:TS 很貼心地提醒我們,父類別 TicketSystemdestinationstartingPoint 成員皆為 private 模式,因此不能在子類別使用

看到這裡的讀者應該也會覺得要處理掉這個錯誤很簡單,就是把父類別的建構子函式裡的 startingPointdestination 這兩個成員從 private 模式切換到 protected 模式,一切就 OK 了!

https://ithelp.ithome.com.tw/upload/images/20190921/201206149b1OJEpMGm.png

走到這裡,我們順利完成了 TrainTicket 類別的實踐 —— 筆者簡單新增一個火車票測試看看結果。(以下程式碼經過編譯並用 node 執行結果如圖八)

https://ithelp.ithome.com.tw/upload/images/20190921/20120614WUqh1XVZNF.png

https://ithelp.ithome.com.tw/upload/images/20190921/201206140QcOVxm1l3.png
圖八:成功地實踐出簡單的交通票券系統中的火車票部分

以上的程式碼,新增一張火車票 —— 只要標明啟程站與終點站(startingPointdestination)以及發車時間(departureTime),是不是符合平常買車票的邏輯呢?

我們也不需要再次實踐就可以使用父類別早就幫我們定義過的 TicketSystem 方法,因為被 TrainTicket 繼承下來了。

另外,父類別也早就幫我們把 arrivalTime 也都算好了,所以也不需要再重寫一次那些邏輯。

使用 super 的注意事項

最後的最後,筆者還是得講一下使用 super 語法的注意事項:

重點 4. 子類別的建構子注意事項

  1. 子類別的建構子函式裡,進行初始化物件時 —— 也就是**super 被呼叫之前**,由於物件還未建立完畢,不能有 this 相關的操作行為
  2. 假設宣告某類別 C,而另外一個類別 D 繼承 C。另外,C 類別的建構子函式裡擁有若干參數 ...args 並且子類別也沒有實作建構子函式時,則預設的子類別建構子函式的行為為:

https://ithelp.ithome.com.tw/upload/images/20190920/20120614Xk4lz0FcAa.png

第一點可能還可以理解,物件都沒建構出來怎麼可能去 Reference 到物件的實體(也就是 this)呢?(圖九)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614uIhTV1w1kG.png
圖九:錯誤訊息很明確就跟你說,在 super 被呼叫前,不能使用 this,因為物件還沒被導出來(用的是 Derive 這個單字)

然而,第二點要表達的東西可能會對眾多人感到陌生,這裡舉簡單的例子,單純確認 super 的行為。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614wUWzCIOjTB.png

以上的程式碼就是仿造 TrainTicket 繼承 TicketSystem 類別的方式:子類別 TestChildClass1 的建構子函式呼叫了 super —— 父類別 TestParentClass 的建構子函式,並且按照順序填入參數,初始化類別內繼承過來的成員變數。

回過頭來,我們來試試看 TestChildClass1 這個案例。(編譯以下程式碼並用 node 執行結果如圖十)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614zPRx3lyCQU.png

https://ithelp.ithome.com.tw/upload/images/20190920/20120614NBBsHrcabc.png
圖十:執行結果正常

那我們來換另一種情形 —— 繼承了父類別卻沒有實踐子類別的建構子函式。(也就是我們要講的重點 4. 的第二點)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614EKawRT7e1e.png

對,就這麼短 XD,不過我們還是ㄧ樣來測看看如何從子類別建構出物件,在這裡筆者刻意不填入任何參數在子類別的函式建構子內。

https://ithelp.ithome.com.tw/upload/images/20190920/20120614vLpxgqM7sD.png

這樣子的程式碼一定會出現錯誤訊息,因此筆者就直接把它貼出來。(如圖三)

https://ithelp.ithome.com.tw/upload/images/20190920/20120614mE83twCJPg.png
圖三:錯誤訊息明確跟你講少了哪些參數

就算你不對子類別自訂建構子函式,子類別會直接延用父類別建構子函式的規格,要求使用者必須代入建立物件時所需具備的參數

就算 TestChildClass2 沒有 constructor,它照樣要求你必須要依序代入 (p1: number, p2: string, p3: boolean) 這些值,所以 TestChildClass2 的建構子函式跟 TestChildClass1 的效果也沒什麼兩樣,讀者可以試試看進行驗證。

基本上讀者反覆看覺得很複雜,沒關係 —— 記下驗證過程的結論就好!畢竟那些重點只是以公式的方式表現出來罷了。

小結

簡而言之就是廢話心得一籮筐。

本篇文章其實完全超出筆者預料之外的難寫 —— 因為是隨時想出應用實例、隨時寫出解法,但也萬萬沒想到單純講繼承的相關功能會花掉筆者整整兩天的時間思考(就為了一個很笨的交通票務系統)。

要能夠將自身的知識轉換成文字真的頗困難的,筆者也是感到有點受挫。不過如果一直都在講語法,當然會覺得無聊,所以才會想說寫一段簡單應用。


上一篇
Day 19. 機動藍圖・存取修飾 X 藍圖規劃 - TypeScript Class Access Modifiers
下一篇
Day 21. 機動藍圖・靜態成員 X 即刻操作 - Static Properties & Methods
系列文
讓 TypeScript 成為你全端開發的 ACE!51

1 則留言

0
wilson652033
iT邦新手 5 級 ‧ 2020-10-06 10:02:23

您好
看不懂const {departureTime} = this;這一句是什麼意思
能解釋一下嗎?

它是 ES6 Destructuring 也就是 “解構式語法”,就是把物件的屬性拔出來變成一個變數

const { departureTime } = this;

就等於:

const departureTime = this.departureTime;

前者的寫法是不是比後者簡潔許多~

我要留言

立即登入留言