15.数类型:协变与逆变的比较

10/9/2023

全面梳理类型系统的层级关系:从 Top Type 到 Bottom Type (opens new window) 一节中,我们分析了 TypeScript 类型系统自下而上的层级,比较了原始类型、联合类型、对象类型、内置类型等的层级关系。但是,如果你使用过 TypeScript 很容易就会想到,我们好像漏了一点什么:函数类型有类型层级吗? 如果有,它的类型层级又是怎么样的?比如,下面这几个函数类型之间的兼容性如何?

type FooFunc = () => string;
type BarFunc = () => "literal types";
type BazFunc = (input: string) => number;
1
2
3

没什么头绪对吧?这一节,我们就来对比函数类型的类型层级,以及隐藏在这一比较幕后的理论——协变与逆变。这一篇文章中的概念我曾在此前的掘金文章中分享过,但大部分读者表示内容过于晦涩难懂,因此在这一节中我会换更接地气的方式来讲解。首先,我们通过逐步推导比较函数的类型层级,引出协变与逆变的概念,然后了解在 TypeScript 的内部定义是如何使用协变与逆变的,以及如何通过额外的配置启用这一检查。

本节代码见:Covariance and Contravariance (opens new window)

# 如何比较函数的签名类型?

首先要明确的是,我们不会使用函数类型去和其他类型(如对象类型)比较,因为这并没有意义,本文中只会对两个函数类型进行比较。

来看示例,给出三个具有层级关系的类,分别代表动物、狗、柯基。

class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

class Corgi extends Dog {
  cute() {}
}
1
2
3
4
5
6
7
8
9
10
11

对于一个接受 Dog 类型并返回 Dog 类型的函数,我们可以这样表示:

type DogFactory = (args: Dog) => Dog;
1

在本文中,我们进一步将其简化为:Dog -> Dog 的表达形式。

对于函数类型比较,实际上我们要比较的即是参数类型与返回值类型(也只能是这俩位置的类型)。对于 Animal、Dog、Corgi 这三个类,如果将它们分别可重复地放置在参数类型与返回值类型处(相当于排列组合),就可以得到以下这些函数签名类型:

这里的结果中不包括 Dog -> Dog,因为我们要用它作为基础来比较

  • Animal -> Animal
  • Animal -> Dog
  • Animal -> Corgi
  • Dog -> Dog
  • Dog -> Animal
  • Dog -> Corgi
  • Corgi -> Animal
  • Corgi -> Dog
  • Corgi -> Corgi

直接比较完整的函数类型并不符合我们的思维直觉,因此我们需要引入一个辅助函数:它接收一个 Dog -> Dog 类型的参数:

function transformDogAndBark(dogFactory: DogFactory) {
  const dog = dogFactory(new Dog());
  dog.bark();
}
1
2
3
4

对于函数参数,实际上类似于我们在类型系统层级时讲到的,如果一个值能够被赋值给某个类型的变量,那么可以认为这个值的类型为此变量类型的子类型

如一个简单接受 Dog 类型参数的函数:

function makeDogBark(dog: Dog) {
  dog.bark();
}
1
2
3

它在调用时只可能接受 Dog 类型或 Dog 类型的子类型,而不能接受 Dog 类型的父类型:

// 没问题
makeDogBark(new Corgi());
// 不行
makeDogBark(new Animal());
1
2
3
4

相对严谨地说,这是因为派生类(即子类)会保留基类的属性与方法,因此说其与基类兼容,但基类并不能未卜先知的拥有子类的方法。相对欢脱地说,因为我们要让这只狗汪汪两声,柯基、柴犬、德牧都会,但如果你传个牛进来,这我就很难办了啊。

里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能,子类型(subtype)必须能够替换掉他们的基类型(base type)。

回到这个函数,这个函数会实例化一只狗狗,并传入 Factory(就像宠物美容),然后让它叫唤两声。实际上,这个函数同时约束了此类型的参数与返回值。首先,我只会传入一只正常的狗狗,但它不一定是什么品种。其次,你返回的必须也是一只狗狗,我并不在意它是什么品种。

