TypeScript手册——类型操作——泛型(Generics)

37 阅读11分钟

免责声明: 本文为翻译自 TypeScript 官方手册内容,非原创。版权归原作者所有。

原文来源: Generics

翻译者注: 本人在翻译过程中已尽最大努力确保内容的准确性和完整性,但翻译工作难免有疏漏。如读者在阅读中发现任何问题,欢迎提出建议或直接查阅原文以获取最准确的信息。

软件工程的主要部分是构建的组件不仅需要有明确定义和一致的 API,而且还需要具备可复用性。只有那些既能处理现在的数据,也能处理未来数据的组件,才能为你在构建大型软件系统时提供最大的灵活性。

在像 C# 和 Java 这样的语言中,创建可重用组件的主要工具之一是泛型,也就是说,能够创建一个可以处理各种类型而不仅仅是单一类型的组件。这使得用户可以使用这些组件并使用他们自己的类型。

泛型的 'Hello World'

首先,让我们从泛型的 “hello world” 开始:恒等函数。恒等函数是一个会返回任何传入内容的函数。你可以将其视为类似于 echo 命令的方式。

如果没有泛型,我们要么需要给恒等函数一个指定的类型:

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

或者,我们可以使用 any 类型来描述恒等函数:

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

尽管使用 any 确实很通用,因为它会使函数接受任何和所有类型作为 arg 的类型,但实际上,当函数返回时,我们实际上失去了关于该类型是什么的信息。如果我们传入了一个数字,我们唯一知道的就是可能返回任何类型。

相反,我们需要一种方式来捕获参数的类型,以便我们也可以用它来表示返回的内容。在这里,我们将使用一个类型变量,这是一种特殊的变量,它作用于类型而不是值。

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

我们现在已经在恒等函数中添加了一个类型变量 Type。这个 Type 让我们可以捕获用户提供的类型(例如,number),以便我们稍后可以使用这些信息。在这里,我们又一次将 Type 用作返回类型。在检查时,我们现在可以看到参数和返回类型使用了相同的类型。这允许我们在函数的一边传入类型信息,在另一边传出。

我们说这个版本的恒等函数是泛型的,因为它适用于一系列的类型。不同于使用 any,它也同样是精确的(即,它不会丢失任何信息),如同第一个使用数字作为参数和返回类型的恒等函数那样。

一旦我们写了泛型的恒等函数,我们就可以用两种方式中的一种来调用它。第一种方式是传递所有的参数,包括类型参数,给函数:

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

这里,我们在函数调用的参数中显式设置 Typestring,通过在参数周围使用 <> 而非 () 来表示。

第二种方法也可能是最常见的。在这里,我们使用类型参数推断——也就是说,我们希望编译器根据我们传入的参数类型自动为我们设置 Type 的值:

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

注意,我们并不需要在尖括号(<>)中显式传递类型;编译器只是查看了值 "myString",并将 Type 设置为其类型。虽然类型参数推断可以作为一个有用的工具来使代码更短、更易读,但当编译器无法推断出类型时,你可能需要像我们在前面的例子中那样显式地传入类型参数,这可能会在更复杂的例子中发生。

使用泛型类型变量

当你开始使用泛型时,你会注意到,当你创建像恒等这样的泛型函数时,编译器会强制你在函数体中正确地使用任何泛型类型的参数。也就是说,你实际上要对待这些参数,就好像它们可以是任何类型一样。

让我们来回顾一下之前的恒等函数:

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

如果我们也想在每次调用时将参数 arg 的长度记录到控制台,该怎么办?我们可能会试图这样写:

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  // 报错:类型“Type”上不存在属性“length”
  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,以及一个 Type 的数组作为参数 arg,并返回一个 Type 的数组。” 如果我们传入一个数字数组,我们将得到一个数字数组输出,因为 Type 会绑定到 number。这允许我们使用泛型类型变量 Type 作为我们正在处理的类型的一部分,而不是整个类型,给予我们更大的灵活性。

我们也可以用这种方式来书写样本示例:

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array 拥有 .length 属性,所以没有报错
  return arg;
}

你可能已经熟悉了这种来自其他语言的类型风格。在下一节中,我们将介绍如何创建你自己的泛型类型,比如 Array<Type>

泛型类型

在前面的部分,我们创建了工作在一系列类型上的泛型恒等函数。在这一节中,我们将探索这些函数本身的类型以及如何创建泛型接口。

泛型函数的类型就像非泛型函数的类型一样,首先列出了类型参数,这与函数声明类似:

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

我们也可以在类型中为泛型类型参数使用不同的名称,只要类型变量的数量和类型变量的使用方式对应即可。

function identity<Input>(arg: Input): Input {
  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 示例中,我们希望能够访问 arg.length 属性,但编译器无法证明每种类型都有 .length 属性,所以它警告我们不能做这种假设。

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  // 报错:类型“Type”上不存在属性“length”
  return arg;
}

我们希望限制这个函数只能处理具有 .length 属性的所有类型,而不是任何类型。只要类型具有此成员,我们就允许它,但至少需要有这个成员。为了做到这一点,我们必须将我们的需求列为对 Type 可以是什么的约束。

为此,我们将创建一个描述我们约束的接口。在这里,我们将创建一个具有单一 .length 属性的接口,然后我们将使用这个接口和 extends 关键字来表示我们的约束:

interface Lengthwise {
  length: number;
}
 
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // 现在我们知道它有一个.length属性,所以不再出错。
  return arg;
}

因为泛型函数现在受到了限制,它将不再适用于任何类型:

loggingIdentity(3)
// 报错:类型“number”的参数不能赋给类型“Lengthwise”的参数。

相反,我们需要传入具有所有必需属性的类型的值:

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

在泛型约束中使用类型参数

你可以声明一个受其他类型参数约束的类型参数。例如,我们想从一个对象中获取其名称所对应的属性。我们希望确保我们不会意外地抓取到 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");
// 报错:类型“"m"”的参数不能赋给类型“"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 {
  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;

这种模式被用来驱动混入设计模式的运行。

泛型参数默认值

通过为泛型类型参数声明一个默认值,你可以使得指定相应的类型参数变为可选。例如,一个创建新 HTMLElement 的函数。不带任何参数调用该函数会生成一个 HTMLDivElement;以元素作为第一个参数调用该函数会生成与参数类型相对应的元素。你也可以选择性地传递一组子元素。之前,你需要这样定义函数:

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[]>;

通过使用泛型参数默认值,我们可以将其减少到:

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[]>

泛型参数默认值遵循以下规则:

  • 如果类型参数有默认值,则视为可选。
  • 必需的类型参数不能在可选的类型参数之后。
  • 类型参数的默认类型必须满足该类型参数的约束(如果存在)。
  • 在指定类型参数时,只需要为必需的类型参数指定类型参数。未指定的类型参数将解析为它们的默认类型。
  • 如果指定了默认类型,并且推断无法选择候选项,则会推断出默认类型。
  • 与现有类或接口声明合并的类或接口声明可能会为现有类型参数引入默认值。
  • 只要它指定了一个默认值,与现有类或接口声明合并的类或接口声明就可以引入一个新的类型参数。