Generics 是 TypeScript 一大特色

泛型 是物件導向 多型 的延伸技術,多型是以 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
2
3
4
5
6
7
function echo(arg: number): number {
return arg;
}

function echo(arg: string): string {
return arg;
}

很簡單的 identity function,為了 numberstring 就開了 2 個 function,傳入 物件 與傳入 函式 的部分還沒有實作。

1
2
3
function echo(arg: any): any {
return arg;
}

使用 any 就類似 PHP 不使用型別一樣,就表示任何型別都可以當 parameter,return type 也可以是任何型別。

但這種寫法有 2 個缺點 :

  • Parameter 與 return type 都為 any,但不代表是同一個 any,可能 parameter 是 string,而 return type 是 number
  • echo() 回傳後的物件喪失了原本的型別,無法得知原本是 number 還是 string

any 算是某種 泛型 味道,但算是 無型,並不算是個完美的解決方案,它會喪失原本變數的型別。

1
2
3
function echo<T>(arg: T): T {
return arg;
}

echo() 為了支援各種型別,引入泛型觀念,在 function 名稱後加入 <>,此為 type parameter,專門負責傳入 型別參數,有別於 function 的 () ,用來傳遞數值、物件與函式。

因為把型別當成參數後,echo() 就可以支援各種型別,只要呼叫 echo() 多傳入 型別參數 即可,這樣也可確保 parameter 型別為 T,且 return type 為 T

並不一定要用 T,因為 Type,所以慣用 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 推導出 Tnumber

T 有在 parameter 裡,TypeScript 會自動根據 argument 推導出 T 的型別,因此使用上不用特別傳入型別參數;若 T 不在 parameter 上,就必須明確在 <> 傳入型別參數。

Generic function 對於使用上並沒有任何負擔,且不會喪失原本變數的型別資訊,是比 any 更好的 泛型 解決方案。

陣列

1
2
3
4
function echo<T>(arg: T): T {
console.log(arg.length); // error
return arg;
}

若希望 parameter 為陣列,當然也可以繼續用 T,不過使用 T 之後,TypeScript 將認為此 parameter 可能為任何型別,若你去使用陣列的 property,如 length,將沒有 intellisense 使用,且編譯也會報錯。

1
2
3
4
function echo<T>(arg: T[]): T[] {
console.log(arg.length);
return arg;
}

因為 argT[],會視為 Array,因此 intellisense 有 length,且編譯也會通過。

1
2
3
4
function echo<T>(arg: Array<T>): Array<T> {
consoloe.log(arg.length);
return arg;
}

使用 Array<T> 寫法亦可。

Generic Interface


1
2
3
4
5
function echo<T>(arg: T): T {
return arg;
}

const myEcho: <T>(arg: T) => T = echo;

若要描述 echo<T>() 的型別,使用 <T>(arg: T) =>T,比較特別的是 function 的 return type 改用 =>,而非 :,因為 myEcho 的型別已經用了 :,因此 function 的 return type 改用 arrow function 的 =>

1
2
3
4
5
function echo<T>(arg: T): T {
return arg;
}

const myEcho: {<T>(arg: T): T} = echo;

也可使用 object literal 寫法,因為在 {} 內,此時 return type 可用 :

1
2
3
4
5
6
7
8
9
interface IEcho {
<T>(arg: T): T;
}

function echo<T>(arg: T): T {
return arg;
}

const myEcho: IEcho = echo;

也可將 echo<T>() 型別的 object literal 寫法,改用 function interface 表示。

1
2
3
4
5
6
7
8
9
interface IEcho<T> {
(arg: T): T;
}

function echo<T>(arg: T): T {
return arg;
}

const myEcho<string>: IEcho = echo;

<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
2
3
4
5
6
7
8
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

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
2
3
4
5
6
7
8
interface Lengthwise {
length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}

第 1 行

1
2
3
interface Lengthwise {
length: number;
}

使用 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
2
3
4
5
6
7
8
interface Lengthwise {
length: number;
}

function loggingIdentity(arg: Lengthwise) {
console.log(arg.length);
return arg;
}

Parameter 部分沒問題,型別使用 Lengthwise interface 即可。

但 return type 就尷尬了,無法只傳回 Lengthwise interface。

所以還是得靠 <T> 搭配 generic constraint 才會功德圓滿。

若 return type 與 T 無關時,的確使用 interface 即可。

Extends KeyOf

Generic constraint 不見的只能根據既有的 interface,還能根據其他 type parameter。

1
2
3
4
5
6
7
8
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // okay
getProperty(x, "m"); // error: Argument of type 'm' isn't assignable to 'a' | 'b' | 'c' | 'd'.

實務上我們可能希望 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
2
3
function create<T>(c: new() => T): T {
return new c();
}

