深入探討 TypeScript 之 Generics
泛型
是物件導向 多型
的延伸技術,多型是以 interface 為基礎,在 執行時期
決定適當型別;而泛型則是以 type parameter 為基礎,在 編譯時期
決定適當型別。
泛型也是 TypeScript 的一大特色,預計 ECMAScript 將來也不會採用泛型。
Version
TypeScript 2.5
泛型概論
OOP 就是 繼承
、封裝
與 多型
,為了支援更多型別,OOP 教我們使用 多型
,白話就是抽 interface
,但實務上會發現,有很多物件之間根本扯不上什麼關係,很難抽 interface,此時就必須使用 泛型
。
泛型並不是什麼新觀念,早在 C++ 就有泛型,後來 C# 2.0 也支援泛型,Java 最後也跟進,而 TypeScript 讓 JavaScript 也能使用泛型。
多型 : 以 interface 為基礎,將不同型別的物件,抽象化成 interface 型別的物件,但實際上是
多
種型
別的物件。(C++、C#、Java、PHP)泛型 : 不用抽 interface,以 type parameter 為基礎,透過傳遞型別參數,將各物件抽象化成
T
型別物件,但實際上是泛
指各種型
別物件。(C++、C#、Java、TypeScript)無型 : 因為
無型
別,因此可以支援各種型別,但也因為無型別,所以喪失 intellisense 與編譯檢查型別的功能。(JavaScript、PHP)
TypeScript Generics
TypeScript 一共提供 3 種 generics :
- Generic Function
- Generic Interface
- Generic Class
Generic Function
內建型別
Identity Function
無論輸入什麼,就輸出什麼
白話 : 輸入數值就會傳數值;輸入物件就回傳物件;輸入函式就回傳函式。
1 | function echo(arg: number): number { |
很簡單的 identity function,為了 number
與 string
就開了 2 個 function,傳入 物件
與傳入 函式
的部分還沒有實作。
1 | function echo(arg: any): any { |
使用 any
就類似 PHP 不使用型別一樣,就表示任何型別都可以當 parameter,return type 也可以是任何型別。
但這種寫法有 2
個缺點 :
- Parameter 與 return type 都為
any
,但不代表是同一個any
,可能 parameter 是string
,而 return type 是number
echo()
回傳後的物件喪失了原本的型別,無法得知原本是number
還是string
any
算是某種泛型
味道,但算是無型
,並不算是個完美的解決方案,它會喪失原本變數的型別。
1 | function echo<T>(arg: T): T { |
echo()
為了支援各種型別,引入泛型觀念,在 function 名稱後加入 <>
,此為 type parameter,專門負責傳入 型別參數
,有別於 function 的 ()
,用來傳遞數值、物件與函式。
因為把型別當成參數後,echo()
就可以支援各種型別,只要呼叫 echo()
多傳入 型別參數
即可,這樣也可確保 parameter 型別為 T
,且 return type 為 T
。
並不一定要用
T
,因為T
ype,所以慣用T
,第二個泛型常用U
,以此類推。
1 | let output = echo<string>('Sam'); |
呼叫 echo()
時多帶 <string>
,就能確保 output
也是 string
型別,而 <>
可以傳進任何型別,因此稱為 泛型
。
1 | let output = echo('Sam'); |
因為 arg: T
,TypeScript 會自動根據 Sam
推導出 T
應該為 string
。
同理
1 | let output = echo(1); |
TypeScript 也會自動根據 1
推導出 T
為 number
。
若
T
有在 parameter 裡,TypeScript 會自動根據 argument 推導出 T 的型別,因此使用上不用特別傳入型別參數;若T
不在 parameter 上,就必須明確在<>
傳入型別參數。Generic function 對於使用上並沒有任何負擔,且不會喪失原本變數的型別資訊,是比
any
更好的泛型
解決方案。
陣列
1 | function echo<T>(arg: T): T { |
若希望 parameter 為陣列,當然也可以繼續用 T
,不過使用 T
之後,TypeScript 將認為此 parameter 可能為任何型別,若你去使用陣列的 property,如 length
,將沒有 intellisense 使用,且編譯也會報錯。
1 | function echo<T>(arg: T[]): T[] { |
因為 arg
為 T[]
,會視為 Array
,因此 intellisense 有 length
,且編譯也會通過。
1 | function echo<T>(arg: Array<T>): Array<T> { |
使用 Array<T>
寫法亦可。
Generic Interface
1 | function echo<T>(arg: T): T { |
若要描述 echo<T>()
的型別,使用 <T>(arg: T) =>T
,比較特別的是 function 的 return type 改用 =>
,而非 :
,因為 myEcho
的型別已經用了 :
,因此 function 的 return type 改用 arrow function 的 =>
。
1 | function echo<T>(arg: T): T { |
也可使用 object literal 寫法,因為在 {}
內,此時 return type 可用 :
。
1 | interface IEcho { |
也可將 echo<T>()
型別的 object literal 寫法,改用 function interface 表示。
1 | interface IEcho<T> { |
<T>(arg: T): T;
的寫法,T
僅作用於 method,若我們想將 T
作用於整個 interface,會移到 interface 名稱後面。
Q : 使用 generic interface 時,
<T>
該使用在 interface 還是 function ?
- 若你想由 function 來推倒出 interface 的
T
,則將T
放在 function - 若你想由 interface 來決定
T
,function 必須遵守,則將T
放在 interface - 各有各的優點,要依實際狀況決定該用哪種方式
Generic Class
1 | class GenericNumber<T> { |
T
定義了 class 內的 property 與 method signature 的型別。
Generic class 的
T
只能用在 object level,不能用在 class level 的 static 部分。
只有 generic function,generic interface 與 generic class,並沒有 generic enums。
只有 generic function 可以由 parameter 自動推導出<T>
,generic interface 與 generic class 則必須明確傳入<T>
。
Generic Constraint
Extends Interface
在之前的例子,因為我們需要 arg.length
,因而改用 T[]
,若我們要得不是陣列的 length
property,而是傳入的 parameter 必須要有 length
property 呢 ?
1 | interface Lengthwise { |
第 1 行
1 | interface Lengthwise { |
使用 object interface,定義 length
property。
第 5 行
1 | function loggingIdentity<T extends Lengthwise>(arg: T): T |
一樣使用 <T>
,但必須 T extends Lengthwise
,表示 T
不再是任意型別,而必須是有實現 Lengthwise
interface 的型別,否則 TypeScript 會編譯錯誤。
Q :
Lengthwise
是 interface,T
應該是要implements
interface,怎麼是extends
interface 呢 ?
T
是個 type,TypeScript 也允許 interface 繼承 interface,相當於 property 的組合,因此 T extends Lengthwise
後,T
會多了 length
property,因此 arg
一定要有 length
property 的才會通過 TypeScript 編譯。
1 | loggingIdentity(3); // Error, number doesn't have a .length property |
用了 generic constraint 之後,傳入 3
會編譯錯誤,因為 number
型別沒有 length
property。
1 | loggingIdentity({length: 10, value: 3}); |
({length: 10, value: 3}
有 lenght
property,有符合 Lengthwise
的要求,TypeScript 編譯通過。
之前講 interface 時,談到 object literal 當 argument 時,會啟動 excess property checks,也就是 strong typing 檢查,但用在 generic constraint 時,TypeScript 卻只採用 duck typing 檢查。
Q : 為什麼不直接用 interface,卻要用 generic constraint ?
一開始我也覺得 interface 與 generic constraint 是一樣的,但仔細想想還是不同,若用 interface 改寫 :
1 | interface Lengthwise { |
Parameter 部分沒問題,型別使用 Lengthwise
interface 即可。
但 return type 就尷尬了,無法只傳回 Lengthwise
interface。
所以還是得靠 <T>
搭配 generic constraint 才會功德圓滿。
若 return type 與
T
無關時,的確使用 interface 即可。
Extends KeyOf
Generic constraint 不見的只能根據既有的 interface,還能根據其他 type parameter。
1 | function getProperty<T, K extends keyof T>(obj: T, key: K) { |
實務上我們可能希望 function 的第 1 個參數為 object,第 2 個參數為 object 的 key,希望傳回 object 的 value,但要如何確保輸入的 key 一定是 object 的 key 呢 ?
1 | function getProperty<T, K extends keyof T>(obj: T, key: K) |
T
: 傳入物件的泛型。
keyof T
: 物件的 key。
K extends keyof T
: K
須為 T
的 key。
藉由
extends keyof
語法,若輸入的值不是 object 的 key,TypeScript 將會編譯錯誤。
new(): T
1 | function create<T>(c: new() => T): T { |
Factory method / constructor function 目的在於取代 new 建立物件,因此我們會需要 paramter 傳入 constructor,若想要以泛型描述 constructor,可用 new() =>T
加以描述。
實務上的應用
剛剛介紹了泛型的語法,現在介紹實務上該如何使用泛型。
Service 處理不同 Model
實務上常會遇到 service 的不同 method 處理不同 model,事後卻發現其演算法完全相同,因此想抽成同一個 method 實現 DRY,卻發現因為 model 型別不同而無法 extract method。
1 | class NotificationService |
第 3 行1
2
3
4
5
6
7
8public function addFromToSMS(SMSMessage $smsMessage): SMSMessage
{
...
$smsMessage->from = 'Senao';
...
return $smsMessage;
}
傳入 SMSMessage
model 進 addFromToSMS()
,主要目的是將 Senao
指定給 $smsMessag->from
。
Senao
也可能是由演算法算出的產物,但最終都要指定給from
property,並回傳SMSMessage
model。
第 10 行
1 | public function addFromToLine(LineMessage $lineMessage): LineMessage |
傳入 LineMessage
model 進 addFromToLine()
,主要目的是將 Senao
指定給 $lineMessage
。
Senao
也可能是由演算法算出的產物,但最終都要指定給from
property,並回傳LineMessage
model。
我們可以發現,addFromToSMS()
與 addFromToLine()
基本上演算法相同,要解決的問題也相同,只因為傳入 parameter 的型別與傳回的 return type 不同,因此被逼要用兩個 method 處理。
因為演算法相同,我們希望只使用一個 method 就能解決。
在 PHP,可能有 2
種解法 :
- 放棄 Type Hint
- 將共用部分抽 Interface
放棄 Type Hint
1 | class NotificationService |
既然因為 type hint 而拆成兩個 method,那乾脆回歸 PHP 最原始寫法 : 完全放棄 type hint
,這樣 SMSMessage
與 LineMessage
兩種型別的物件都可以傳入。
這也是最多人在 PHP 處理的方式。
但這種方式也有幾個缺點 :
- 因為
$smsMessage
沒有任何型別描述,因此$smsMessage
也喪失了 intellisense - 維護程式的人無法得知
$smsMessage
是什麼型別,只能 trace code 根據前後文去得知其型別 - 沒有描述 return type,因此接受
addFromToMessage()
的物件也喪失 intellisense
將共用部分抽 Interface
1 | interface IMessageFrom |
第 1 行
1 | interface IMessageFrom |
既然 from
為 SMSMessage
與 LineMessage
共同的部分,我們可以將 from
抽成 IMessageFrom
。
第 7 行
1 | class SMSMessage implements IMessageFrom |
14 行
1 | class LineMessage implements IMessageFrom |
然後要求 SMSMessage
與 LineMessage
去 implement IMessageFrom
。
23 行
1 | public function addFromToMessage(IMessageFrom $smsMessage) |
這樣 addFromToMessage()
的 parameter 就可以加上 IMessageFrom
type hint。
這種寫法解決了之前的 2 個問題 :
- 因為
$smsMessage
有了型別描述,因此$smsMessage
有 intellisense - 維護程式的人可以得知
$smsMessage
是什麼型別,不需要 trace code
但仍有 1 個問題沒解決 :
- 原本
addFromToSMS()
的回傳型別為SMSMessage
,addFromToLine()
回傳型別為LineMessage
,但現在 return type 仍然無法描述,因此接受addFromToMessage()
的物件仍然喪失 intellisense
若有了 generics,就可以完美解決這個問題
1 | interface IMessageFrom { |
第 1 行
1 | interface IMessageFrom { |
一樣使用 interface 描述要有 from
property。
第 6 行
1 | addFromToMessage<T extends IMessageFrom>(smsMessage: T): T { |
1 | addFromToMessage<T>(smsMessage: T): T |
smsMessage
的型別改用 T
表示,且 return type 也為 T
。
這解決了之前 2 種方式的 addFromToMessage()
無法描述 return type 的問題,目前 return type 為 T
,與傳入 parameter 的型別相同。
1 | addFromToMessage<T extends IMessageFrom>(smsMessage: T): T |
但 T
為任何型別,並無法保證 from
property 一定存在,因此在 type parameter 加上 <T extends IMessageFrom>
(Generic Constraint),確保 T
一定要有 IMessageFrom
interface,否則 TypeScript 會編譯失敗。
如此 return type 與 interface 問題皆可解決。
T extends interface
讓我們實現有限制的泛型
,T
不再是任意型別,而是要有實現 interface 的特定型別。
Array 處理不同 Model
實務上會使用 array 放不同 model,但缺發現 method 回傳後,已經喪失原本 array 內物件的型別。
1 | class NotificationService |
sortMessages()
負責對 model 陣列做排序,由於 $messages
只宣告為 array
型別,因此使用上
1 | sortMessages($smsMessages); |
或
1 | sortMessages($lineMessages); |
皆可以傳入。
儘管在 array 內處理不同 model,但最少都在同一個 sortMessages()
裡面,已經有泛型的味道。
但這樣寫有 2
個問題 :
array
是一個概括性的型別,我們無法得知 array 內放的是SMSMessage
或是LineMessage
型別。- 回傳的
array
也是個概括型別,接受sortMessages()
的回傳物件,只能知道是個 array,但不知道內部是什麼物件,如foreach()
時,就無法對內部物件做 intellisense。
1
的問題還好,因為 sortMessages()
主要是針對 array 做排序,因此不會使用到 array 內部物件的 intellisense,但 2
的問題比較嚴重。
若有了 generics,就可以完美解決這個問題
1 | class NotificationService { |
T
代表任意型別,T[]
為任意型別的陣列,且 return type T[]
亦為任意型別的陣列。
這種寫法解決了原本的問題 :
messages
不再只是概括性的array
型別,而是明確的T[]
型別,其中T
就是傳入的 model 型別。- 回傳的也不再是概括的
array
型別,而是明確的T[]
,sortMessages()
的接受者可以明確地得知其型別為T
,TypeScript 編譯器會自動將T
以 model 的型別取代,因此foreach
可順利抓到物件的型別。
對於 array 這種 container 類型的型別,我們不必只是宣告空洞的
array
型別而已,而是真正以 array 的內容來宣告型別。
Repository 處理不同 Model
Repository 負責處理資料庫邏輯,實務上會發現有些 method 在所有的 repository 都會出現,因此會想定義 abstract repository,其他 repository 則繼承此 abstract repository。
這裡的 model 指的是 .NET 的 entity,以 ORM 來描述 table 的一筆資料,而 CodeIgniter 的 model 則類似於 repository。
1 | abstract class AbstractRepository |
這種架構 OrderRepository
繼承了 AbstractRepository
,因此 OrderRepository
就有了 AbstractRepository
的 find()
、all()
、create()
、update()
與 delete()
功能,這些都是每個 repository 都會有的 method。
這種架構看似很好,但仔細去看 AbstractRepository
,仍有一些問題 :
第 3 行
1 | /** @var 注入的model */ |
$model
因為可能是各種型別,所以無法宣告型別,不過目前 PHP 還無法對 field 下 type hint。
第 6 行
1 | /** 根據 key 找單一筆資料 */ |
find()
理論上應該回傳 model 型別,但為了支援各種 model,只好選擇不用 return type,但也因此 find()
的接受物件喪失了 intellisense。
12 行
1 | /** 回傳全部資料 */ |
all()
會傳回多筆資料,return type 使用了 array
這種概括型別,但 all()
的接受物件將喪失 array 內物件的 intellisense。
18 行
1 | /** 新增資料 */ |
create()
與 update()
為了讓各種 model 的 repository 都能套用,因而退守改用 array
這種概括型別,理論上應該將 model 型別的 物件
傳入較佳。
Array 的缺點
- Array 是個概括型別,使用了之後,array 內部的物件都會喪失 intellisense
- Array 與 null 很像,只要在底層用了 array 或 null,如 repository,就會逼得 service 與 controller 也繼續用 array 或 null
若有了 generics,就可以完美解決這個問題
1 | abstract class AbstractRepository<T> { |
第 3 行
1 | /** 注入的model */ |
宣告 model
為泛型 T
。
第 5 行
1 | /** 根據 key 找單一筆資料 */ |
find()
的 return type 也有了 model 的明確型別,為泛型 T
。
10 行
1 | /** 回傳全部資料 */ |
all()
的 return type 由 T[]
取代 array
,有了 model 的明確的型別。
16 行
1 | /** 新增資料 */ |
create()
與 update()
的 parameter 由 array
改成 T
,有了 model 的明確型別。
31 行
1 | class OrderRepository extends AbstractRepository<Order> { |
在繼承 AbstractRepository
時,把 Order
的 type parameter 明確帶入,因此 AbstractRepository
的 T
都是 Order
model。
改用泛型後,Repository 的型別可由 type parameter 指定,不再為了支援各種 model 而放棄 type hint 或改用 array。
Conclusion
- C# 也有 generics,因此學習 TypeScript 的 generics 觀念,完全可用在 C#。
- 因為 interface 是在執行階段切換,所以又稱為
動態多型
,而 generics 是在編譯階段切換,又稱為靜態多型
。 - 因為 generics 是在編譯階段處理,所以執行效率會比 interface 高。
<T>
要放在 function 還是要放在 class 或 interface 都有它的優缺點,要看實際狀況決定。- 泛型讓我們在寫程式時,能以支援各種型別來思考,但使用上又能享受強型別的 intellisense 與編譯時期檢查,非常好用。