iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 14
1
Modern Web

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

Day 14. 機動藍圖・函式超載 X 究極融合 - Function Overload & Interface Merging

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

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

  1. 試問介面跟型別系統的差異性在哪?
  2. 為何要儘量對程式碼進行抽象化的動作?

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

什麼是函式超載!?

沒有接觸過 OOP 的讀者們不要害怕~ 讓我們以輕鬆的方式:正文開始

介面的函式超載 Function Overload

看到這個東西,應該很少會把 JavaScript 提供的功能連結到這個概念:函式超載(Function Overload) —— 畢竟 JS 本來就沒有這個功能

因此這裡簡單介紹一下 —— 什麼是函式超載(Function Overload)

通常在靜態型別的語言 —— 如 C++、Java 等語言,必須嚴格對函式(或方法)的輸入與輸出強制進行型別註記。尤其這一類的語言,型別的種類性比 TypeScript 或原生的 JS 所規範的還要細緻 —— 光是 JS 的 number 型態就會被 C++ 細分成 intfloatlongdouble 還有以上型別的特定組合。

這裡筆者開始舉例:想要在 C++ 裡面定義一個計算四邊形面積的函式,除了基本的案例 -- 正方形的例子:

int areaOfRect(int size) {
  return size * size;
}

我們還可能會遇到更多組合變體,譬如計算長方形面積:

int areaOfRect(int edge1, int edge2) {
  return edge1 * edge2;
}

還有型別不同的問題,比如:

float areaOfRect(float size) {
  return size * size;
}

不過呢~我們可以發現共通點是 —— 該函式的名稱都是一樣的:areaOfRect

然而,實作對應的參數數目與型別組合可以不ㄧ樣,這也造成一個現象:只要使用 areaOfRect 的函式,填入之參數與型別有被對應到定義過的其中一種案例areaOfRect 可以被正常執行。

// 符合第一種: int areaOfRect(int size)
areaOfRect(5);

// 符合第二種: int areaOfRect(int edge1, int edge2)
areaOfRect(5, 10);

// 符合第三種: float areaOfRect(float size)
areaOfRect(2.5);

以上擴充函式可以被執行的形式,又被稱之為函式超載(Function Overload)。(這時候背景出現:“Ta! Da!” 以及亂七八糟的彩帶飛來飛去)

讀者云:“作者一定是在開玩笑,這種東西 JS 根本不可能動作,騙我做什麼!以下這種程式碼怎麼可能動作!”

https://ithelp.ithome.com.tw/upload/images/20190917/20120614CMmxKN8JuX.png

對!上述的狀況在 TS 裡根本不會動作,遑論在原生的 JS 執行

但是筆者的含義不在這~ (讀者真要硬生生試一下上面的程式碼也是可行,但就是會出錯~ BJ4)

筆者的意思是說:“TypeScript 的介面裡,函式的型別可以被超渡...”(被鐵臉盆打到)

『 絕・對・不・是・超・渡! 』

是介面裡屬性對應的函式型別可以被超載!(希望筆者不要這系列還沒寫完就被讀者超渡,呸!呸!呸!)

以下的範例程式碼在 TS 的檢測結果如圖一~

https://ithelp.ithome.com.tw/upload/images/20190917/201206143asAVcJzgK.png

https://ithelp.ithome.com.tw/upload/images/20190917/201206145z1SX4njfc.png
圖一:這竟然在 TypeScript 是可以被接受的行為,筆者第一次看到時,受驚了一下

有些人可能覺得:“等等!這情況也不太對啊,這應該是:重複的屬性名稱會蓋掉前一個型別的宣告;也就是說,在這個案例裡,只要有任何物件實踐該介面時,應該只要實踐出 addition(p1: string, p2: string): number 這個功能就好了,因為前一個宣告的函式型別被蓋掉了啊!”

哦~是嗎?(一副 P 孩樣)

我們來檢測看看。(被 TypeScript 檢測結果如圖二,錯誤訊息如圖三)

https://ithelp.ithome.com.tw/upload/images/20190917/20120614MCIfzBDflm.png

https://ithelp.ithome.com.tw/upload/images/20190917/20120614RMLJYHjHTn.png
圖二:剛剛我們猜測的 “覆蓋” 性理論是錯誤的

https://ithelp.ithome.com.tw/upload/images/20190917/20120614e3NUnM6ruX.png
圖三:哇塞,這錯誤訊息有夠長

這錯誤訊息挺長的,我們節選重點部分:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614U7LwODVhiy.png