对于这两条约束依次进行检查:

  • 对于 Animal/Dog/Corgi -> Animal 类型,无论它的参数类型是什么,它的返回值类型都是不满足条件的。因为它返回的不一定是合法的狗狗,即我们说它不是 Dog -> Dog 的子类型。
  • 对于 Corgi -> CorgiCorgi -> Dog,其返回值满足了条件,但是参数类型又不满足了。这两个类型需要接受 Corgi 类型,可能内部需要它腿短的这个特性。但我们可没说一定会传入柯基,如果我们传个德牧,程序可能就崩溃了。
  • 对于 Dog -> CorgiAnimal -> CorgiAnimal -> Dog,首先它们的参数类型正确的满足了约束,能接受一只狗狗。其次,它们的返回值类型也一定会能汪汪汪。

而实际上,如果我们去掉了包含 Dog 类型的例子,会发现只剩下 Animal -> Corgi 了,也即是说,(Animal → Corgi) ≼ (Dog → Dog) 成立(A ≼ B 意为 A 为 B 的子类型)。

观察以上排除方式的结论:

  • 参数类型允许为 Dog 的父类型,不允许为 Dog 的子类型。
  • 返回值类型允许为 Dog 的子类型,不允许为 Dog 的父类型。

你是否 get 到了什么?这里用来比较的两个函数类型,其实就是把具有父子关系的类型放置在参数位置以及返回值位置上,最终函数类型的关系直接取决于类型的父子关系。 “取决于”也就意味着,其中有规律可循。那么这个时候,我们就可以引入协变与逆变的概念了。

# 协变与逆变

我们上一节得到的结论是,考虑 Corgi ≼ Dog ≼ Animal,当有函数类型 Dog -> Dog,仅有 (Animal → Corgi) ≼ (Dog → Dog) 成立(即能被视作此函数的子类型,)。这里的参数类型与返回值类型实际上可以各自独立出来看:

考虑 Corgi ≼ Dog,假设我们对其进行返回值类型的函数签名类型包装,则有 (T → Corgi) ≼ (T → Dog),也即是说,在我需要狗狗的地方,柯基都是可用的。即不考虑参数类型的情况,在包装为函数签名的返回值类型后,其子类型层级关系保持一致。

考虑 Dog ≼ Animal,如果换成参数类型的函数签名类型包装,则有 (Animal -> T) ≼ (Dog -> T),也即是说,在我需要条件满足是动物时,狗狗都是可用的。即不考虑返回值类型的情况,在包装为函数签名的参数类型后,其子类型层级关系发生了逆转。

