快速理解 TypeScript 的逆变和协变

开发 前端
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 类型:

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

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

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

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

逆变相对难理解一些:

逆变

我们有这样两个函数:

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 么?

测试一下发现是这样的:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

结果就是交叉类型:

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

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

不变

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

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

类型父子关系的判断

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

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

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

Guang 和 Person 有 extends 的关系么?

没有呀。

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

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

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

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

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

测试下:

总结

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

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

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

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

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

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

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

责任编辑:姜华 来源: 神光的编程秘籍
相关推荐

2020-09-29 06:37:30

Java泛型

2009-08-03 18:24:28

C# 4.0协变和逆变

2020-08-03 08:13:51

Vue3TypeScript

2009-05-27 11:30:20

C#Visual Stud协变

2012-03-13 09:32:15

C#协变

2011-01-14 10:27:18

C#.netasp.net

2013-10-31 09:36:43

程序员程序高手

2010-01-20 09:17:46

2010-08-17 11:18:19

BISAP商业智能

2015-02-06 17:00:04

2018-04-27 16:45:41

华为

2018-08-01 15:48:05

搜狗

2023-01-29 09:15:42

2020-12-25 16:52:28

CIO数字化转型计世

2011-11-17 17:22:10

IT管理云计算

2015-09-11 10:45:55

服务器华为

2011-11-18 10:00:05

云计算IT管理

2013-10-22 15:31:52

CIO
点赞
收藏

51CTO技术栈公众号