17-原理篇:逆变、协变、双向协变、不变
课程
1
如何阅读本小册
已学完
学习时长: 2分6秒
2
为什么说 TypeScript 的火爆是必然?
已学完
学习时长: 4分28秒
3
TypeScript 类型编程为什么被叫做类型体操?
已学完
学习时长: 9分16秒
4
TypeScript 类型系统支持哪些类型和类型运算?
已学完
学习时长: 19分27秒
5
套路一:模式匹配做提取
已学完
学习时长: 41分32秒
6
套路二:重新构造做变换
已学完
学习时长: 39分51秒
7
套路三:递归复用做循环
学习时长: 42分22秒
8
套路四:数组长度做计数
学习时长: 27分58秒
9
套路五:联合分散可简化
学习时长: 19分36秒
10
套路六:特殊特性要记清
学习时长: 36分41秒
11
类型体操顺口溜
学习时长: 27分26秒
12
TypeScript 内置的高级类型有哪些?
学习时长: 31分59秒
13
真实案例说明类型编程的意义
学习时长: 34分36秒
14
类型编程综合实战一
学习时长: 25分55秒
15
类型编程综合实战二
学习时长: 29分9秒
16
新语法 infer extends 是如何简化类型编程的
学习时长: 11分14秒
17
原理篇:逆变、协变、双向协变、不变
学习时长: 11分31秒
18
原理篇:编译 ts 代码用 tsc 还是 babel?
学习时长: 19分49秒
19
原理篇:实现简易 TypeScript 类型检查
学习时长: 47分51秒
20
原理篇:如何阅读 TypeScript 源码
学习时长: 19分27秒
21
原理篇:一些特殊情况的说明
已学完
学习时长: 12分39秒
22
小册总结
学习时长: 2分32秒
23
加餐:3 种类型来源和 3 种模块语法
学习时长: 17分6秒
24
加餐:用 Project Reference 优化 tsc 编译性能
已学完
学习时长: 4分47秒
juejin_logo copyCreated with Sketch.

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

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

类型安全和型变

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

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

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

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

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

先来看下协变:

协变(covariant)

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

interface Person {
    name: string;
    age: number;
} 

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

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

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

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

试一下

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

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

逆变相对难理解一些:

逆变(contravariant)

我们有这样两个函数:

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

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

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

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

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

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

测试一下发现是这样的:

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

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

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

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

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

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

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

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

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

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

试一下(双向协变的情况)

试一下(逆变的情况)

再举个逆变的例子,大家觉得下面这样的 ts 代码会报错么:

type Func = (a: string) => void;

const func: Func = (a: 'hello') => undefined

答案是参数的位置会,返回值的位置不会:

参数的位置是逆变的,也就是被赋值的函数参数要是赋值的函数参数的子类型,而 string 不是 'hello' 的子类型,所以报错了。

返回值的位置是协变的,也就是赋值的函数的返回值是被赋值的函数的返回值的子类型,这里 undefined 是 void 的子类型,所以不报错。

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

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

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

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

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

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

结果就是交叉类型:

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

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

不变(invariant)

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

那类型之间的父子关系是怎么确定的呢,好像也没有看到 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 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变(variant)。

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

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

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

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

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

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

留言
Ctrl + Enter
全部评论(22)
hedgehog_boy的头像
删除
我也懂逆变和协变了,简单理解,型变分为协变和逆变,协变是子类型赋值给父类型,逆变就是父类型赋值给子类型(一般用于函数参数)。
点赞
回复
CountingStar的头像
删除
牛逼[看],终于懂逆变协变了
点赞
回复
用户9785261362001的头像
删除
而 string 不是 'hello' 的子类型? 还是'hello' 不是string 的子类型?
点赞
回复
SPA枸杞泡脚盆的头像
删除
逆变前面说是 "被赋值的函数参数要是赋值的函数参数的子类型",
后面说”因为函数参数是逆变的,会返回联合类型的几个类型的子类型,也就是更具体的交叉类型“

后面这句话啥意思啊。。为什么提到返回类型,这个联合转交差解释逆变不是很懂。。
点赞
2
删除
R是子,这样好像说得通[捂脸]
点赞
回复
删除
因为联合类型有分布式的特点(详见套路5),所以这个`(U extends U ? (x: U) => unknown : never)`块代码会成为一个函数式的联合类型,然后集合类型是联合类型的子类型,根据函数参数的逆变性质,所以`(U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown`这段代码中的R就是联合类型转换后的交叉类型。
点赞
回复
三郎mr的头像
删除
天生的布道师特质
点赞
回复
焦糖色的橙子的头像
删除
害我理解这么久 不愧是你[奸笑]
点赞
回复
草苺奶昔的头像
删除
学生 @ 摸鱼
> 注意,这里用的是更具体,而不是更多
太精妙了
点赞
回复
Asuka14024的头像
删除
前端工程师
天啦噜这节讲的太好了 终于搞明白了
点赞
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
应该把这章顺序跳转下, 父子类型的判断放到 文首
点赞
回复
shadowMike的头像
删除
点赞、打卡
点赞
回复
bubbletg的头像
删除
学习学习,每天一遍,离光越近
点赞
回复
muyiyr的头像
删除
前端
协变是不是鸭子类型的概念?
点赞
1
删除
(作者)
是的
1
回复
蛋闪的头像
删除
前端
[强]
点赞
回复
瓦嘞嘞的头像
删除
看懂了~打个卡
终于明白逆变协变了,光哥[赞]
点赞
回复
小龙6666的头像
删除
前端开发 @ 伙伴密码
看了两遍,总算是理解了[哭笑]
点赞
回复
饿了么黄金骑手的头像
删除
一个前端 @ 海康威视
菜鸟表示都看得懂[呲牙]
点赞
回复
ShiYi的头像
删除
前端开发工程师 @ shopee
这回是真的明白了,神光这么多文章没白写,简单清晰,已经是一个优秀的布道者了。
点赞
1
删除
(作者)
[嘿哈]
1
回复
云剪者的头像
删除
程序员
[赞]
点赞
回复