实际上,这就是 TypeScript 中的协变( covariance 逆变( contravariance 在函数签名类型中的表现形式。这两个单词最初来自于几何学领域中:随着某一个量的变化,随之变化一致的即称为协变,而变化相反的即称为逆变。

用 TypeScript 的思路进行转换,即如果有 A ≼ B ,协变意味着 Wrapper<A> ≼ Wrapper<B>,而逆变意味着 Wrapper<B> ≼ Wrapper<A>

而在这里的示例中,变化(Wrapper)即指从单个类型到函数类型的包装过程,我们可以使用工具类型来实现独立的包装类型(独立指对参数类型与返回值类型):

type AsFuncArgType<T> = (arg: T) => void;
type AsFuncReturnType<T> = (arg: unknown) => T;
1
2

再使用这两个包装类型演示我们上面的例子:

// 1 成立:(T -> Corgi) ≼ (T -> Dog)
type CheckReturnType = AsFuncReturnType<Corgi> extends AsFuncReturnType<Dog>
  ? 1
  : 2;

// 2 不成立:(Dog -> T) ≼ (Animal -> T)
type CheckArgType = AsFuncArgType<Dog> extends AsFuncArgType<Animal> ? 1 : 2;
1
2
3
4
5
6
7

进行一个总结:函数类型的参数类型使用子类型逆变的方式确定是否成立,而返回值类型使用子类型协变的方式确定

学习了函数类型的比较以及协变逆变的知识以后,你已经了解了如何通过“公式”来确定函数类型之间的兼容性关系,但实际上,基于协变逆变地检查并不是始终启用的(毕竟 TypeScript 在严格检查全关与全开的情况下,简直像是两门语言),我们需要通过配置来开启。

# TSConfig 中的 StrictFunctionTypes

如果你曾经翻过 tsconfig 配置,你可能会注意到 strictFunctionTypes (opens new window) 这一项配置,但它在文档中的描述其实相对简略了些:在比较两个函数类型是否兼容时,将对函数参数进行更严格的检查When enabled, this flag causes functions parameters to be checked more correctly),而实际上,这里的更严格指的即是 对函数参数类型启用逆变检查,很自然的我们会产生一些疑惑:

  • 如果启用了这个配置才是逆变检查,那么原来是什么样的?
  • 在实际场景中的逆变检查又是什么样的?

还是以我们的三个类为例,首先是一个函数以及两个函数类型签名:

function fn(dog: Dog) {
  dog.bark();
}

type CorgiFunc = (input: Corgi) => void;
type AnimalFunc = (input: Animal) => void;
1
2
3
4
5
6

我们通过赋值的方式来实现对函数类型的比较:

const func1: CorgiFunc = fn;
const func2: AnimalFunc = fn;
1
2

还记得吗?如果赋值成立,说明 fn 的类型是 CorgiFunc / AnimalFunc 的子类型

这两个赋值实际上等价于:

  • (Dog -> T) ≼ (Corgi -> T)
  • (Dog -> T) ≼ (Animal -> T)

结合上面所学,我们很明显能够发现第二种应当是不成立的。但在禁用了 strictFunctionTypes 的情况下,TypeScript 并不会抛出错误。这是因为,在默认情况下,对函数参数的检查采用 双变( bivariant 即逆变与协变都被认为是可接受的

在 TypeScript ESLint 中,有这么一条规则:method-signature-style (opens new window),它的意图是约束在接口中声明方法时,需要使用 property 而非 method 形式:

// method 声明
interface T1 {
  func(arg: string): number;
}

// property 声明
interface T2 {
  func: (arg: string) => number;
}
1
2
3
4
5
6
7
8
9

进行如此约束的原因即,对于 property 声明,才能在开启严格函数类型检查的情况下享受到基于逆变的参数类型检查

对于 method 声明(以及构造函数声明),其无法享受到这一更严格的检查的原因则是对于如 Array 这样的内置定义,我们希望它的函数方法就是以协变的方式进行检查,举个栗子,Dog[] ≼ Animal[] 是否成立?

  • 我们并不能简单的比较 Dog 与 Animal,而是要将它们视为两个完整的类型比较,即 Dog[] 的每一个成员(属性、方法)是否都能对应的赋值给 Animal[]
  • Dog[].push ≼ Animal[].push 是否成立?
  • 由 push 方法的类型签名进一步推导,Dog -> void ≼ Animal -> void 是否成立?
  • Dog -> void ≼ Animal -> void在逆变的情况下意味着 Animal ≼ Dog,而这很明显是不对的!
  • 简单来说, Dog -> void ≼ Animal -> void 是否成立本身就为 Dog[] ≼ Animal[] 提供了一个前提答案。

因此,如果 TypeScript 在此时仍然强制使用参数逆变的规则进行检查,那么 Dog[] ≼ Animal[] 就无法成立,也就意味着无法将 Dog 赋值给 Animal,这不就前后矛盾了吗?所以在大部分情况下,我们确实希望方法参数类型的检查可以是双变的,这也是为什么它们的声明中类型结构使用 method 方式来声明:

interface Array<T> {
    push(...items: T[]): number;
}
1
2
3

# 总结与预告

在这一节,我们学习了 TypeScript 函数类型的兼容性比较,这应该带给了你一些新的启发:原来不只是原始类型、联合类型、对象类型等可以比较,函数类型之间同样是能够比较的。而对我们开头提出的,如何对两个函数类型进行兼容性比较这一问题,我想你也有了答案:比较它们的参数类型是否是反向的父子类型关系,返回值是否是正向的父子类型关系。

如果用本章学到的新知识来说,其实就是判断参数类型是否遵循类型逆变,返回值类型是否遵循类型协变。我们可以通过 TypeScript ESLint 的规则以及 strictFunctionTypes 配置,来为 interface 内的函数声明启用严格的检查模式。如果你的项目内已经配置了 TypeScript ESLint,不妨添加上 method-signature-style (opens new window) 这条规则来让你的代码质量更上一层楼。

除了对自定义函数类型地比较,我们也了解了对于部分 TypeScript 内置的方法,会通过显式的 method 声明方式来确保在调用时,对参数类型检查采用双变而非逆变。

到这里,你已经了解了 TypeScript 类型系统中绝大部分的知识,我想在未来你再遇到奇怪的类型报错时,应该再也不会憋着气打开 StackOverflow 搜索,而是微微一笑胸有成竹地轻松解决,所凭借的自然就是我们对类型系统的深刻掌握。

类型工具、类型系统、类型编程这三辆马车我们已经解决了俩,在下一节,我们就将开始进入类型编程的世界里,此前我们所学的所有类型工具与类型系统知识将轮番上阵接受考验,而你也将从看见复杂工具类型就头疼,变成看见类型就两眼放光的 TypeScript 高高手。

# 扩展阅读

# 联合类型与兄弟类型下的比较

在上面我们只关注了显式的父子类型关系,实际上在类型层级中还有隐式的父子类型关系(联合类型)以及兄弟类型(同一基类的两个派生类)。对于隐式的父子类型其可以仍然沿用显式的父子类型协变与逆变判断,但对于兄弟类型,比如 Dog 与 Cat,需要注意的是它们根本就不满足逆变与协变的发生条件(父子类型),因此 (Cat -> void) ≼ (Dog -> void) (或者反过来)无论在严格检查与默认情况下均不成立。

# 非函数签名包装类型的变换

我们在最开始一直以函数体作为包装类型来作为协变与逆变的转变前提,后面虽然提到了使用数组的作为包装类型(Dog[])的,但只是一笔带过,重点还是在函数体方面。现在,如果我们就是就是要考虑类似数组这种包装类型呢?比如直接一个简单的笼子 Cage ?

先不考虑 Cage 内部的实现,只知道它同时只能放一个物种的动物,Cage<Dog> 能被作为 Cage<Animal> 的子类型吗?对于这一类型的比较,我们可以直接用实际场景来代入:

  • 假设我需要一笼动物,但并不会对它们进行除了读以外的操作,那么你给我一笼狗我也是没问题的,但你不能给我一笼植物。也就意味着,此时 List 是 readonly 的,而 Cage<Dog> ≼ Cage<Animal> 成立。即在不可变的 Wrapper 中,我们允许其遵循协变。
  • 假设我需要一笼动物,并且会在其中新增其他物种,比如兔子啊王八,这个时候你给我一笼兔子就不行了,因为这个笼子只能放狗,放兔子进行可能会变异(?)。也就意味着,此时 List 是 writable 的,而 Cage<Dog> Cage<Rabit> Cage<Turtle> 彼此之间是互斥的,我们称为 不变(*invariant*),用来放狗的笼子绝不能用来放兔子,即无法进行分配。
  • 如果我们再修改下规则,现在一个笼子可以放任意物种的动物,狗和兔子可以放一个笼子里,这个时候任意的笼子都可以放任意的物种,放狗的可以放兔子,放兔子的也可以放狗,即可以互相分配,我们称之为双变(*Bivariant*)

也就是说,包装类型的表现与我们实际需要的效果是紧密关联的。

Last Updated: 10/9/2023, 5:43:25 PM