TypeScript 直接用 overload 這個單字,很明確地說:(string, string) => number{ (number, number): number; (string, string): number } 兩個完全是不一樣的。

我們肯定在想:“恩... 若要實踐出函式超載的話,但也沒辦法直接用以下的方式去進行超載啊!”

https://ithelp.ithome.com.tw/upload/images/20190917/20120614UUuOtqYFpS.png

是的,因為在狹義物件的世界裡(普通 JSON 格式的物件),重複名稱的屬性會蓋掉前一個屬性

於是又是複合型別的 union 出場了。

使用複合型別主要是因為 —— 沒辦法直接對函式超載,但又得同時符合參數可以填入不同形式的狀況,因此也只能採取這樣的路徑。

另外,通常被 union 複合過後的型別,會出現至少兩種以上的型別推論可能性,因此必須進行型別限縮 —— 也就是 Type Guard,又名型別檢測 —— 由判斷敘述式進行型別限縮喔~

https://ithelp.ithome.com.tw/upload/images/20190917/20120614S3WILJZlxH.png

好了,但是這段程式碼還是會錯誤!

“恩!?怎麼會錯?”

我們來看看 TypeScript 的檢測結果(圖四)跟錯誤訊息(圖五)。

https://ithelp.ithome.com.tw/upload/images/20190917/20120614AhaYILQm8r.png
圖四:TypeScript 依然認為會錯

https://ithelp.ithome.com.tw/upload/images/20190917/20120614HmJWTkiI5h.png
圖五:這錯誤訊息真讓人搞不懂在做啥耶

別急!我們看一下錯誤訊息的這一句話:

Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.

有些讀者應該猜得出來 —— undefined 會出現在錯誤訊息裡的原因。

