TypeScript 官方手冊翻譯計劃【六】:型別操控-泛型

Chor發表於2021-12-02
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:Generics

泛型

軟體工程的一個主要部分是構建元件。元件不僅具有定義良好且一致的 API,而且還具有可重用性。元件如果既能處理現在的資料,又能處理將來的資料,那麼它將在構建大型軟體系統的時候為你提供最靈活的能力。

在 C# 和 Java 等語言中,建立可重用元件的主要工具之一就是泛型。利用泛型,我們可以建立出適用於多種型別而不是單一型別的元件,從而允許使用者在使用這些元件的時候用上自己的型別。

初識泛型

初識泛型,讓我們先來實現一個 identity 函式。identity 函式可以接受任意引數並將其返回,你可以認為它的功能類似於 echo 指令。

如果不使用泛型,那麼我們必須給 identity 函式指定一個具體的型別:

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

或者,我們可以使用 any 型別去描述 identity 函式:

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

使用 any 確實是一種通用的做法,因為函式的 arg 引數可以接受任意型別或者所有型別的值,但實際上,我們丟失了函式返回值型別的資訊。如果我們傳遞的引數是數字,那麼我們唯一能知道的資訊是函式可以返回任意型別的值。

相反,我們需要一種方法去捕獲引數的型別,這樣我們也可以使用它來表示返回值的型別。這裡,我們會使用一個型別變數,這種特殊的變數作用於型別而非值。

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

我們現在給 identity 函式新增了型別變數 TypeType 允許我們捕獲使用者傳入引數的型別(比如說 number 型別),這將作為稍後可以利用的資訊。接著,我們在返回值型別的宣告中也使用了 Type。從程式碼可以看出,引數和返回值都使用了相同的型別,這可以讓我們在函式的一側接受型別資訊,並將資訊傳輸給另一側作為函式的輸出。

我們稱這個版本的 identity 函式是一個泛型函式,因為它適用於一系列的型別。和使用 any 不同,這個函式非常地明確(比如說,它沒有丟失任何型別資訊),效果和之前使用 number 作為引數和返回值型別的那個 identity 函式一樣。

一旦實現了泛型的 identity 函式,我們就可以通過兩種方式去呼叫它。第一種方式是給函式傳遞所有引數,包括型別引數:

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

這裡,我們顯式設定 Typestring 並將其作為函式呼叫的引數。注意包裹引數的是 <> 不是 ()

第二種方式可能是最常用的。我們在這裡使用了型別引數推斷 —— 也就是說,我們想要讓編譯器基於傳入引數的型別自動設定 Type 的值:

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

注意,我們並不一定要在 <> 中顯式傳入型別。編譯器會檢視值 myString,並將這個值的型別作為 Type 的值。雖然型別引數推斷可以有效地保證程式碼的簡潔與可讀性,但在更復雜的案例中,編譯器可能無法成功推斷出型別,這時候你就需要像之前那樣顯式傳入型別引數了。

使用泛型的型別變數

在你開始使用泛型之後,你會注意到,每次你建立類似 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 型別的值進去,而這種值是沒有 .length 成員的。

假設我們實際上想要讓這個函式處理 Type 型別的陣列,而不是直接處理 Type。既然當前處理的是陣列,那麼它就肯定有 .length 成員了。我們可以像建立其它型別的陣列一樣進行描述:

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

你可以將 loggingIdentity 的型別解讀為“泛型函式 loggingIdentity 接受一個型別引數 Type,以及一個引數 arg,後者是一個 Type 型別的陣列。函式最後返回的也是一個 Type 型別的陣列”。如果我們傳入的是 number 型別的陣列,那麼最終返回的也是 number 型別的陣列,因為 Type 會繫結為 number。這允許我們將泛型型別變數 Type 作為要用到的其中一種型別去使用,而不是作為整個型別去使用,因此給予了我們更大的靈活性。

我們也可以將這個簡單的示例改寫為如下:

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // 陣列有 length 屬性,所以不會再丟擲錯誤
  return arg;
}

你可能已經很熟悉其它語言中的這種型別風格了。在下一節中,我們會介紹如何自己建立類似 Array<Type> 這樣的泛型。

泛型型別

在前面幾節中,我們建立了適用於一系列型別的泛型函式 identity。在本節中,我們將探索函式本身的型別以及建立泛型介面的方法。

泛型函式型別和非泛型函式型別一樣,只是前者會像泛型函式宣告一樣先列舉出型別引數:

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),從而有效地保證它被底層的函式簽名所使用。理解什麼時候把型別引數直接放到呼叫簽名中,什麼時候把型別引數放到介面本身中,對於描述某個型別的哪些地方是泛型有很大的作用。

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

泛型類

泛型類和泛型介面的結構很相似。泛型類在類名後面會跟著 <>,裡面是一個泛型型別引數列表。

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

這種使用 GenericNumber 類的方法非常平實,但你可能注意到了一件事情,那就是我們並沒有限制類只能使用 number 型別。相反,我們可以使用 string 甚至是其它更復雜的物件。

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

和介面一樣,把型別引數放到類本身,可以讓我們確保類的所有屬性都在使用同一型別。

我們在關於類的章節有提到過,類包含兩部分:靜態部分和例項部分。泛型類的泛型只適用於例項部分而非靜態部分,因此在使用泛型類的時候,靜態成員無法使用類的型別引數,

泛型約束

還記得之前訪問引數長度的例子嗎?有時候,你可能需要編寫一個只作用於某些型別的泛型函式,並且你對這些型別的特徵有一定的瞭解。比如在示例的 loggingIdentity 函式中,我們想要訪問 arglength 屬性,但是編譯器無法驗證每個型別都有 length 屬性,因此它警告我們,我們不能假設所有型別都有該屬性。

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

我們不想讓函式處理任意的型別,而是想將它約束為只能處理任意具備 length 屬性的型別。只要某個型別具備這個屬性,我們就允許傳入該型別,反過來,要傳入某個型別,那麼它至少必須具備這個屬性。為了實現這一點,我們必須列舉出對 Type 的要求,以約束 Type 的型別。

為此,我們會建立一個描述約束條件的介面。這裡,我們建立了一個具備單屬性 length 的介面,之後使用該介面和 extends 關鍵字去表示我們的約束:

interface Lengthwise {
  length: number;
}
 
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // 現在我們知道它一定是有 length 屬性的,所以不會丟擲錯誤
  return arg;
}

因為泛型函式現在受到了約束,所以它不再可以處理任意的、所有的型別:

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

相反,我們傳入的值的型別應該具備所有必需屬性:

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

在泛型約束中使用型別引數

你可以宣告一個型別引數,讓它受到另一個型別引數的約束。舉個例子,現在我們需要通過給定屬性名訪問物件的屬性,那麼我們必須確保不會意外地訪問物件上不存在的屬性,因此我們就會在兩個型別之間使用一個約束:

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

在泛型中使用類型別

在使用 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;

這種模式可以用於驅動混入設計模式。

相關文章