Factory method / constructor function 目的在於取代 new 建立物件,因此我們會需要 paramter 傳入 constructor,若想要以泛型描述 constructor,可用 new() =>T 加以描述。

實務上的應用


剛剛介紹了泛型的語法,現在介紹實務上該如何使用泛型。

Service 處理不同 Model

實務上常會遇到 service 的不同 method 處理不同 model,事後卻發現其演算法完全相同,因此想抽成同一個 method 實現 DRY,卻發現因為 model 型別不同而無法 extract method。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class NotificationService
{

public function addFromToSMS(SMSMessage $smsMessage): SMSMessage
{

...
$smsMessage->from = 'Senao';
...

return $smsMessage;
}

public function addFromToLine(LineMessage $lineMessage): LineMessage
{

...
$lineMessage->from = 'Senao';
...

return $lineMessage;
}
}

第 3 行

1
2
3
4
5
6
7
8
public function addFromToSMS(SMSMessage $smsMessage): SMSMessage
{

...
$smsMessage->from = 'Senao';
...

return $smsMessage;
}

傳入 SMSMessage model 進 addFromToSMS(),主要目的是將 Senao 指定給 $smsMessag->from

Senao 也可能是由演算法算出的產物,但最終都要指定給 from property,並回傳 SMSMessage model。

第 10 行

