Generics
tags: TypeScript
參考資料: TypeScript Handbook、冴羽 TypeScript 系列
軟體工程中的一個主要部分就是構建元件 (building component),元件不僅需要有定義良好和一致的API,也需要是可複用的 (reusable)。好的元件可以處理當前的資料也可以處理未來可能遇到的資料,這會在建構大型軟體系統時給你最大的靈活度。
在比如 C# 和 Java 中,用來創建可複用元件的工具,我們稱之為泛型 (generics)。利用泛型,我們可以創建一個支援眾多型別的元件,這讓使用者可以使用自己的型別消費 (consume) 這些元件。
Hello World of Generics
讓我們寫一個恆等函式 (identity function),這個 identity 函式會回傳任何傳入內容的函式,你也可以把它理解為類似於 echo
指令。
如果沒有泛型,我們就需要給這個 identity 函式特定的型別:
function identity(arg: number): number {
return arg;
}
或是給它一個 any
:
function identity(arg: any): any {
return arg;
}
雖然使用 any
可以讓函式接收各種型別的 arg
參數,但我們也遺失了這個函式回傳值的型別資訊,例如我們傳了一個 number
進去,我們唯一能知道的訊息就是函式回傳了一個 any
型別的值。
我們需要一個可以去捕獲參數型別的方式,然後用它來表示回傳值的型別。這裡我們使用了一個型別的變數 (type variable),一個代表型別而不是值的特殊變數。
function identity<Type>(arg: Type): Type {
return arg;
}
加上 Type
這個型別變數後,我們能夠去捕獲到使用者傳入參數的型別 (例如 number
),以便我們將來去使用這個型別。然後我們又在 return type 裡使用了 Type
,透過檢查我們可以清楚的知道參數和回傳值的型別是相同的。
像這樣寫法的 identity 函式就是一個泛型,它可以作用於任何型別。和 any
不同的是,它和第一個用 number
定義的方式一樣不會遺失回傳值的型別。
我們有兩種呼叫這個泛型函式的方式,第一種是傳入所有參數,包含參數型別:
let output = identity<string>('myString'); // let output: string
在這裡我們明確的給 Type
設置為 string
作為函式呼叫的參數。
第二種方式是最常見的,使用型別推論 (type argument inference) 讓 TypeScript 在編譯時根據傳入的參數自動推論出 Type
的值。
let output = identity('myString'); // let output: string
注意這次我們並沒有用 <>
明確的傳入型別,當編譯器看到 myString
這個值,就會自動設置 Type 為它的型別(即 string
)。
type argument inference 是非常好用的工具,它可以讓程式碼更精簡且可讀性更好,我們只需要在一些更複雜的情境裡,當編譯器無法自動推論出型別時,才需要像第一個例子一樣傳入一個明確的型別。
Working with Generic Type Variables
當你開始使用泛型函式時,一定會注意到當你寫一個像是 identity
函式的時候,編譯器會強制你在函式體中正確地去使用這些參數型別,也就是說這些參數實際上被認為可以是 any
或所有型別。
以剛才的 identity
函式為例:
function identity<Type>(arg: Type): Type {
return arg;
}
如果我們想在呼叫它的時候印出這個參數的長度,可能會這樣去寫:
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length); // 類型 'Type' 沒有屬性 'length'。ts(2339)
return arg;
}
此時編譯器會報錯並提示我們正在使用 arg
的 .length
屬性,但是我們沒有在其他地方定義過 arg
有這個屬性。請記住,我們之前說過這些型別變數代表著 any
甚至是所有型別,所以很有可能今天傳入的是一個沒有 .lengrh
的 number
型別。
假設我們打算將此函式用在 Type[]
而不是直接使用 Type
,因為使用的是陣列,所以 .length
屬性必定存在,我們就可以像創建其他型別的陣列一樣去定義:
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
你可以這樣去理解 loggingIdentity
的型別:泛型函式 loggingIdentity
接受一個參數型別 Type
然後它的 argument arg
是一個 Type
陣列,函式回傳值是 Type
陣列。
如果我們傳一個 number 陣列進去,將會得到一個也是 number 的陣列回傳值,使用型別變數 Type
,是作為我們使用的型別的一部分,而不是之前的一整個型別,這會給我們更大的自由度。
我們也可以這樣去寫剛剛這個例子,效果是一樣的:
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
Generic Types
在前一個章節裡,我們已經建立了一個泛型 identity 函式,並且可以支持傳入各種型別的參數。在這個章節裡,我們將探索函式本身的型別,以及如何建立泛型介面 (interface)。
泛型函式的形式就跟其他非泛型函式的一樣,都需要先列一個參數型別清單,這有點像函式宣告:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity;
泛型的參數型別可以使用不同的名字,只要數量和使用方式上一致即可:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Input>(arg: Input) => Input = identity;
我們也可以以物件型別的呼叫簽章 (call signature) 的形式,書寫這個泛型:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: { <Type>(arg: Type): Type } = identity;
我們可以進一步將上面這個物件字面值抽離出來,寫成一個 interface:
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
let myIdentity: GenericIdentityFn = identity;
有的時候,我們會希望將泛型參數作為整個介面的參數,這可以讓我們清楚的知道傳入的是什麼參數(舉個例子:Dictionary<string>
而不是 Dictionary
)。而且介面裡其他的成員也可以看到。
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
let myIdentity: GenericIdentityFn<number> = identity;
注意在這個例子裡,我們只做了少許改動。不再描述一個泛型函式,而是將一個非泛型函式簽章,作為泛型的一部分。
現在當我們使用 GenericIdentityFn
的時候,需要明確給出參數的型別。(在這個例子中,是 number
),有效的鎖定了呼叫簽章使用的型別。
當要描述一個包含泛型的型別時,理解什麼時候把參數型別放在呼叫簽章里,什麼時候把它放在介面裡是很有用的。
Generic Classes
泛型 class 和泛型 interface 的寫法很像,在泛型 class 的名字後面加上一個尖括號(<>
)包裹住參數型別列表。
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
你可能會在寫這個泛型時遇到了關於沒有初始化的報錯,這裡可以先將
tsconfig.json
裡的"strictPropertyInitialization"
調成false
,後面會再提到關於 class 的型別問題。
在這個例子中你可能已經注意到了這個 GenericNumber
class 並沒有限制你只能使用 number
型別,我們也可以使用 string
會是其他更複雜的型別:
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = '';
stringNumeric.add = function (x, y) {
return x + y;
};
console.log(stringNumeric.add(stringNumeric.zeroValue, 'test')); // 'test'
和 interface 一樣,將參數型別放在 class 上可以確保 class 裡所有的屬性都使用一樣的型別。
正如我們在 Class 章節提過的,一個 class 它的型別有兩部分:靜態部分 (static side) 和實例部分 (instance side)。泛型 class 僅僅對實例部分生效,所以當我們使用 class 的時候,要注意靜態成員並不能使用參數型別。
Generic Constraints
在之前我們用泛型寫過這個 loggingIdentity
函式,假設我們想要獲取參數 arg
的 .length
屬性,但是編譯器並不能證明每種型別都有 .length
屬性,所以它會報錯:
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length); // 類型 'Type' 沒有屬性 'length'。ts(2339)
return arg;
}
我們希望將此函式限制為只能使用也具有 .length
屬性的型別,只要型別有這個成員,我們就允許使用它,但必須至少要有這個成員。為此,我們需要列出對 Type
限制的必要條件。
為了實現它,我們可以寫一個 interface 來描述我們的限制條件,我們在這寫了一個只有一個 .length
屬性的 interface 然後使用 extends
關鍵字去對泛型進行限制:
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length);
return arg;
}
loggingIdentity('hello'); // OK
loggingIdentity(['hello', 'world']); // OK
因為我們已經對這個泛型函式做了限制,所以它將不再適用於所有型別:
loggingIdentity(3);
// 類型 'number' 的引數不可指派給類型 'Lengthwise' 的參數。ts(2345)
需要傳入符合限制條件的屬性值:
loggingIdentity({ length: 10, value: 3 });
Using Type Parameters in Generic Constraints
你可以宣告一個受另一個參數型別限制的參數型別。舉例來說,我們想要從一個物件中拿到給定屬性名的值,我們必須確保我們不會意外抓取物件上不存在的屬性,因此我們要在這兩種型別中放置一個限制 (constraints):
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, 'a');
getProperty(x, 'm');
// 類型 '"m"' 的引數不可指派給類型 '"a" | "b" | "c" | "d"' 的參數。ts(2345)
在實作的時候,你可能會注意到 vscode 會正確提示你可以輸入的屬性名:
Using Class Types in Generics
在 TypeScript 中使用泛型建立工廠函式 (factory) 時,有必要透過其建構函式 (constructor function) 來引用類別的型別,例如:
function create<Type>(c: { new (): Type }): Type {
return new c();
}
接著是一個更複雜的範例,使用原型屬性來推論和限制建構函式和類別型別的實例之間的關係:
class BeeKeeper {
hasMask: boolean = true;
}
class ZooKeeper {
nametag: string = 'sheep';
}
class Animal {
numLegs: number = 4;
}
class Bee extends Animal {
keeper: BeeKeeper = new BeeKeeper();
}
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
console.log(createInstance(Lion).keeper.nametag); // sheep
console.log(createInstance(Bee).keeper.hasMask); // true
console.log(createInstance(BeeKeeper));
/*
類型 'typeof BeeKeeper' 的引數不可指派給類型 'new () => Animal' 的參數。
類型 'BeeKeeper' 缺少屬性 'numLegs',但類型 'Animal' 必須有該屬性。ts(2345)
*/