TypeScript 之 Generics

冴羽發表於2021-11-25

前言

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。

本篇整理自 TypeScript Handbook 中 「Generics」 章節。

本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。

正文

軟體工程的一個重要部分就是構建元件,元件不僅需要有定義良好和一致的 API,也需要是可複用的(reusable)。好的元件不僅能夠相容今天的資料型別,也能適用於未來可能出現的資料型別,這在構建大型軟體系統時會給你最大的靈活度。

在比如 C# 和 Java 語言中,用來建立可複用元件的工具,我們稱之為泛型(generics)。利用泛型,我們可以建立一個支援眾多型別的元件,這讓使用者可以使用自己的型別消費(consume)這些元件。

Generics 初探(Hello World of Generics)

讓我們開始寫第一個泛型,一個恆等函式(identity function)。所謂恆等函式,就是一個返回任何傳進內容的函式。你也可以把它理解為類似於 echo 命令。

不借助泛型,我們也許需要給予恆等函式一個具體的型別:

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

或者,我們使用 any 型別:

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

儘管使用 any 型別可以讓我們接受任何型別的 arg 引數,但也讓我們丟失了函式返回時的型別資訊。如果我們傳入一個數字,我們唯一知道的資訊是函式可以返回任何型別的值。

所以我們需要一種可以捕獲引數型別的方式,然後再用它表示返回值的型別。這裡我們用了一個型別變數(type variable),一種用在型別而非值上的特殊的變數。

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

現在我們已經給恆等函式加上了一個型別變數 Type,這個 Type 允許我們捕獲使用者提供的型別,使得我們在接下來可以使用這個型別。這裡,我們再次用 Type 作為返回的值的型別。在現在的寫法裡,我們可以清楚的知道引數和返回值的型別是同一個。

現在這個版本的恆等函式就是一個泛型,它可以支援傳入多種型別。不同於使用 any,它沒有丟失任何資訊,就跟第一個使用 number 作為引數和返回值型別的的恆等函式一樣準確。

在我們寫了一個泛型恆等函式後,我們有兩種方式可以呼叫它。第一種方式是傳入所有的引數,包括型別引數:

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

在這裡,我們使用 <> 而不是 ()包裹了引數,並明確的設定 Typestring 作為函式呼叫的一個引數。

第二種方式可能更常見一些,這裡我們使用了型別引數推斷(type argument inference)(部分中文文件會翻譯為“型別推論”),我們希望編譯器能基於我們傳入的引數自動推斷和設定 Type 的值。

let output = identity("myString"); // let output: string

注意這次我們並沒有用 <> 明確的傳入型別,當編譯器看到 myString 這個值,就會自動設定 Type 為它的型別(即 string)。

型別引數推斷是一個很有用的工具,它可以讓我們的程式碼更短更易閱讀。而在一些更加複雜的例子中,當編譯器推斷型別失敗,你才需要像上一個例子中那樣,明確的傳入引數。

使用泛型型別變數(Working with Generic Type Variables)

當你建立類似於 identity 這樣的泛型函式時,你會發現,編譯器會強制你在函式體內,正確的使用這些型別引數。這就意味著,你必須認真的對待這些引數,考慮到他們可能是任何一個,甚至是所有的型別(比如用了聯合型別)。

讓我們以 identity 函式為例:

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

如果我們想列印 arg 引數的長度呢?我們也許會嘗試這樣寫:

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

如果我們這樣做,編譯器會報錯,提示我們正在使用 arg.length屬性,但是我們卻沒有在其他地方宣告 arg 有這個屬性。我們前面也說了這些型別變數代表了任何甚至所有型別。所以完全有可能,呼叫的時候傳入的是一個 number 型別,但是 number 並沒有 .length 屬性。

現在假設這個函式,使用的是 Type 型別的陣列而不是 Type。因為我們使用的是陣列,.length 屬性肯定存在。我們就可以像建立其他型別的陣列一樣寫:

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

你可以這樣理解 loggingIdentity 的型別:泛型函式 loggingIdentity 接受一個 Type 型別引數和一個實參 arg,實參 arg 是一個 Type 型別的陣列。而該函式返回一個 Type 型別的陣列。