1
2
3
4
5
6
7
8
 public function addFromToLine(LineMessage $lineMessage): LineMessage
{

...
$lineMessage->from = 'Senao';
...

return $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
2
3
4
5
6
7
8
9
10
11
class NotificationService
{

public function addFromToMessage($smsMessage)
{

...
$smsMessage->from = 'Senao';
...

return $smsMessage;
}
}

既然因為 type hint 而拆成兩個 method,那乾脆回歸 PHP 最原始寫法 : 完全放棄 type hint,這樣 SMSMessageLineMessage 兩種型別的物件都可以傳入。

這也是最多人在 PHP 處理的方式。

但這種方式也有幾個缺點 :

  • 因為 $smsMessage 沒有任何型別描述,因此 $smsMessage 也喪失了 intellisense
  • 維護程式的人無法得知 $smsMessage 是什麼型別,只能 trace code 根據前後文去得知其型別
  • 沒有描述 return type,因此接受 addFromToMessage() 的物件也喪失 intellisense

將共用部分抽 Interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
interface IMessageFrom 
{

/** @var string*/
public from;
}

class SMSMessage implements IMessageFrom
{

...
public from;
...
}

class LineMessage implements IMessageFrom
{

...
public from;
...
}

class NotificationService
{

public function addFromToMessage(IMessageFrom $smsMessage)
{

...
$smsMessage->from = 'Senao';
...

return $smsMessage;
}
}

第 1 行

1
2
3
4
5
interface IMessageFrom 
{

/** @var string*/
public from;
}

既然 fromSMSMessageLineMessage 共同的部分,我們可以將 from 抽成 IMessageFrom

第 7 行

1
2
3
4
5
6
class SMSMessage implements IMessageFrom
{

...
public from;
...
}

14 行

1
2
3
4
5
6
class LineMessage implements IMessageFrom
{

...
public from;
...
}

然後要求 SMSMessageLineMessage 去 implement IMessageFrom

23 行

1
2
3
4
5
6
7
public function addFromToMessage(IMessageFrom $smsMessage)
{

...
$smsMessage->from = 'Senao';
...
return $smsMessage;
}

這樣 addFromToMessage() 的 parameter 就可以加上 IMessageFrom type hint。

這種寫法解決了之前的 2 個問題 :

  • 因為 $smsMessage 有了型別描述,因此 $smsMessage 有 intellisense
  • 維護程式的人可以得知 $smsMessage 是什麼型別,不需要 trace code

但仍有 1 個問題沒解決 :

  • 原本 addFromToSMS() 的回傳型別為 SMSMessageaddFromToLine() 回傳型別為 LineMessage,但現在 return type 仍然無法描述,因此接受 addFromToMessage() 的物件仍然喪失 intellisense

若有了 generics,就可以完美解決這個問題

1
2
3
4
5
6
7
8
9
10
11
12
13
interface IMessageFrom {
from: string;
}

class NotificationService {
addFromToMessage<T extends IMessageFrom>(smsMessage: T): T {
...
smsMessage.from = 'Senao';
...

return smsMessage;
}
}

第 1 行

1
2
3
interface IMessageFrom {
from: string;
}

一樣使用 interface 描述要有 from property。

第 6 行

1
2
3
4
5
6
7
addFromToMessage<T extends IMessageFrom>(smsMessage: T): T {
...
smsMessage.from = 'Senao';
...

return smsMessage;
}
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
2
3
4
5
6
7
class NotificationService
{

public function sortMessages(array $messages): array
{

...
}
}

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
2
3
4
5
class NotificationService {
sortMessages<T>(messages: T[]): T[] {
...
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
abstract class AbstractRepository
{

/** @var 注入的model */
protected $model;

/** 根據 key 找單一筆資料 */
public function find(int $id, $columns = ['*'])
{

...
}

/** 回傳全部資料 */
public function all($columns = ['*']): array
{

...
}

/** 新增資料 */
public function create(array $data)
{

...
}

/* 修改資料 */
public function update(array $data)
{

...
}

/* 刪除資料 */
public function delete(int $id)
{

...
}
}

class OrderRepository extends AbstractRepository
{

public __construct(Order $order)
{
parent::__construct();
$this->order = $order;
}
}

這種架構 OrderRepository 繼承了 AbstractRepository,因此 OrderRepository 就有了 AbstractRepositoryfind()all()create()update()delete() 功能,這些都是每個 repository 都會有的 method。

這種架構看似很好,但仔細去看 AbstractRepository,仍有一些問題 :

第 3 行

1
2
/** @var 注入的model */
protected $model;

$model 因為可能是各種型別,所以無法宣告型別,不過目前 PHP 還無法對 field 下 type hint。

第 6 行

1
2
3
4
5
/** 根據 key 找單一筆資料 */
public function find(int $id, $columns = ['*'])
{

...
}

find() 理論上應該回傳 model 型別,但為了支援各種 model,只好選擇不用 return type,但也因此 find() 的接受物件喪失了 intellisense。

12 行

1
2
3
4
5
/** 回傳全部資料 */
public function all($columns = ['*']): array
{

...
}

all() 會傳回多筆資料,return type 使用了 array 這種概括型別,但 all() 的接受物件將喪失 array 內物件的 intellisense。

18 行

1
2
3
4
5
6
7
8
9
10
11
/** 新增資料 */
public function create(array $data)
{

...
}

/* 修改資料 */
public function update(array $data)
{

...
}

create()update() 為了讓各種 model 的 repository 都能套用,因而退守改用 array 這種概括型別,理論上應該將 model 型別的 物件 傳入較佳。

Array 的缺點

  • Array 是個概括型別,使用了之後,array 內部的物件都會喪失 intellisense
  • Array 與 null 很像,只要在底層用了 array 或 null,如 repository,就會逼得 service 與 controller 也繼續用 array 或 null

若有了 generics,就可以完美解決這個問題

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
abstract class AbstractRepository<T> {
/** 注入的model */
protected model: T;

/** 根據 key 找單一筆資料 */
find(id: number, columns = ['*']): T {
...
}

/** 回傳全部資料 */
function all(columns = ['*']): T[] {
...
}

/** 新增資料 */
create(data: T) {
...
}

/* 修改資料 */
update(data: T) {
...
}

/* 刪除資料 */
delete(id: number) {
...
}
}

class OrderRepository extends AbstractRepository<Order> {
constructor(protected order: Order) {
super();
}
}

第 3 行

1
2
/** 注入的model */
protected model: T;

宣告 model 為泛型 T

第 5 行

1
2
3
4
/** 根據 key 找單一筆資料 */
find(id: number, columns = ['*']): T {
...
}

find() 的 return type 也有了 model 的明確型別,為泛型 T

10 行

1
2
3
4
/** 回傳全部資料 */
all(columns = ['*']): T[] {
...
}

all() 的 return type 由 T[] 取代 array,有了 model 的明確的型別。

16 行

1
2
3
4
5
6
7
8
9
/** 新增資料 */
create(data: T) {
...
}

/* 修改資料 */
update(data: T) {
...
}

create()update() 的 parameter 由 array 改成 T,有了 model 的明確型別。

31 行

1
2
3
4
5
class OrderRepository extends AbstractRepository<Order> {
constructor(protected order: Order) {
super();
}
}

在繼承 AbstractRepository 時,把 Order 的 type parameter 明確帶入,因此 AbstractRepositoryT 都是 Order model。

改用泛型後,Repository 的型別可由 type parameter 指定,不再為了支援各種 model 而放棄 type hint 或改用 array。

Conclusion


  • C# 也有 generics,因此學習 TypeScript 的 generics 觀念,完全可用在 C#。
  • 因為 interface 是在執行階段切換,所以又稱為 動態多型,而 generics 是在編譯階段切換,又稱為 靜態多型
  • 因為 generics 是在編譯階段處理,所以執行效率會比 interface 高。
  • <T> 要放在 function 還是要放在 class 或 interface 都有它的優缺點,要看實際狀況決定。
  • 泛型讓我們在寫程式時,能以支援各種型別來思考,但使用上又能享受強型別的 intellisense 與編譯時期檢查,非常好用。

Reference


TypeScript, Handbook : Generics

2018-03-18