Typescript中的协变和逆变

痛定思痛。 2022-09-10 09:25 343阅读 0赞

最近用TS时碰到协变和逆变的一些概念,发现有篇外国人写的文章比较容易理解的,这里记录下。

1. 协变和逆变简单理解

先简单说下协变和逆变的理解。

首先,无论协变还是逆变,必然是存在于有继承关系的类当中,这个应该好理解吧。如果你只有一个类,那没有什么好变的。
其次,无论协变还是逆变,既然是变,那必然是存在不同类之间的对象的赋值,比如子类对象赋值给父类对象,父类对象赋值给子类对象,这样才叫做变。
结合上面两条,我觉得协变和逆变在我的字典中就能定义成:支持子类对象赋值给父类对象的情况称之为协变;反之,支持父类对象赋值给子类对象的情况称之为逆变。

举个栗子,我们先假定我们有这么几个类

  1. class Animal {
  2. }
  3. class Dog extends Animal {
  4. }
  5. class Greyhound extends Dog {
  6. }

那么按照上面的理解,要整出一个示例的话,首先我们这里类的继承关系这个条件有了,其次我们要整出的就是这几个类赋值的情况,那么用实参和形参的方式来demo应该是很不错的选择。

2. 协变举例

那么协变的情况我们可以用代码表示为

  1. class Animal {
  2. }
  3. class Dog extends Animal {
  4. bark(): void {
  5. console.log("Bark")
  6. }
  7. }
  8. class Greyhound extends Dog {
  9. }
  10. function makeDogBark(dog:Dog) : void {
  11. dog.bark()
  12. }
  13. let dog: Dog = new Dog();
  14. let greyhound: Greyhound = new Greyhound();
  15. let animal: Animal = new Animal();
  16. makeDogBark(greyhound) // OK。 子类赋值给父类
  17. makeDogBark(animal) // Error。编译器会报错,父类不能赋值给子类

我们如果有面向对象基础的话,相信对上面这段代码不难理解, 子类赋值给父类,即协变的情况,在面向对象编程中是非常常见的,且这是实现语言多态特性的基础。而多态,却又是实现众多设计模式的基础。

3. 逆变举例

当我们将函数作为参数进行传递时,就需要注意逆变的情况。比如下面的makeAnimalAction这个函数,就尝试错误的让一只猫去做出狗吠的动作。

  1. class Animal {
  2. doAnimalThing(): void {
  3. console.log("I am a Animal!")
  4. }
  5. }
  6. class Dog extends Animal {
  7. doDogThing(): void {
  8. console.log("I am a Dog!")
  9. }
  10. }
  11. class Cat extends Animal {
  12. doCatThing(): void {
  13. console.log("I am a Cat!")
  14. }
  15. }
  16. function makeAnimalAction(animalAction: (animal: Animal) => void) : void {
  17. let cat: Cat = new Cat()
  18. animalAction(cat)
  19. }
  20. function dogAction(dog: Dog) {
  21. dog.doDogThing()
  22. }
  23. makeAnimalAction(dogAction) // TS Error at compilation, since we are trying to use `doDogThing()` to a `Cat`

这里作为实参的dogAction函数接受一个Dog类型的参数,而makeAnimalAction的形参animalAction接受一个Dog的父类Animal类型的参数,返回值都是void,那么按照正常的思路,这时应该可以像上面协变的例子一样进行正常的赋值的。

但事实上编译是不能通过的,因为最终makeAnimalAction中的代码会尝试以cat为参数去调用dogAction,然后让一个cat去执行doDogThing。

所以这里我们把函数作为参数传递时,如果该函数里面的参数牵涉到有继承关系的类,就要特别注意下逆变情况的发生。

不过有vscode等代码编辑工具的错误提示支持的话,应该也很容易排除这种错误。

4. 更简单点的理解

