Typescript学习(十二)协变和逆变

191 阅读5分钟

什么是协变?什么是逆变?

前面我们学习了类型层级, 下至never, 上至any/unknown, 中间夹杂了字面量类型、联合类型、原始类型、包装类型等等; 我们也从结构化类型/类型信息多少的角度, 来区分了许多类型的上下级关系, 但是, 我们好像遗漏了一种很重要的类型, 即 函数! 试想, 我们是否可以像联合类型那样, 找到两个函数之间的层级关系? 或者说, 两个函数是否有可能存在层级关系呢?

我们知道, 一个函数的类型签名大体就是这样:

type Fn = (args: ParmasType) => ReturnType

可以看出, 造成函数之间类型存在差异的点, 就在参数类型和返回值类型上, 那么, 是不是说A函数的参数和返回值分别为B函数参数和返回值的子类型, 就说明A是B的子类型? 凡事不能猜, 得实践, 我们来看看下面的案例:

// 动物
class Animal {
  constructor() {}
  eat() {
    console.log('I can eat!')
  }
}

// 鸟类
class Bird extends Animal {
  constructor() {super()}
  fly () {
    console.log('I can fly')
  }
}

// 鹦鹉
class Parrot extends Bird {
  constructor() {super()}
  say () {
    console.log('I can say!!!')
  }
}
// 作为参数的函数类型
type BirdShop = (bird: Bird) => Bird

// 传入的ParrotShop, 必须保证ParrotShop方法运行和birdFunc运行都不出错
function playWithBird (functionParams: BirdShop) {
  const bird = functionParams(new Bird())
  bird.fly()
}

这里, 我们先定义了三个类型: Animal、Bird、Parrot, 即 动物、鸟类、鹦鹉; 紧接着, 我们定义了一个playWithBird的方法, 接受一个函数其类型为 (bird: Bird) => Bird; 据我们之前的学习, 一个值A如果能赋值给B, 那就说明A是B的子类型! 同理, 如果我们定义一个函数(即定义好入參类型和返回值类型), 它能够作为playWithBird的参数, 那就说明这个函数的类型是(bird: Bird) => Bird的子类型 ! 好, 我们来理解下,什么叫'能够作为playWithBird的参数'? 我传给你你就用, 哪来那么多问题? 其实这里隐藏了一个条件: 用了你传入的参数, 执行阶段不能出错! 即 playWithBird内的逻辑不能出错, 同时functionParams本身也不能出错! 这个要求很合理, 很符合逻辑, 那如何声明这个函数的类型呢? 还是从入參和返回值两个角度入手:

  • 先来入參, 在playWithBird中, functionParams固定接受了一个Bird的实例作为参数, 那么它内部, 很可能会使用这个实例内的属性/方法! 也就是说, 想要保证它正常运行, 这个函数的入參所需的信息必须少于或等于Bird所包含的信息 , 说白了, 就是给你的参数信息固定的情况下, 我要得少了, 就显得你给的多了! 那就能够保证functionParams正常运行了! 少于或等于Bird所包含的信息, 就是入參类型可以是Bird的父类型或者Bird类型本身! 这样就能保证传入的functionParams运行正常!
  • 再来看看返回值, 在playWithBird函数体内执行了bird.fly(), 这说明什么, 这说明birdShop的返回值至少要有这个fly方法! 也就是birdShop的返回值所包含的信息, 必须多余或等于Bird, 这样playWithBird函数, 也能运行正常了;

我们来试着定义:

// 如果参数是Bird的子类型, 返回值Bird
type ParrotParams = (parrot: Parrot) => Bird
let functionParams: ParrotParams = (parrot) => {
  parrot.say()
  return parrot
}

playWithBird(functionParams) // 报错, 类型ParrotParams的参数不能赋值给类型BirdShop的参数!

其实这里也好理解, 因为functionParams执行的时候, 外部只会传入new Bird()这个固定参数, 但是functionParams内部, 调用了say方法, 鹦鹉会说话,但是不代表所有的鸟都会说话! 具体到本案例就是Bird压根没有say这个方法, 肯定报错! 所以这个函数不行, 其入參要求不能是Bird的子类型, 并依次保证其内部只调用Bird有的属性、方法!

那我们继续修改:

// 此时参数为Animal, 返回值是也是Animal, 会怎样?
type AnimalReturn = (animal: Animal) => Animal
let functionParams: AnimalReturn = (animal) => {
  animal.eat()
  return animal
}
playWithBird(functionParams) //缺少fly方法!

这个很明显了, 因为Animal作为返回值, 它内部根本没有fly方法! 不是所有动物都会飞! 因此, 又错了!

我们接着改:

// 参数是Bird的父类型Animal, 返回值为Bird的子类型Parrot
type FinalType = (animal: Animal) => Parrot
let functionParams: FinalType = (animal) => {
  animal.eat()
  return new Parrot()
}

playWithBird(functionParams)

以上代码完全正确! 返回值Parrot具有fly的方法, 所以执行正确;

由此我们可以总结出一个规律, 想要成为(bird: Bird) => Bird类型函数的子类型, 其参数必须是Bird的父类, 返回值必须是Bird的子类! 即 (animal: Animal) => Parrot; 所以(bird: Bird) => Bird >= (animal: Animal) => Parrot; 但是参数部分, Bird < Animal; 而返回值部分Bird > Parrot; 也就是在层级关系上 参数部分和总体部分相反, 而返回值部分相同! 我们把这种相反称之为逆变; 而把这种相同称之为协变;

逆变的配置

在我们的tsconfig.json中, 存在一个配置项strictFunctionTypes, 它的作用是是否开启逆变校验, 也就是如果开启, 则会进行逆变的校验, 如果关闭, 则不会校验逆变!

type BirdShop = (bird: Bird) => Bird
let func1:BirdShop = (bird: Bird) => {
  return new Parrot()
}

let func2:BirdShop = (parrot: Parrot) => {
  return new Parrot()
}

以上案例中, func2明显是不可被接受的, 因为它的参数是Bird的子类型, 即不符合逆变的要求, 但是如果我们将strictFunctionTypes设为false, 则不会报错;

property和method

在eslint中有一种method-signature-style的校验规则, 这条规则要求在接口声明中使用property而非method!

从图中我们可以看出, property和method很相似:

interface Example {
  // method shorthand syntax
  func(arg: string): number; // method形式

  // regular property with function type
  func: (arg: string) => number; // property形式
}

但是两者在开启strictFunctionTypes时, 其逆变的表现不同, property会强制校验逆变情况; 而method则不会! 为何要设计这种能够绕过逆变的规则呢? 其实很多时候, 逆变也会造成一定的问题;

试想, Bird[]和Animal[]那个是哪个的子类型? 是否应该是Bird[]<Animal[]?但是我们知道, 一个object类型要是另一个object类型的子类型, 那么其每个成员势必能够赋值给另一个object的成员! Bird[]的属性都是Bird, 自然能够赋值给Animal,那么其push方法不就是Bird->void < Animal -> void; 但是根据逆变的原则, Bird > Animal! 这显然不成立, 所以这也是为什么内置方法的定义都是使用method形式