TypeScript 的逆变和协变

匿名用户 分类:javascript

深入学习 TypeScript 类型系统的话,逆变、协变、双向协变、不变是绕不过去的概念。

这些概念看起来挺高大上的,其实并不复杂,这篇文章我们就来学习下它们吧。

类型安全和型变

TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。

比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法。

这是类型检查做的事情,遇到类型安全问题会在编译时报错。

但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变”(类型改变)。

这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变,一种是父类型可以赋值给子类型,叫做逆变。

先来看下协变:

协变

其中协变是很好理解的,比如我们有两个 interface:

interface Person {
    name: string;
    age: number;


interface Guang {
    name: string;
    age: number;
    hobbies: string[]
}

这里 Guang 是 Person 的子类型,更具体,那么 Guang 类型的变量就可以赋值给 Person 类型:
TypeScript 的逆变和协变

这并不会报错,虽然这俩类型不一样,但是依然是类型安全的。

这种子类型可以赋值给父类型的情况就叫做协变。

为什么要支持协变很容易理解:类型系统支持了父子类型,那如果子类型还不能赋值给父类型,还叫父子类型么?

所以型变是实现类型父子关系必须的,它在保证类型安全的基础上,增加了类型系统的灵活性。

逆变相对难理解一些:

逆变

我们有这样两个函数:

let printHobbies: (guang: Guang) => void;

printHobbies = (guang) => {
    console.log(guang.hobbies);
}

let printName: (person: Person) => void;

printName = (person) => {
    console.log(person.name);
}

printHobbies 的参数是 printName 参数的子类型。

那么问题来了,printName 能赋值给 printHobbies 么?printHobbies 能赋值给 printName 么?

测试一下发现是这样的:

TypeScript 的逆变和协变

printName 的参数Person不是 printHobbies 的参数Guang的父类型么,为啥能赋值给子类型?

因为这个函数调用的时候是按照 Guang 来约束的类型,但实际上函数只用到了父类型 Person 的属性和方法,当然不会有问题,依然是类型安全的。

这就是逆变,函数的参数有逆变的性质(而返回值是协变的,也就是子类型可以赋值给父类型)。

那反过来呢,如果 printHoobies 赋值给 printName 会发生什么?

因为函数声明的时候是按照 Person 来约束类型,但是调用的时候是按照 Guang 的类型来访问的属性和方法,那自然类型不安全了,所以就会报错。

但是在 ts2.x 之前支持这种赋值,也就是父类型可以赋值给子类型,子类型可以赋值给父类型,既逆变又协变,叫做“双向协变”。

但是这明显是有问题的,不能保证类型安全,所以之后 ts 加了一个编译选项 strictFunctionTypes,设置为 true 就只支持函数参数的逆变,设置为 false 则是双向协变。

TypeScript 的逆变和协变

我们把 strictFunctionTypes 关掉之后,就会发现两种赋值都可以了:

TypeScript 的逆变和协变

这样就支持函数参数的双向协变,类型检查不会报错,但不能严格保证类型安全。

开启之后,函数参数就只支持逆变,子类型赋值给父类型就会报错:

TypeScript 的逆变和协变

在类型编程中这种逆变性质有什么用呢?

还记得之前联合转交叉的实现么?

type UnionToIntersection<U> = 
    (U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
        ? R
       : never

类型参数 U 是要转换的联合类型。

U extends U 是为了触发联合类型的 distributive 的性质,让每个类型单独传入做计算,最后合并。

利用 U 做为参数构造个函数,通过模式匹配取参数的类型。

结果就是交叉类型:

TypeScript 的逆变和协变

我们通过构造了多个函数类型,然后模式提取参数类型的方式,来实现了联合转交叉,这里就是因为函数参数是逆变的,会返回联合类型的几个类型的子类型,也就是更具体的交叉类型。

逆变和协变都是型变,是针对父子类型而言的,非父子类型自然就不会型变,也就是不变:

不变

非父子类型之间不会发生型变,只要类型不一样就会报错:

TypeScript 的逆变和协变

那类型之间的父子关系是怎么确定的呢,好像也没有看到 extends 的继承?

类型父子关系的判断

像 java 里面的类型都是通过 extends 继承的,如果 A extends B,那 A 就是 B 的子类型。这种叫做名义类型系统(nominal type)。

而 ts 里不看这个,只要结构上是一致的,那么就可以确定父子关系,这种叫做结构类型系统(structual type)。

还是拿上面那个例子来说:

TypeScript 的逆变和协变

Guang 和 Person 有 extends 的关系么?

没有呀。

那是怎么确定父子关系的?

通过结构,更具体的那个是子类型。这里的 Guang 有 Person 的所有属性,并且还多了一些属性,所以 Guang 是 Person 的子类型。

注意,这里用的是更具体,而不是更多。

判断联合类型父子关系的时候, 'a' | 'b' 和 'a' | 'b' | 'c' 哪个更具体?

'a' | 'b' 更具体,所以  'a' | 'b' 是 'a' | 'b' | 'c' 的子类型。

测试下:

TypeScript 的逆变和协变

总结

ts 通过给 js 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变。

型变分为逆变和协变。协变很容易理解,就是子类型赋值给父类型。逆变主要是函数赋值的时候函数参数的性质,参数的父类型可以赋值给子类型,这是因为按照子类型来声明的参数,访问父类型的属性和方法自然没问题,依然是类型安全的。但反过来就不一定了。

不过 ts 2.x 之前反过来依然是可以赋值的,也就是既逆变又协变,叫做双向协变。

为了更严格的保证类型安全,ts 添加了 strictFunctionTypes 的编译选项,开启以后函数参数就只支持逆变,否则支持双向协变。

型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变。

ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型。

理解了如何判断父子类型(结构类型系统),父子类型的型变(逆变、协变、双向协变),很多类型兼容问题就能得到解释了。

作者:神光

TypeScript 的逆变和协变

回复

我来回复
  • 暂无回复内容