閱讀本篇文章前,仔細想想看
- 描述類別存取修飾子(Access Modifiers)的功能與意義。
- 為何類別要實踐某介面時,介面裡的所有規格在類別裡會直接綁定為
public
模式呢?如果還沒理解完畢的話,可以先翻看前一篇文章喔!
上一篇的筆者的例子裡提到:類別如果想要根據某個介面的設計進行實作的話,可以使用 implements
這個關鍵字 —— 使類別進行與介面的規格進行綁定的行為。詳細的類別與介面之間的協作過程會在 Day 25. 以後ㄧ併介紹,並且也會教讀者實踐簡單的設計模式,讓讀者認識 OOP 可以寫出多實用的程式碼!
回過頭來,本篇要講到前兩篇不停出現的東西 —— 繼承(Class Inheritance)的概念 —— 內容有點多,初學的讀者斟酌分次服用也可以,學習路上不勉強一定得跟上步伐,最終目的就是會理解然後會應用就好了。
繼承的語法跟介面的延伸(Extension)很像 —— 都是使用 extends
關鍵字,不過請讀者注意這兩個功能與意義是完全不同的:一個是對於類別的延伸(也就是繼承);另一個則是對於介面的延伸,所以才會除了有 Interface Extension 的說法外,也有介面的繼承 Interface Inheritance 的說法,但筆者傾向前者說法,因為 TypeScript 針對介面的延伸是用 extends
這個關鍵字。
而類別的繼承與類別實踐(Implements)介面的用法,兩者也是有相似的地方,但也有各自需要注意的使用情境。
不過本篇就先探討繼承的概念以及使用情境,否則一下子又在介面與類別之間切換來切換去 —— 筆者也被搞得很亂 XD。
貼心小提示
廢話不多說,正文開始!
這一次筆者以設計陽春的交通票務系統為例子,不過從本篇開始變得稍微複雜一些!
筆者立馬把本日主角類別 TicketSystem
變出來,程式碼如下。
簡單地描述一下類別 TicketSystem
裡面的內容:
TransportTicketType
為列舉型別(Enumerated Type),代表的是交通票券的種類 —— 因為資料的獨特性(Uniqueness)與主觀認知的資料相關性(Similarity)皆符合,因此判斷採用列舉
TimeFormat
為元組型別(Tuple),代表的是時間的格式,依順序分別代表小時、分鐘與秒鐘TicketSystem
內含的成員變數 Member Variables:
startingPoint
與 departure
分別代表啟程點與終點,皆為 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
就是一個例子。
筆者可以開始使用這個票務系統的類別建立簡單的火車票券,程式碼如下。
(這邊有一點需要注意:Date
物件裡,月份部分的值是從 0
開始計算(代表一月),也就是說範例裡的 new Date(2019, 8, 1)
代表的是 2019 年的 9 月 1 日)
因此我們經過 TypeScript 編譯器編譯過後並且由 node
執行之結果(如圖一)。
以上的範例建立了一張簡單的火車票!但這時筆者必須提出幾個疑點:
enum
)deriveDuration
用 if...else...
根據票根交通種類進行站點與站點間的行車時間運算嗎?本篇將示範:使用類別的繼承(Inheritance),創造出好用一點的火車票的票務系統。(當然也可以選擇設計其他交通種類的票券,但筆者擇其一作為示範)
本節目標是設計出一個 TrainTicket
類別,其中的部分功能來自於 TicketSystem
類別。
首先,把 TrainTicket
類別宣告出來,並且制訂站點間的路線對應與間隔時間的表,請看以下程式碼。
筆者一樣先定義站點的靜態格式 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
。
下一個項目對讀者來說比較複雜一些。
原本 TicketSystem
類別有 deriveDuration
:專門計算票券之起點站與抵達站之間所需耗費的交通時間的方法。
我們也可以在 TrainTicket
裡實踐類似的機制(取代父類別的 deriveDuration
方法)—— 專門為火車站點這個應用做特別設計,因此筆者將實踐結果直接貼出來如下:
筆者簡單描述設定為 private
模式的 deriveDuration
在 TrainTicket
類別裡面會怎麼跑,以下舉兩種狀況:
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
方法裡:
我們有使用到 destination
以及 startingPoint
這兩個值,其為 TicketSystem
類別早就有宣告過的成員變數。
如果想要讓 TrainTicket
可以使用 TickeySystem
的功能,因此必須進行類別繼承的動作!
重點 1. 類別的繼承 Class Inheritance
若宣告類別
C
,其程式碼實踐結果如下:另外宣告類別
D
,然後D
對C
進行類別的繼承(Inheritance),因此必須用extends
語法,其寫法如下:我們稱
D
類別繼承了C
類別。其中,
C
為D
的父類別(Parent Class/Superclass);相對地,D
為C
的子類別(Child Class/Subclass)。
D
類別具有以下的特性:
D
類別可以使用C
類別非private
模式的成員變數與方法們(D
除了Pprivate
與Mprivate
外,其他成員都可以使用)D
類別建造出來的物件(使用new
),該物件的型別除了屬於D
類別以外 —— 由於繼承的關係,該物件的型別也同時屬於類別C
- 相對地,
C
類別所建造出來的物件型別為C
類別,但不屬於D
類別
貼心小提示
類別的型別推論與註記的機制將會在 Day 24. 介紹。然而,筆者倒是可以先劇透一下:
“類別(Class)與 TypeScript 介面(Interface)本身也是型別化名(Type Alias)的一種~”
另外也要注意一點,介面的擴充(Extension)以及類別的繼承(Inheritance)用的都是 extends
關鍵字,但這兩個東西是兩回事,不能相提並論,只能說機制很像,但對象不同罷了。
以下開始使用類別的繼承。
首先將 TrainTicket
對 TicketSystem
進行繼承的動作。
筆者就先貼上到目前為止的程式碼的狀況。
以上的程式碼一定會被 TypeScript 發出警訊。筆者是故意要讓錯誤出來的,為的是要講解類別繼承的一些重要規則 —— 從檢視錯誤訊息的過程可以學到很多東西。
以下幾節就是介紹運用類別繼承時,通常會遇到的問題。
protected
存取模式的應用首先,第一個錯誤出現就在進行繼承的那一剎那 —— TrainTicket
類別馬上被 TypeScript 警告。(錯誤狀態如圖二所示;而錯誤訊息如圖三)
圖二:進行繼承的那一剎那,我們就被 TypeScript 警告
圖三:TypeScript 告訴我們,類別 TrainTicket
繼承時出現的錯誤原因是 —— deriveDuration
這個模式是 private
模式
讀者看到圖三的訊息,TypeScript 告訴我們:“deriveDuration
在父類別被設定成 private
模式,因此不能被使用。” 這一點在本篇重點ㄧ就有提到:
(
D
繼承C
類別的情形下)D
具有以下的特性:
D
類別可以使用C
類別非private
模式的成員變數與方法們(D
除了Pprivate
與Mprivate
外,其他成員都可以使用)- (...略)
繼承過後的子類別不能使用父類別的 private
成員。
於是這裡就產生一個問題了:
我們要如何能夠同時封裝好類別成員們,不被外部使用,但又能夠給繼承的子類別們定義(或是覆寫父類別)的成員呢?
這裡的答案就是使用 protected
存取模式。
重點 2.
protected
存取模式當類別成員被標示為
protected
模式下,該成員儘管不能被外部取用,但可以在當前類別以及子類別的範圍內使用。
protected
存取模式跟private
的差別就只有一個:能不能在繼承的子類別裡使用該成員 —— 標示為protected
存取模式即代表可以。
因此筆者將 TicketSystem
以及 TrainTicket
裡的 deriveDuration
成員方法改成 protected
模式。
可以看到剛剛標示在 TrainTicket
底下的錯誤警告消失了。(如圖四)
不過我們還沒完全解決完畢!筆者提出另一個問題:
若從
TrainTicket
類別(它是本案例中的子類別)建立出物件,由於TrainTicket
擁有其父類別TicketSystem
的成員變數與方法們。但如果藉由子類別去創建物件時,如何將成員變數們進行初始化的動作?
畢竟
TrainTicket
的成員變數是被繼承過來的,然而那些初始化的邏輯都是被寫在父類別裡面,也就是TicketSystem
。
因此這裡必須要有一個管道專門從子類別去連結父類別的建構子函式進行物件成員變數初始化的動作。
而這個管道就是 —— 名為 super
的關鍵字。
重點 3. 類別繼承中的
super
由於子類別繼承了父類別的成員,通常也會需要進行初始化物件的動作。其中,與父類別進行溝通的管道就是
super
。在子類別裡,
super
可以等效於父類別的建構子函式。
還記得一開始使用的 TicketSystem
類別,光是要初始化一張交通票券,會這樣寫:
其中,new TicketSystem(...)
這段是負責從 TicketSystem
建立票券的物件。而 TicketSystem
本身就是類別建構子函式。
那麼我們如何使用 super
來連結到父類別並填入物件初始化資料呢?
根據重點 3. 的描述:我們可以在子類別裡的建構子函式內,使用 super
,並且將 super
看成父類別的建構子函式。
運用 super
來呼叫父類別的建構子函式時,必須按照父類別建構子函式的型別,填入正確順序的參數。
資深開發者可能會問:“有時父類別會被放置在其他的檔案,想要在龐大的專案找父類別的建構子函式的內容豈不是很痛苦?更何況這裡的參數有四個,也是很容易被搞錯的。”
我們可以依靠簡單的技巧取得父類別建構子的型別資訊 —— 請將鼠標指向 super
這個關鍵字就會出現如圖四的畫面。
圖四:super
關鍵字被鼠標指的時後,它會提供父類別建構子的資訊
你可以看到 super
關鍵字彈出的視窗很明確地說 —— constructor TicketSystem
,後面接的是初始化物件所需要的參數,型別對照也寫得非常清楚。
因此這裡筆者提醒:重點是要會利用工具帶來的便利性省去翻程式碼的麻煩。
最後還有一個小 Bug 還沒有解決 —— 在 deriveDuration
函式裡有使用到父類別的 private
模式下的成員變數:startingPoint
以及 destination
。
如果我們查看 TrainTicket
裡面的函式,TypeScript 自動地幫我們標註錯誤。(錯誤警告如圖五;訊息如圖六)
圖五:TypeScript 自動幫我們標註 startingPoint
與 destination
部分有潛在錯誤
圖六:TS 很貼心地提醒我們,父類別 TicketSystem
的 destination
與 startingPoint
成員皆為 private
模式,因此不能在子類別使用
看到這裡的讀者應該也會覺得要處理掉這個錯誤很簡單,就是把父類別的建構子函式裡的 startingPoint
跟 destination
這兩個成員從 private
模式切換到 protected
模式,一切就 OK 了!
走到這裡,我們順利完成了 TrainTicket
類別的實踐 —— 筆者簡單新增一個火車票測試看看結果。(以下程式碼經過編譯並用 node
執行結果如圖八)
圖八:成功地實踐出簡單的交通票券系統中的火車票部分
以上的程式碼,新增一張火車票 —— 只要標明啟程站與終點站(startingPoint
與 destination
)以及發車時間(departureTime
),是不是符合平常買車票的邏輯呢?
我們也不需要再次實踐就可以使用父類別早就幫我們定義過的 TicketSystem
方法,因為被 TrainTicket
繼承下來了。
另外,父類別也早就幫我們把 arrivalTime
也都算好了,所以也不需要再重寫一次那些邏輯。
super
的注意事項最後的最後,筆者還是得講一下使用 super
語法的注意事項:
重點 4. 子類別的建構子注意事項
- 子類別的建構子函式裡,進行初始化物件時 —— 也就是**
super
被呼叫之前**,由於物件還未建立完畢,不能有this
相關的操作行為- 假設宣告某類別
C
,而另外一個類別D
繼承C
。另外,C
類別的建構子函式裡擁有若干參數...args
並且子類別也沒有實作建構子函式時,則預設的子類別建構子函式的行為為:
第一點可能還可以理解,物件都沒建構出來怎麼可能去 Reference 到物件的實體(也就是 this
)呢?(圖九)
圖九:錯誤訊息很明確就跟你說,在 super
被呼叫前,不能使用 this
,因為物件還沒被導出來(用的是 Derive 這個單字)
然而,第二點要表達的東西可能會對眾多人感到陌生,這裡舉簡單的例子,單純確認 super
的行為。
以上的程式碼就是仿造 TrainTicket
繼承 TicketSystem
類別的方式:子類別 TestChildClass1
的建構子函式呼叫了 super
—— 父類別 TestParentClass
的建構子函式,並且按照順序填入參數,初始化類別內繼承過來的成員變數。
回過頭來,我們來試試看 TestChildClass1
這個案例。(編譯以下程式碼並用 node
執行結果如圖十)
圖十:執行結果正常
那我們來換另一種情形 —— 繼承了父類別卻沒有實踐子類別的建構子函式。(也就是我們要講的重點 4. 的第二點)
對,就這麼短 XD,不過我們還是ㄧ樣來測看看如何從子類別建構出物件,在這裡筆者刻意不填入任何參數在子類別的函式建構子內。
這樣子的程式碼一定會出現錯誤訊息,因此筆者就直接把它貼出來。(如圖三)
圖三:錯誤訊息明確跟你講少了哪些參數
就算你不對子類別自訂建構子函式,子類別會直接延用父類別建構子函式的規格,要求使用者必須代入建立物件時所需具備的參數!
就算 TestChildClass2
沒有 constructor
,它照樣要求你必須要依序代入 (p1: number, p2: string, p3: boolean)
這些值,所以 TestChildClass2
的建構子函式跟 TestChildClass1
的效果也沒什麼兩樣,讀者可以試試看進行驗證。
基本上讀者反覆看覺得很複雜,沒關係 —— 記下驗證過程的結論就好!畢竟那些重點只是以公式的方式表現出來罷了。
簡而言之就是廢話心得一籮筐。
本篇文章其實完全超出筆者預料之外的難寫 —— 因為是隨時想出應用實例、隨時寫出解法,但也萬萬沒想到單純講繼承的相關功能會花掉筆者整整兩天的時間思考(就為了一個很笨的交通票務系統)。
要能夠將自身的知識轉換成文字真的頗困難的,筆者也是感到有點受挫。不過如果一直都在講語法,當然會覺得無聊,所以才會想說寫一段簡單應用。
您好
看不懂const {departureTime} = this;這一句是什麼意思
能解釋一下嗎?
它是 ES6 Destructuring 也就是 “解構式語法”,就是把物件的屬性拔出來變成一個變數
const { departureTime } = this;
就等於:
const departureTime = this.departureTime;
前者的寫法是不是比後者簡潔許多~
補充一個個人看法:
我覺得要馬把所有的屬性都從 this
拿出來再做一次命名,要馬全部都用 this.xx
來使用,較為統一寫法。
我會傾向全部用 this.xx
,原因是可以一眼看出哪些是來自於 this
,哪些是 method 中專屬。