likes
comments
collection
share

一次关于TS逆变与协变的尝试

作者站长头像
站长
· 阅读数 49

不知道各位平时在写TS的过程中有没有看到或听到过有关于【逆变】、【协变】这样的名词?反正我每每听到都是一头雾水,如果你也跟我一样,那实在是泰裤辣! 那就通过这篇文章来简单认识一下吧!

关于什么是逆变,什么是协变维基百科给出的定义是:

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

什么是型别?上网查一下,竟然就是类型的意思。那么这句话虽然目前还看不懂,但起码获取到了一个有用的信息,那就是逆变协变都与父子类型有关。

父子类型

我们先从父子类型说起。

思考以下接口

interface Person {
  name: string
}

interface Teacher extends Person {
  teach(): void
}

我们定义了一个接口 Person,并让另一个接口 Teacher 继承了 Person,显而易见的,TeacherPerson 的所有属性,另外还有自己的属性 teach,那么,我们就认为 PersonTeacher 的父类型。

在类型系统中,子类型总是比父类型的属性更多,且更加具体;在集合论中,属性少的是子集,属性多的称为超集,因此也可以说,子类型是父类型的超集。

再思考以下类型

type A = 'a' | 'b' | 'c'

type B = 'a' | 'b'

我们定义了两个字面量类型的联合类型,根据上面的结论,我们可能很容易把 A 误认为是 B 的子类型,其实不然,A 比 B 更宽泛,B 比 A 更具体,B 其实是 A 的子类型。

我们从【可赋值性】这一角度来简单解释一下。

思考如下代码

let person: Person
let teacher: Teacher

teacher = person
person = teacher

我们把 person 赋值给 teacher,即将一个父类型的变量赋值给一个子类型的变量,这是会报错的。ts 会告诉我们,变量 person 上缺少属性 “teach”。 但假如我们把 teacher 赋值给 person 就完全没有问题,因为 person 只要满足有 name 这一属性即可。

同样的,在联合类型中

let parent: A
let son: B

parent = son // 正确
son = parent // 错误,son可能会得到不期望的字面量 'c'

这就解释了为什么说 A 是 B 的父类型了。

如果是函数呢?

const func = (param: { a: number, b: number }) => {}

const p1 = { a: 1 }
const p2 = { a: 1, b: 2, c: 3 }

func(p1)
func(p2)

我们的 func(p1) 是会报错的,因为参数缺少属性 b,在 func 函数中可能会使用 b 这个属性。 但 func(p2) 不会报错,因为我们的传参已经完全满足了 func 函数需要的参数,至于多出来的 c 已经不重要了。

逆变与协变

先说【协变】

思考如下代码

let persons: Person[]
let teachers: Teacher[]

teachers = persons // 报错,persons 中缺少 teacher 所需要的 teach
persons = teachers // 正常

发现了吗,即使我们把他们的类型从 “对象” 拓展到了 “数组”,他们的父子关系仍然没有发生变化。

这就是【协变】。

回到维基百科给出的定义中所描述的 “型别构造器”,对于上述例子这一【协变】来说,他的型别构造器就是 type MakeArr<T> = T[]

一切都说通了,我们将 PersonTeacher 分别带入泛型中,他们最后构造出的类型数组,其父子关系没有发生变化。

再说【逆变】

有如下代码

const func1 = (person: Person) => {
    console.log(person.name)
}
const func2 = (teacher: Teacher) => {
    console.log(teacher.name)
    teacher.teach()
}

既然 Person 是 Teacher 的父类型,那么是不是可以让 func1 = func2 呢?

答案是不行。想想看,我们在 func2 里还调用了传入参数的 teach 属性,如果我们把 func2 赋值给了 func1,那么后续使用的时候,传入的参数类型为 Person,再调用 teach 就会报错了! 那么 func2 = func1 可以吗?当然可以,因为 func2 的参数完全能够满足 func1 的需要。

发现了吗,父子类型的关系逆转了,这就是【逆变】。

因此,我们构造一个这样的“型别构造器”:type MakeFunc<T> = (arg: T) => void,对类型 PersonTeacher 调用此构造器,就将这两个类型逆变了。

总结

逆变与协变,其实描述的就是父子关系的变化。通过不同的类型构造器所生成的新的类型与原始类型关系的比较,仍然保持原来的关系就称之为协变,反之称其为逆变。

需要注意的是,在 TypeScript 中,由于灵活性等权衡,对于函数参数默认的处理是双向协变的,即 func1 = func2 是可以成立的。在开启了 tsconfig 中的 strictFunctionType 后才会严格按照 逆变 来约束赋值关系。

转载自:https://juejin.cn/post/7247027696822419515
评论
请登录