如果我們傳入的是一個全是數字型別的陣列,我們的返回值同樣是一個全是數字型別的陣列,因為 Type 會被當成 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)

在上個章節,我們已經建立了一個泛型恆等函式,可以支援傳入不同的型別。在這個章節,我們探索函式本身的型別,以及如何建立泛型介面。

泛型函式的形式就跟其他非泛型函式的一樣,都需要先列一個型別引數列表,這有點像函式宣告:

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;

我們也可以以物件型別的呼叫簽名的形式,書寫這個泛型型別:

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

這可以引導我們寫出第一個泛型介面,讓我們使用上個例子中的物件字面量,然後把它的程式碼移動到介面裡:

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

有的時候,我們會希望將泛型引數作為整個介面的引數,這可以讓我們清楚的知道傳入的是什麼引數 (舉個例子:Dictionary<string> 而不是 Dictionary)。而且介面裡其他的成員也可以看到。

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

注意在這個例子裡,我們只做了少許改動。不再描述一個泛型函式,而是將一個非泛型函式簽名,作為泛型型別的一部分。

現在當我們使用 GenericIdentityFn 的時候,需要明確給出引數的型別。(在這個例子中,是 number),有效的鎖定了呼叫簽名使用的型別。

當要描述一個包含泛型的型別時,理解什麼時候把型別引數放在呼叫簽名裡,什麼時候把它放在介面裡是很有用的。

除了泛型介面之外,我們也可以建立泛型類。注意,不可能建立泛型列舉型別和泛型名稱空間。

泛型類(Generic Classes)

泛型類寫法上類似於泛型介面。在類名後面,使用尖括號中 <> 包裹住型別引數列表:

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

在這個例子中,並沒有限制你只能使用 number 型別。我們也可以使用 string 甚至更復雜的型別:

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

就像介面一樣,把型別引數放在類上,可以確保類中的所有屬性都使用了相同的型別。

正如我們在 Class 章節提過的,一個類它的型別有兩部分:靜態部分和例項部分。泛型類僅僅對例項部分生效,所以當我們使用類的時候,注意靜態成員並不能使用型別引數。

泛型約束(Generic Constraints)

在早一點的 loggingIdentity 例子中,我們想要獲取引數 arg.length 屬性,但是編譯器並不能證明每種型別都有 .length 屬性,所以它會提示錯誤:

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

相比於能相容任何型別,我們更願意約束這個函式,讓它只能使用帶有 .length 屬性的型別。只要型別有這個成員,我們就允許使用它,但必須至少要有這個成員。為此,我們需要列出對 Type 約束中的必要條件。

為此,我們需要建立一個介面,用來描述約束。這裡,我們建立了一個只有 .length 屬性的介面,然後我們使用這個介面和 extend關鍵詞實現了約束:

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

現在這個泛型函式被約束了,它不再適用於所有型別:

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

我們需要傳入符合約束條件的值:

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

在泛型約束中使用型別引數(Using Type Parameters in Generic Constraints)

你可以宣告一個型別引數,這個型別引數被其他型別引數約束。

舉個例子,我們希望獲取一個物件給定屬性名的值,為此,我們需要確保我們不會獲取 obj 上不存在的屬性。所以我們在兩個型別之間建立一個約束:

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"'.

在泛型中使用類型別(Using Class Types in Generics)

在 TypeScript 中,當使用工廠模式建立例項的時候,有必要通過他們的建構函式推斷出類的型別,舉個例子:

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

下面是一個更復雜的例子,使用原型屬性推斷和約束,建構函式和類例項的關係。

class BeeKeeper {
  hasMask: boolean = true;
}
 
class ZooKeeper {
  nametag: string = "Mikle";
}
 
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();
}
 
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

TypeScript 系列

  1. TypeScript 之 Narrowing
  2. TypeScript 之 More on Functions
  3. TypeScript 之 Object Type

如果你對於 TypeScript 有什麼困惑或者其他想要了解的內容,歡迎與我交流,微信:「mqyqingfeng」,公眾號搜尋:「冴羽的JavaScript部落格」或者「yayujs」

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章