TypeScript 之 协变与逆变

884 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

前言

协变与逆变(covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语

父子类型在编程理论上是一个复杂的话题,而它的复杂之处来自于一对经常会被混淆的现象,我们称之为协变逆变

那么首先我们就要先来聊一下什么是父子关系

什么是父子关系

用我们最常见的类之间的关系来说,一个类继承于另一个类,那么它们之间就是父子关系,一个是父类,一个是它的子类。

class Animal {}
class Human extends Animal{}

let animal = new Animal()
let human = new Human()

类似于这样子,我们就能够说 Human 类 是 Animal 类的子类,Human 类的实例会继承 Animal 类的属性以及方法。

当然,我们要知道 TypeScript 是一门结构化类型系统,也就是对于类型的检测使用的是鸭子类型,及

如果它看起来像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那它很可能就是鸭子。

也就是说,两个对象之间父子关系的确立,并不是一定需要 extends 关键字,这只是其中的一种手段,只要子类中含有父类的一切属性和方法,在结构化类型系统中,就会将它们两个当做父子关系,比如说:

type Human = {
  "name": string,
  "age": number
}
type Animal = {
  "name": string,
} 

let human: Human = {
  "name": "动物",
  "age": 1
}
let animal: Animal = {
  "name": "动物"
} 

我们直接定义出来的两个对象,只要满足子类中含有父类的全部属性以及方法,那么它们之间就是父子关系。

怎么确定父子关系

确定两个对象之间的父子关系的方法有两种,一种相互赋值,一种是使用替换

1.通过子类型是否能够赋值给父类型来进行判断

子类型能够赋值给父类型,但是父类型却不能赋值给子类型,在 TypeScript 中会报错

在上面讲解什么是父子关系中,我们定义了两个类型,一个是 Animal 类型,一个是 Human 类型,我们现在可能拿来做一个演示:

type Human = {
  "name": string,
  "age": number
}
type Animal = {
  "name": string,
} 

let human: Human = {
  "name": "动物",
  "age": 1
}
let animal: Animal = {
  "name": "动物"
}

animal = human 
// ok
human = animal
// error: Property '"age"' is missing in type 'Animal' but required in type 'Human'.

这个也很好理解,子类型中含有比父类型更多的属性,所以子类型赋值给父类型能够保证父类型需要的属性子类型全都含有,但是父类型赋值给子类型就无法保证含有子类型特有的属性,所以为了保证类型安全,这里会给出报错,报错的内容也正如我们所说,缺少 'age' 这个属性。

2. 通过使用替换来进行判断

最简单的使用替换的场景就是函数,一个函数,如果能够使用父类型进行入参执行,那么将入参换为子类型执行也一定不会报错。

type Human = {
  "name": string,
  "age": number
}
type Animal = {
  "name": string,
} 

let human: Human = {
  "name": "动物",
  "age": 1
}
let animal: Animal = {
  "name": "动物"
}

function logName(a:Animal):void{
  console.log(a.name)
}
logName(animal) // ok
logName(human) // ok

这个应该也是很好理解的,因为子类中含有父类的全部属性,所以在传入用一个函数的时候,父类中在函数里会使用到的属性,子类中也全都拥有,所以不会报错能够正常运行,这样就可以用来判断父子关系。

什么是协变与逆变

什么是类型转化

上面我们讲完了什么是父子关系,并且在 TypeScript 中,最常见的一种操作就是类型变换,比方说官方提供的一些工具方法,我们能够把一个类型转化为另一个类型:

type Human = {
  "name": string,
  "age": number
}
type Human2 = Pick<Human,'age'>
// type Human2 = {
//     age: number;
// }

这种操作就成为类型转换,那么在知道了什么是类型转换以后,就可以说说什么是协变,什么是逆变。

协变与逆变

协变:在经过相同的类型转换之后,两者的父子关系不变。

逆变:在经过相同的类型转换之后,两者的父子关系互换。

协变

关于协变,举一个简单的例子:

type Human = {
  "name": string,
  "age": number
}

type Animal = {
  "name": string,
} 

type ToArr<T> = [T]

type Human2 = ToArr<Human>

type Animal2 = ToArr<Animal>

这里的 ToArr 泛型就是一个简单的类型转换,将原本的类型用数组做一个包装,转化为元组类型。

并且转化过后,Human2 与 Animal2 它们之间的父子关系并未发生改变。

这点我们可以用上面说的函数入参的方法来进行检验,TypeScript 能够通过编译就说明它们之间的父子关系并未发生改变。

type Human2 = ToArr<Human>
let human2:Human2 = [{
  "name": '动物',
  "age": 1
}]
type Animal2 = ToArr<Animal>
let animal2:Animal2 = [{
  "name": '动物',
}]

function logName(a: Animal2){
  console.log(a)
}

logName(animal2) // ok
logName(human2) // ok

然后经过类型转换以后,父子关系没有发生改变,这就是发生了协变。

这时候我们就可以称数组值的位置为协变位

当然协变位不止这一个,这只是一个例子。

逆变

关于逆变,我们也来举一个例子:

type ToFn<T> = (arg:T) => void

type Human = {
  "name": string,
  "age": number
}

type Animal = {
  "name": string,
} 

type Human3 = ToFn<Human>
type Animal3 = ToFn<Animal>

我们按照刚才上面的方法将两个类型做出同样的类型转换,然后再来测试他们之间的父子关系。

function logName2(a: Animal3){
  console.log(a)
}

logName2(animal3) 
// ok
logName2(human3) 
// error:  Property '"age"' is missing in type 'Animal' but required in type 'Human'.

这里可以看到 TypeScript 给出了编译错误,但是反过来的话确是可以通过编译的:

function logName2(a: Human3){
  console.log(a)
}

logName2(animal3) // ok
logName2(human3) // ok

这里就说明两个类型之间的关系发生了互换,这就是一种逆变,那么为什么会发生逆变呢,我们来看一下类型转换。

type ToFn<T> = (arg:T) => void

将原本的两个类型变为一个函数的入参,对于对象来说,子类型的属性一定是会比父类型来的更多的,所以将它转为函数的入参的时候,也就意味了在这个函数当中能够调用更多的对象属性。

type Human = {
  "name": string,
  "age": number
}

let human:Human = {
   "name": '动物',
  "age": 1
}

type Human3= (arg:Human) => void

let human3:Human3 = (human: Human)=>{
  console.log(human.name)
  console.log(human.age)
}

那么这时候问题就来了,在 Animal 转为 方法以后,能够调用的方法就会变得比 Human 要少,那么,一个 Human3 类型的方法的入参对象中,一定会含有比 Animal 3 类型方法的入参的属性更多。所以它们两个之间的父子关系就发生了改变,一个 Animal 3 类型的方法能够赋值给 Human3 类型,但是 Human3 类型 却不能够赋值给 Animal 3 类型:

这样就能够说这次类型变化中,发生了逆变。

这时候我们就可以称函数入参的位置为逆变位

总结

本文主要是简单的介绍了一下什么是父子关系以及怎么去判断父子关系,要先知道父子关系是什么才能够去理解什么是协变以及逆变,这是了解协变和逆变的一个前提所在,还有比较重要的点就是关于 TypeScript 的 结构化类型系统使用的是鸭子类型,这和 标称类型系统 是完全不同的,具体可以查阅相关文章了解。