筆者就來解釋一下:在 if...else... 判斷敘述後,儘管讓參數 p1p2 可以為 numberstring,但是參數的組合也有可能是 p1p2 分別為 p1: number; p2: stringp1: string; p2: number —— 後兩種組合狀況會導致直接跳脫函式,並回傳 undefined,但這不符合函式型別輸出的規定狀況。(除非輸出改成 number | void

這裡就要考驗讀者對於這類型的案例的處理想法了

回想一下,如果出現這種狀況,我們希望有一種東西可以幫助我們進行例外排除,而這裡的例外就是 —— 使用者亂填 p1p2 導致它們同時不為 numberstring 的狀況。

如果讀者還記得 Day 10. 這一個重點:

《Day 10. 前線維護・特殊型別 X 永無止盡》之 重點 1. never 型別的概念
never 型別的概念是程序在函式或方法執行時:

  1. 無法跳脫出該函式或方法
  2. 出現例外結果中斷執行

以及:

never is a subtype of and assignable to every type.

也就是說,任何一種型別本來就存在例外處理的狀況導致程式中斷。所以輸出型別為 number 也可以看成輸出型別為 number | never —— 因為 number 等效於 number | never

根據以前學過的觀念,直接對剛剛的程式碼進行修改,改成這樣:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614iJy9EDu3qF.png

拋出例外這件事情本身在 TypeScript 函式的輸出是合理的!

這裡筆者再強調一次:根據 AddOperation 介面的設計,不管回傳型別為 number 亦或者是 number | never —— 由於 never 跟任何型別進行 union 都會被其他型別吃掉,因此在這裡 number | never 被推論過後又會變回跟 number 沒兩樣。因此,TypeScript 檢測過後結果是正確的!(如圖六)

https://ithelp.ithome.com.tw/upload/images/20190917/20120614s7TOlbuHrC.png
圖六:恩~我們實踐出一個 TypeScript 認定很安全的函式

相信讀者看到這裡應該更能體會到 TypeScript 之所以要建構 never 型別的意義了。如果讀者覺得 never 的概念很模糊的話,剛剛的 AddOperation 就是一個展示,也是 never 型別必存在的關鍵 —— 不管你回傳的型別是什麼,依然可以隨時進行例外處理 —— never 就藏身在各種型別當中。

never 也隱藏在你心裡。

重點 1. 介面的函式(或方法)超載

介面定義的屬性對應的函式可以進行超載的動作

被超載的函式名稱必須相同。(型別格式也可以相同,但沒有意義,就很像多出來冗贅的程式碼)

單純函式形式的介面也可以進行函式超載,差別在沒有名稱標記而已。

若某物件實踐該介面時,必須符合該介面裡 —— 超載過的函式之所有情形

讀者試試看

有興趣的讀者可以去玩玩看一些特別(又讓人感到莫名其妙)的案例,畢竟筆者不可能把全部的案例都講完:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614Z721IEhW8C.png

筆者順便說明:其實連型別化名(Type Alias)運用 type 宣告出來的函式也可以進行超載。然而,為何放在介面講解的原因是希望不要把靜態意義的型別系統採用超出靜態行為的技巧,型別的宣告會變得很不合理又很變態。(有雙關喔~)

讀者好奇的話,倒是可以自行驗證看看。

介面延展的另類形式 - 介面融合 Interface Merging

讀者可能以為介面這種東西只能用 extends 來延展。

不過,這裡要介紹 —— 還有另一種方式可以延展介面,稱之為介面融合

對於跟第三方插件或框架協作時,這一節會是很關鍵的概念 —— 請讀者務必好好把這裡的內容學會,就算你看不懂函式超載,也一定要會下面介面融合的技巧;它不會很難,但會對未來使用 TypeScript 作為專案主要語言時非常有用,想要不被其他的第三方套件的型別系統雷到,介面融合的技巧會是救命解藥。(不過也要等筆者寫到很後面很後面,可能第 30 天以後,寫專案時才會實踐到)

貼心小提示

儘管這裡是說 Interface Merging,但是官方的名稱為 Declaration Merging,而介面就是屬於可以進行 Declaration Merging 的其中一種格式

至於有關於 Declaration Merging 部分,筆者考慮過後,本系列文應該不會講太深,也有可能不會講到。但是,如果想要開發 TypeScript 版本的第三方套件,就必須對這領域要更深一點的著墨

有興趣的話可以點這裡看官方 TypeScript 的 Declaration Merging

介面融合的限制也是有的:

重點 2. 介面融合 Interface Merging

若某介面 I重複定義多次,則該介面到最後的推論結果會是所有重複定義的介面的交集

其中,所有重複定義的過程必須遵守這個特性:I 若被重複定義時,裡面若干屬性跟過去所定義的某屬性相符的話,該屬性的型別必須跟過往定義出的介面裡的屬性之型別吻合

筆者今天就順便結合學到的函式超載技巧,舉一個實用的例子。

通常在網頁的 DOM 裡,要創建 HTML 元素與操作它們 —— 譬如要建立一個超連結元素並插入到其他的元素裡:

const $app = document.getElementById('app');
const $a = document.createElement('a');

$a.setAttribute('href', '<url>');
$a.style.color = 'red';
$app.appendChild($a);

其中 createElement 這個方法為例:每一次建立 <a> 標籤,JS 相對會建構出一個 HTMLAnchorElement 物件。如果建立的是 <p> 標籤,相對就會建構 HTMLParagraphElement 物件;如果是 <input> 標籤,則是 HTMLInputElement 等等 ...。

哇,那讀者試著想一下:createElement 這個介面會長成什麼樣子?

可能會有人想出這樣的版本:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614iV5KG1B4X9.png

但是呢,有些敏銳的讀者會發覺不對勁,因為 name 參數在 createElement 這個函式裡面的限制:太過寬鬆

什麼意思?

比如筆者可以刻意實踐出:輸入 'a' 結果創建出 HTMLParagraphElement 而非 HTMLAnchorElement ,但在該介面裡的定義仍然是通過的,因為是用 union 的關係。(讀者撐一下~複合型別的詳細解析會在 Day 17. 出現~)

因此筆者提出一個改善的版本:運用字串明文型別(String Literal Type)與函式超載的技巧。(其實這是官方 Doc 的範例 XD,因為太有趣所以放在這邊做說明)

https://ithelp.ithome.com.tw/upload/images/20190917/20120614xBkwhMwDhK.png

這感覺正常多了。不過還有一個潛在問題:HTML 的元素實在是太多種類(超過 100 多種),要是所有的函式超載狀況與實踐該介面時的內容細節塞在一起 —— 豈不變成很龐大的檔案

這時候,可以對介面進行拆解的動作。但要注意的是:該介面重複定義狀態下,介面的名稱必須重複

https://ithelp.ithome.com.tw/upload/images/20190917/20120614zMuqeLR0UG.png

以上的程式碼,儘管介面被剖成兩半,但是經過介面融合(Interface Merging),你可以把它們想成一體!

介面融合的應用情境

介面融合之所以很重要的原因 —— 筆者這裡就舉一個應用情境。

如果專案跟專案(或第三方套件)之間必須合作 —— 此時兩個專案產生了依賴性(Dependency),勢必會在型別上有衝突產生之可能,尤其如果有使用 Middleware 這種東西,簡直就是破壞型別的平衡

『 什麼是中間層 Middleware!? 』 有些入門的初心者問。

假設某一個很陽春的框架,專門是在處理後端 Request 與 Response。

另外再假設:從 Client 端送出的 Request 部分會被該框架解析成以下格式。

https://ithelp.ithome.com.tw/upload/images/20190917/20120614vF3GL8q4Db.png

裡面的 StupidRequest 的格式設計裡,沒有針對 url 解析出路徑的查詢字串(也就是我們常聽到的 Query String);另外,StupidRequest 介面裡面也沒有宣告含有 query 這個屬性。

通常要從網址解析 Query String —— 例如某網址:

http://domain.com/index?hello=world&visitor=maxwell

後面這一段:

?hello=world&visitor=maxwell

可以被解析成:

{
  "query": {
    "hello": "world",
    "visitor": "maxwell"
  }
}

回過頭來,正想說到底要怎麼擴充 StupidRequest 的功能,在本案例就是從 StupidRequesturl 額外解析出 query 這個 Query String 的 JSON 格式 —— 通常框架的作法是引入中間層(Middleware)。

中間層的概念很簡單:假設某陽春框架接收 Client 端的 Request 時,開發者原本會對該 Request 做某些事情後再回傳 Response;但是在開發者對 Request 做某些事情前,該 Request 會先經過所謂的中間層,然後再把結果傳到開發者寫的程式碼。因此,原本沒有中間層的流程應該是:

  • Client 端發出 Request
  • Server 端接收 Request
  • 開發者針對不同的 Request 進行動作,比如撰寫 Response、RESTful API 溝通、CRUD、提供 Client 端瀏覽器的靜態資料等等眾多行為
  • Server 端發出 Response
  • Client 接收 Response

但如果引入了中間層,程序則會變成:

  • Client 端發出 Request
  • Server 端接收 Request
  • 經過一系列中間層處理 Request:比如以目前的例子,根據 url 進行解析 Query String 的動作,並對 StupidRequest 型別的物件新增 query 這個屬性
  • 開發者針對不同的 Request 進行動作
  • Server 端發出 Response
  • Client 接收 Response

假設真的引入了中間層 —— 專門將 StupidRequest 裡的 url 進行 Query 字串的解析。原本 StupidRequest 的格式是:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614vF3GL8q4Db.png

被中間層搞到最後新增了一個 query 屬性對應一個 JSON 物件

https://ithelp.ithome.com.tw/upload/images/20190917/20120614RiInTdfGAf.png

讀者一定想說:“我們不可能在第三方套件或框架早已經定義的介面裡進行介面擴充的動作啊!那已經被寫死在該框架裡了,要是再被改,這個框架相依賴的其他套件不就也有壞掉的可能嗎?”

對!雖然筆者後續也會講到 TypeScript namespaces 來協助解決這個問題,但在講解這東西之前,如果想要確保 TypeScript 會確認 query 屬性在 StupidRequest 是存在的話 —— 我們不太可能直接在第三方套件裡直接硬加 query 屬性(搞不好其他相依賴套件也會自己用到?),因此最佳的解法是:

https://ithelp.ithome.com.tw/upload/images/20190917/20120614k1yx87o8Dr.png

藉由介面融合的方式,和第三方套件或框架協作 —— 打造出屬於我們的 TS 專案適合的型別版本!

從上面的例子來看,筆者今天在這裡點出了一個 TypeScript 與第三方套件或框架協作的困難點:

只要有類似於中間層概念的程式,亦或者是經過層層包裝的功能,將會對 TypeScript 協作上產生困難點

最後再給讀者補充,通常第三方套件都會有屬於自己的 namespace 防止污染到全域的狀況。因此為了要讓介面也可以融合,你也必須指定該套件的 namespace。(這些細節就由本系列第三篇章《戰線擴張》再來補足這邊的知識吧!)

小結

開頭原本就說今天要讓筆者輕鬆寫,但寫到後頭根本越寫越累XD,因為東西實在太多。

不過今天也點到了重要的點:理解 TypeScript 的介面不只單純用來簡化程式碼以及抽象化,這裡也銜接到 —— 未來如果要第三方套件協作可能遇到的問題與解決層面的方法。

至於 TypeScript Interface 介面的功能:筆者還沒講完~ 所以我們要繼續看下去啊啊啊~~~


上一篇
Day 13. 機動藍圖・介面的延展 X 功能與意義 - Interface Extension & Significance
下一篇
Day 15. 機動藍圖・功能多樣性 X 多樣性介面 - More on TypeScript Interface
系列文
讓 TypeScript 成為你全端開發的 ACE!51

尚未有邦友留言

立即登入留言