我觉得将上面的例子稍微改动下,将makeAnimalAction的形参的类型抽出来定义成一个type,应该会有助于我们理解上面的代码。

  1. class Animal {
  2. doAnimalThing(): void {
  3. console.log("I am a Animal!")
  4. }
  5. }
  6. class Dog extends Animal {
  7. doDogThing(): void {
  8. console.log("I am a Dog!")
  9. }
  10. }
  11. class Cat extends Animal {
  12. doCatThing(): void {
  13. console.log("I am a Cat!")
  14. }
  15. }
  16. function makeAnimalAction(animalAction: AnimalAction) : void {
  17. let cat: Cat = new Cat()
  18. animalAction(cat)
  19. }
  20. type AnimalAction = (animal: Animal) => void
  21. type DogAction = (dog: Dog) => void
  22. let dogAction: DogAction = (dog: Dog) => {
  23. dog.doDogThing()
  24. }
  25. const animalAction: AnimalAction = dogAction // Error: 和上面一样的逆变导致的错误
  26. makeAnimalAction(animalAction)
  • animalAction(animal: Animal)函数,我们可以将其理解成一个可以让动物做动物都有的动作的函数。因此我们可以传dog、cat或者animal进去作为参数,因为它们都是动物,然后animalAction内部可以调用animal.doAnimalThing方法,但不能调用doCatThing或者doDogThing这些方法,因为这些不是所有动物共有的方法。
  • dogAction(dog: Dog)函数, 同上,我们可以将其理解成一个可以让狗狗做狗狗都有的动作的函数。因此可传dog,greyHound这些狗狗对象作为参数,因为对他们都是狗狗,然后dogAction内部可以调用dog.doDogThing和dog.doAnimalThing, 因为这些都是狗狗共有的动作。但是不能调用dog.doGrenHoundThing,因为这不是狗狗共有的动作,只有狗狗的子类灰狗用欧这样的函数。
    以上两个都是协变的情况。下面我们看下逆变所导致的错误那一行。

animalAction = dogAction,如果有C/C++经验的,就可以理解成一个函数指,指向另外一个函数,否则理解成一个函数复制给另外一个函数也可以。

假如这个语句可以执行,那么执行之前,dogAction(dog: Dog)只能接受Dog和GreyHound类型的对象,然后去做狗狗都有的动作。

执行之后,因为现在animalAction指向了dogAction,但是animalAction自身的参数是(animal: Animal),即可以接受所有动物类型的对象。

所以最终这里animalAction就变成了这幅模样(隐隐约约觉得这是理解的关键):

  1. function animalAction(animal: Animal) {
  2. animal.doDogThing()
  3. }

这很明显就是不合理的嘛!所有狗狗都是动物,但这里反过来就不行,不是所有动物都能做狗狗能做的事情,比如这里传个Cat对象进来,那岂不就是让猫去做狗狗的事情了吗。

而反过来,这里假如我们先定义了animalAction, 然后我们让dogAction = animalAction,这种做法却是可行的。我们看最终dogAction变成

  1. function dogAction(dog: Dog) {
  2. dog.doAnimalThing()
  3. }

即dogAction(dog:Dog)指向了animalAction(animal: Animal), 也就是一个以父类型的对象为参数的函数赋予给了一个以子类型的对象为参数的函数,这和我们协变时候的对象之间的赋值时,只能子对象赋值给父对象的做法是相反的。我想,这应该也是为什么叫做逆变的原因吧。

本来这里在我头脑过的时候感觉应该很容易说清楚的,没有想到写下来的时候还是得写这么一大堆,希望能有帮助吧。

5. 参考

https://dev.to/codeozz/how-i-understand-covariance-contravariance-in-typescript-2766

我是@天地会珠海分舵,「三日清单」 和「好学街」开发者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!

发表评论

表情:
评论列表 (有 0 条评论,343人围观)

还没有评论,来说两句吧...

相关阅读

    相关 JAVA

    协变逆变的概念 可变性是以一种类型安全的方式,将一个对象当做另一个对象来使用。如果不能将一个类型替换成为另一个类型,那么这个类型就称之为:不变量。 协变:如果某个返回的

    相关 Typescript

    最近用TS时碰到协变和逆变的一些概念,发现有篇外国人写的文章比较容易理解的,这里记录下。 1. 协变和逆变简单理解 先简单说下协变和逆变的理解。 首先,无论协变还是逆

    相关 之疑问

    前言 关于协变和逆变已经有很多园友谈论过了,学习时也参考过园友们的文章,非常之到位!这个问题可能对您而言很简单,若有解释,请告知,在此感谢。高手绕道! 既然是标题是协变