泛型
软件工程的一个主要部分是构建组件,这些组件不仅具有定义明确且一致的 API,而且还可以重用。 能够处理今天的数据以及明天的数据的组件将为您构建大型软件系统提供最灵活的能力。
在 C# 和 Java 等语言中,用于创建可重用组件的工具箱中的主要工具之一是泛型,即能够创建一个可以处理多种类型而不是单一类型的组件。 这允许用户使用这些组件并应用他们自己的类型。
泛型的 Hello World
首先,让我们来做泛型的“hello world”:恒等函数。 恒等函数是一个返回传入内容的函数。 你可以把它看作是 echo 命令的类似物。
如果没有泛型,我们要么必须给恒等函数一个特定的类型:
或者,我们可以使用 any 类型来描述恒等函数:
虽然使用 any 肯定是泛型的,因为它会使函数接受 arg 的任何和所有类型,但实际上我们丢失了函数返回时该类型是什么的信息。 如果我们传入一个数字,我们唯一的信息是可能返回任何类型。
相反,我们需要一种捕获参数类型的方式,以便我们也可以用它来表示返回的内容。 这里,我们将使用一个类型变量,一种特殊的变量,它作用于类型而不是值。
我们现在已经向恒等函数添加了一个类型变量 Type。 这个 Type 允许我们捕获用户提供的类型(例如 number),以便我们稍后可以使用该信息。 在这里,我们再次将 Type 用作返回类型。通过检查,我们现在可以看到参数和返回类型使用了相同的类型。 这使我们能够将该类型信息在函数的一侧传入,并在另一侧传出。
我们说这个版本的 identity 函数是泛型的,因为它适用于一系列类型。 与使用 any 不同,它和第一个使用数字作为参数和返回类型的 identity 函数一样精确(即,它不会丢失任何信息)。
一旦我们编写了泛型恒等函数,我们可以通过两种方式之一调用它。 第一种方式是向函数传递所有参数,包括类型参数:
这里我们明确地将 Type 设置为 string,作为函数调用的参数之一,使用 <> 而不是 () 将参数括起来。
第二种方式也许也是最常见的。这里我们使用类型参数推断——也就是说,我们希望编译器根据我们传入的参数类型自动为我们设置 Type 的值:
注意,我们不必在尖括号(<>)中显式传递类型;编译器只查看了值 "myString",并将 Type 设置为其类型。 虽然类型参数推断可以是一个有助于保持代码更短、更易读的工具,但当编译器无法推断类型时(可能在更复杂的例子中发生),你可能需要像我们在前面的例子中那样显式地传入类型参数。
使用泛型类型变量
当你开始使用泛型时,你会注意到,当你创建像 identity 这样的泛型函数时,编译器会强制你在函数体内正确地使用任何泛型类型的参数。 也就是说,你要像对待任何和所有类型一样对待这些参数。
让我们看看之前的 identity 函数:
如果我们还想在每次调用时将参数 arg 的长度记录到控制台,该怎么办? 我们可能会尝试这样写:
当我们这样做时,编译器会给我们一个错误,说我们正在使用 arg 的 .length 成员,但我们没有说 arg 具有这个成员。 记住,我们之前说过这些类型变量代表任何和所有类型,所以使用此函数的人可能传入了一个 number,而 number 没有 .length 成员。
假设我们实际上打算让这个函数处理 Type 的数组而不是直接的 Type。由于我们处理的是数组,.length 成员应该是可用的。 我们可以像创建其他类型的数组一样来描述这一点:
你可以将 loggingIdentity 的类型解读为“泛型函数 loggingIdentity 接受一个类型参数 Type,以及一个参数 arg,它是一个 Type 的数组,并返回一个 Type 的数组。” 如果我们传入一个数字数组,我们将得到一个数字数组返回,因为 Type 会绑定到 number。 这允许我们将泛型类型变量 Type 作为我们正在处理的类型的一部分,而不是整个类型,从而给我们带来更大的灵活性。
我们也可以这样写这个示例:
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // 数组有 .length,因此不再有错误
return arg;
}Try你可能已经熟悉其他语言中的这种类型风格。 在下一节中,我们将介绍如何创建像 Array<Type> 这样的你自己的泛型类型。
泛型类型
在前面的章节中,我们创建了适用于一系列类型的泛型恒等函数。 在本节中,我们将探讨函数本身的类型以及如何创建泛型接口。
泛型函数的类型与非泛型函数的类型类似,类型参数首先列出,类似于函数声明:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity;Try我们也可以为泛型类型参数使用不同的名称,只要类型变量的数量以及类型变量的使用方式保持一致即可。
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Input>(arg: Input) => Input = identity;Try我们也可以将泛型类型写为对象字面量类型的调用签名:
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: { <Type>(arg: Type): Type } = identity;Try这引导我们编写第一个泛型接口。 让我们把上一个例子中的对象字面量移到一个接口中:
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn = identity;Try在一个类似的例子中,我们可能希望将泛型参数移到整个接口的参数上。 这让我们可以看到我们在哪些类型上是泛型的(例如 Dictionary<string> 而不仅仅是 Dictionary)。 这使得类型参数对接口的所有其他成员可见。
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;Try请注意,我们的例子已经变成略有不同的东西。 我们现在不再描述一个泛型函数,而是拥有一个非泛型函数签名,它是泛型类型的一部分。 当我们使用 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;
};Try这是对 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"));Try就像接口一样,将类型参数放在类本身上可以确保类的所有属性都使用相同的类型。
正如我们在关于类的章节中介绍的那样,一个类有两方面的类型:静态方面和实例方面。 泛型类仅在其实例方面是泛型的,而不是其静态方面,因此在使用类时,静态成员不能使用类的类型参数。
泛型约束
如果你还记得前面的例子,有时你可能想要编写一个泛型函数,该函数作用于一组类型,并且你了解这组类型将具有的某些能力。 在我们的 loggingIdentity 示例中,我们希望能够访问 arg 的 .length 属性,但编译器无法证明每个类型都有 .length 属性,因此它警告我们不能做出这种假设。
我们不希望处理任何和所有类型,而是希望约束此函数以处理也具有 .length 属性的任何和所有类型。 只要类型具有这个成员,我们就允许它,但它必须至少具有这个成员。 要做到这一点,我们必须将我们的要求列为对 Type 可以是什么的约束。
为此,我们将创建一个描述约束的接口。 这里,我们将创建一个具有单个 .length 属性的接口,然后我们将使用这个接口和 extends 关键字来表示我们的约束:
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // 现在我们知道它具有 .length 属性,因此不再有错误
return arg;
}Try因为泛型函数现在受到约束,它将不再适用于任何和所有类型:
相反,我们需要传入其类型具有所有必需属性的值:
在泛型约束中使用类型参数
你可以声明一个受另一个类型参数约束的类型参数。 例如,这里我们想从一个对象中根据其名称获取一个属性。 我们希望确保不会意外地获取 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");Try在泛型中使用类类型
在 TypeScript 中使用泛型创建工厂时,有必要通过其构造函数来引用类类型。例如,
一个更高级的示例使用原型属性来推断和约束构造函数与类类型的实例方面之间的关系。
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;以元素作为第一个参数调用该函数生成一个参数类型的元素。你还可以选择传递一个子元素列表。以前你需要将函数定义为:
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使用泛型参数默认值,我们可以将其简化为:
declare function create<T extends HTMLElement = HTMLDivElement, U extends HTMLElement[] = T[]>(
element?: T,
children?: U
): Container<T, U>;
const div = create();
const p = create(new HTMLParagraphElement());
Try泛型参数默认值遵循以下规则:
- 如果类型参数具有默认值,则视为可选。
- 必需的类型参数不能跟在可选的类型参数之后。
- 类型参数的默认类型必须满足该类型参数的约束(如果存在)。
- 当指定类型参数时,你只需要为必需的类型参数指定类型参数。未指定的类型参数将解析为其默认类型。
- 如果指定了默认类型,并且推断无法选择候选类型,则推断默认类型。
- 与现有类或接口声明合并的类或接口声明可以为现有的类型参数引入默认值。
- 与现有类或接口声明合并的类或接口声明可以引入新的类型参数,只要它指定了默认值。
方差注解
这是一个用于解决非常具体问题的高级特性,只应在你确定需要使用它的情况下使用
协变和逆变 是类型理论中的术语,描述了两种泛型类型之间的关系。 以下是关于这个概念的一个简要入门。
例如,如果你有一个表示可以 make 某种类型的对象的接口:
interface Producer<T> {
make(): T;
}我们可以在期望 Producer<Animal> 的地方使用 Producer<Cat>,因为 Cat 是 Animal。 这种关系称为协变:Producer<T> 与 Producer<U> 之间的关系与 T 与 U 之间的关系相同。
相反,如果你有一个可以 consume 某种类型的接口:
interface Consumer<T> {
consume: (arg: T) => void;
}那么我们可以在期望 Consumer<Cat> 的地方使用 Consumer<Animal>,因为任何能够接受 Animal 的函数也一定能够接受 Cat。 这种关系称为逆变:Consumer<T> 与 Consumer<U> 之间的关系与 U 与 T 之间的关系相同。 注意与协变相比方向是相反的!这就是为什么逆变“会自我抵消”,而协变不会。
在像 TypeScript 这样的结构类型系统中,协变和逆变是从类型的定义自然产生的行为。 即使没有泛型,我们也会看到协变(和逆变)关系:
interface AnimalProducer {
make(): Animal;
}
// CatProducer 可以在任何期望
// Animal 生成器的地方使用
interface CatProducer {
make(): Cat;
}TypeScript 具有结构类型系统,因此在比较两种类型时,例如查看 Producer<Cat> 是否可以在期望 Producer<Animal> 的地方使用,通常的算法是结构性地扩展这两个定义,并比较它们的结构。 然而,方差允许一个非常有用的优化:如果 Producer<T> 在 T 上是协变的,那么我们可以简单地检查 Cat 和 Animal,因为我们知道它们将具有与 Producer<Cat> 和 Producer<Animal> 相同的关系。
请注意,这种逻辑仅在我们检查同一类型的两个实例化时才能使用。 如果我们有一个 Producer<T> 和一个 FastProducer<U>,不能保证 T 和 U 必然引用这些类型中的相同位置,因此这种检查将始终以结构方式执行。
由于方差是结构类型的自然属性,TypeScript 会自动推断每个泛型类型的方差。 在极少数情况下,涉及到某些循环类型时,这种推断可能不准确。 如果发生这种情况,你可以向类型参数添加方差注解来强制指定特定的方差:
// 逆变注解
interface Consumer<in T> {
consume: (arg: T) => void;
}
// 协变注解
interface Producer<out T> {
make(): T;
}
// 不变注解
interface ProducerConsumer<in out T> {
consume: (arg: T) => void;
make(): T;
}只有在编写与结构上应该发生的方差相同时才这样做。
千万不要编写与结构方差不匹配的方差注解!
必须强调的是,方差注解仅在基于实例化的比较期间生效。 它们在结构比较期间不起作用。 例如,你不能使用方差注解来“强制”一个类型实际上是不变的:
// 不要这样做 - 方差注解
// 与结构行为不匹配
interface Producer<in out T> {
make(): T;
}
// 这不是类型错误 -- 这是一个结构
// 比较,因此方差注解不生效
const p: Producer<string | number> = {
make(): number {
return 42;
}
}这里,对象字面量的 make 函数返回 number,我们可能期望这会导致错误,因为 number 不是 string | number。 然而,这不是基于实例化的比较,因为对象字面量是一个匿名类型,而不是 Producer<string | number>。
方差注解不会改变结构行为,并且仅在特定情况下被参考
只有在绝对了解为什么要这样做、它们的限制以及它们何时不生效的情况下,才编写方差注解。 TypeScript 使用基于实例化的比较还是结构比较并不是一个明确的行为,可能会因正确性或性能原因在不同版本之间发生变化,因此只有在方差注解与类型的结构行为匹配时才应编写它们。 不要试图使用方差注解来“强制”特定的方差;这会导致代码中出现不可预测的行为。
除非方差注解与类型的结构行为匹配,否则不要编写方差注解
记住,TypeScript 可以自动从你的泛型类型中推断方差。 几乎从来不需要编写方差注解,只有在确定了特定需求时才应该这样做。 方差注解不会改变类型的结构行为,根据情况,你可能在期望基于实例化的比较时看到结构比较。 方差注解不能用于修改类型在这些结构上下文中的行为方式,并且除非注解与结构定义相同,否则不应编写。 由于这很难做对,并且 TypeScript 在绝大多数情况下都能正确推断方差,你不应该在正常代码中发现自己编写方差注解。
不要试图使用方差注解来改变类型检查行为;这不是它们的用途
在“类型调试”的情况下,你可能会发现临时方差注解很有用,因为方差注解会被检查。 如果注解的方差明显错误,TypeScript 会发出错误:
// 错误,这个接口在 T 上显然是逆变的
interface Foo<out T> {
consume: (arg: T) => void;
}然而,方差注解可以更严格(例如,如果实际方差是协变的,in out 也是有效的)。 调试完成后,请务必移除你的方差注解。
最后,如果你正在尝试最大化类型检查性能,并且已经运行了性能分析器,并且已经确定了一个特定的类型很慢,并且已经确定方差推断特别慢,并且已经仔细验证了你想编写的方差注解,那么通过添加方差注解,你可能会在极其复杂的类型中看到微小的性能提升。
不要试图使用方差注解来改变类型检查行为;这不是它们的用途