泛型

軟體工程的重要部分是建置元件,這些元件不僅具有定義完善且一致的 API,而且還能重複使用。能夠處理今日資料以及明日資料的元件,將為您建置大型軟體系統提供最靈活的功能。

在 C# 和 Java 等語言中,用於建立可重複使用元件的工具箱中的一個主要工具是泛型,也就是能夠建立一個元件,可以在各種型別上執行作業,而不仅仅是單一型別。這使用戶能夠使用這些元件並使用他們自己的型別。

泛型的 Hello World

首先,我們來做泛型的「hello world」:身分函數。身分函數是一個函數,它會回傳傳入的任何東西。你可以把它想成類似於 echo 指令。

如果沒有泛型,我們必須為身分函數指定一個特定的型別

ts
function identity(arg: number): number {
return arg;
}
Try

或者,我們可以使用 any 型別來描述身分函數

ts
function identity(arg: any): any {
return arg;
}
Try

雖然使用 any 當然在於它會讓函數接受 arg 型別的任何和所有型別,但我們實際上會在函數回傳時失去該型別的資訊。如果我們傳入一個數字,我們唯一擁有的資訊就是任何型別都可以回傳。

相反地,我們需要一種方法來擷取引數的型別,這樣我們也可以使用它來表示回傳的內容。這裡,我們將使用型別變數,這是一種特殊類型的變數,用於處理型別而不是值。

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

我們現在已將型別變數 Type 新增到身分函數。這個 Type 讓我們可以擷取使用者提供的型別(例如 number),以便我們稍後可以使用該資訊。這裡,我們再次使用 Type 作為回傳型別。在檢查後,我們現在可以看到引數和回傳型別使用相同的型別。這讓我們可以將該型別資訊傳遞到函數的一端,再從另一端傳出。

我們說這個版本的 identity 函數是泛型的,因為它適用於各種型別。與使用 any 不同,它也和第一個使用數字作為引數和回傳型別的 identity 函數一樣精確(即,它不會遺失任何資訊)。

一旦我們寫好泛型身分函數,我們就可以用兩種方式之一來呼叫它。第一種方法是將所有引數(包括型別引數)傳遞給函數

ts
let output = identity<string>("myString");
let output: string
Try

這裡我們明確地將 Type 設定為 string 作為函數呼叫的引數之一,使用 <> 表示引數,而不是 ()

第二種方法也許是最常見的。這裡我們使用型別引數推論,也就是說,我們希望編譯器根據我們傳入的引數型別自動設定 Type 的值

ts
let output = identity("myString");
let output: string
Try

請注意,我們不必在尖括號 (<>) 中明確傳遞型別;編譯器只會查看值 "myString",並將 Type 設定為其型別。雖然型別引數推論可以是一個有用的工具,讓程式碼更簡短且更易於閱讀,但當編譯器無法推論型別時,你可能需要像前一個範例中那樣明確傳入型別引數,這可能會發生在更複雜的範例中。

使用泛型型別變數

當您開始使用泛型時,您會注意到,當您建立泛型函式(例如 identity)時,編譯器會強制您在函式主體中正確使用任何泛型型別參數。亦即,您實際上將這些參數視為可以是任何和所有型別。

讓我們採用我們先前提到的 identity 函式

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

如果我們還想要將參數 arg 的長度記錄到主控台中,每次呼叫時該怎麼辦?我們可能會想這樣寫

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

當我們這樣做時,編譯器會給我們一個錯誤,指出我們正在使用 arg.length 成員,但我們並未在任何地方說明 arg 有這個成員。請記住,我們先前提到這些型別變數代表任何和所有型別,因此使用這個函式的人可能會傳入一個 number,而 number 沒有 .length 成員。

假設我們實際上打算讓這個函式作用於 Type 陣列,而不是直接作用於 Type。由於我們使用的是陣列,因此 .length 成員應該是可用的。我們可以像建立其他型別的陣列一樣來描述這個陣列

ts
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
Try

您可以將 loggingIdentity 的型別讀作「泛型函式 loggingIdentity 採用一個型別參數 Type,以及一個參數 arg,它是一個 Type 陣列,並傳回一個 Type 陣列。」如果我們傳入一個數字陣列,我們會得到一個數字陣列,因為 Type 會繫結到 number。這讓我們能夠將我們的泛型型別變數 Type 用作我們正在使用的型別的一部分,而不是整個型別,這讓我們有更大的彈性。

我們也可以這樣寫範例

ts
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
Try

您可能已經從其他語言熟悉這種型別風格。在下一節中,我們將介紹如何建立您自己的泛型型別,例如 Array<Type>

泛型型別

在前面的章節中,我們建立了泛型身分函式,可以在各種型別上運作。在本節中,我們將探討函式本身的型別,以及如何建立泛型介面。

泛型函數的類型就像非泛型函數的類型一樣,類型參數列在第一個,類似於函數宣告

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: <Type>(arg: Type) => Type = identity;
Try

我們也可以在類型中使用不同的名稱作為泛型類型參數,只要類型變數的數量和類型變數的使用方式一致即可。

ts
function identity<Input>(arg: Input): Input {
return arg;
}
 
let myIdentity: <Input>(arg: Input) => Input = identity;
Try

我們也可以將泛型類型寫成物件文字類型的呼叫簽章

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: { <Type>(arg: Type): Type } = identity;
Try

這讓我們寫出第一個泛型介面。我們從前一個範例中取出物件文字,並將它移到介面中

ts
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn = identity;
Try

在類似的範例中,我們可能想要將泛型參數移到整個介面的參數。這讓我們可以看到我們泛型化的是什麼類型(例如 Dictionary<string> 而不是只有 Dictionary)。這讓類型參數對介面的所有其他成員可見。

ts
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn<number> = identity;
Try

請注意,我們的範例已變更為略有不同的東西。我們現在有一個非泛型函數簽章,它是泛型類型的一部分,而不是描述泛型函數。當我們使用 GenericIdentityFn 時,我們現在還需要指定對應的類型引數(這裡:number),實際上鎖定底層呼叫簽章將使用什麼。了解何時將類型參數直接放在呼叫簽章上,以及何時將它放在介面上本身,有助於描述類型的哪些面向是泛型的。

除了泛型介面外,我們還可以建立泛型類別。請注意,無法建立泛型列舉和命名空間。

泛型類別

泛型類別的形狀與泛型介面類似。泛型類別在類別名稱後面有尖括號 (<>) 的泛型類型參數清單。

ts
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;
};
Try

這是 GenericNumber 類別相當字面的用法,但你可能已經注意到,沒有任何東西限制它只能使用 number 類型。我們也可以使用 string 甚至更複雜的物件。

ts
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
 
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
Try

就像介面一樣,將類型參數放在類別本身上,讓我們確保類別的所有屬性都使用相同的類型。

正如我們在 關於類別的章節 中所涵蓋的,類別有兩個面向的類型:靜態面向和實例面向。泛型類別僅泛型化其實例面向,而不是其靜態面向,因此在使用類別時,靜態成員無法使用類別的類型參數。

泛型約束

如果您還記得之前的範例,您有時可能想要撰寫一個泛函數,它可以在一組類型上運作,而您對這組類型將具備哪些功能有一些了解。在我們的 loggingIdentity 範例中,我們想要能夠存取 arg.length 屬性,但編譯器無法證明每個類型都有 .length 屬性,因此它會警告我們,我們不能做出這個假設。

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

我們不想要使用任何和所有類型,而是希望將此函數限制為與任何和所有類型一起使用,這些類型具有 .length 屬性。只要類型有這個成員,我們就會允許它,但它至少需要有這個成員。為此,我們必須將我們的需求列為 Type 可以是什麼的限制。

為此,我們將建立一個描述我們限制的介面。在此,我們將建立一個具有單一 .length 屬性的介面,然後我們將使用這個介面和 extends 關鍵字來表示我們的限制

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

由於泛函數現在受到限制,因此它不再會在任何和所有類型上運作

ts
loggingIdentity(3);
Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.2345Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
Try

相反地,我們需要傳入類型具有所有必要屬性的值

ts
loggingIdentity({ length: 10, value: 3 });
Try

在泛型限制中使用類型參數

您可以宣告一個受另一個類型參數約束的類型參數。例如,在此我們想要從物件取得一個屬性,並給定它的名稱。我們想要確保我們不會意外擷取到不存在於 obj 上的屬性,因此我們會在兩個類型之間放置一個限制

ts
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");
Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.2345Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.
Try

在泛型中使用類別類型

在 TypeScript 中使用泛型建立工廠時,必須透過建構函式來參照類別類型。例如,

ts
function create<Type>(c: { new (): Type }): Type {
return new c();
}
Try

更進階的範例使用原型屬性來推論和限制建構函式與類別類型實例側之間的關係。

ts
class BeeKeeper {
hasMask: boolean = true;
}
 
class ZooKeeper {
nametag: string = "Mikle";
}
 
class Animal {
numLegs: number = 4;
}
 
class Bee extends Animal {
numLegs = 6;
keeper: BeeKeeper = new BeeKeeper();
}
 
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
 
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
 
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;
Try

此模式用於支援 mixins 設計模式。

泛型參數預設值

透過宣告泛型類型參數的預設值,您可以選擇不指定對應的類型引數。例如,建立新 HTMLElement 的函式。在不帶任何引數呼叫函式時,會產生 HTMLDivElement;在將元素作為第一個引數呼叫函式時,會產生引數類型元素。您也可以選擇傳遞子項清單。以前您必須將函式定義為

ts
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]
): Container<T, U[]>;
Try

使用泛型參數預設值,我們可以將其簡化為

ts
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
element?: T,
children?: U
): Container<T, U>;
 
const div = create();
const div: Container<HTMLDivElement, HTMLDivElement[]>
 
const p = create(new HTMLParagraphElement());
const p: Container<HTMLParagraphElement, HTMLParagraphElement[]>
Try

泛型參數預設值遵循下列規則

  • 如果類型參數有預設值,則視為選用。
  • 必要的類型參數不得置於選用類型參數之後。
  • 如果存在,類型參數的預設類型必須符合類型參數的約束。
  • 指定類型引數時,您只需要為必要的類型參數指定類型引數。未指定的類型參數將解析為其預設類型。
  • 如果指定了預設類型,且推論無法選擇候選項,則會推論預設類型。
  • 與現有類別或介面宣告合併的類別或介面宣告可能會為現有的類型參數引入預設值。
  • 與現有類別或介面宣告合併的類別或介面宣告可能會引入新的類型參數,只要它指定預設值即可。

TypeScript 文件是一個開源專案。透過 傳送 Pull Request ❤ 來協助我們改善這些頁面

此頁面的貢獻者
OTOrta Therox (26)
NKNavneet Karnani (2)
JBJake Bailey (1)
MMFredX (1)
ZSZack Schuster (1)
6+

最後更新:2024 年 3 月 21 日