协变与逆变

Bigno
大约 15 分钟

🔗 协变与逆变:描述具有父 / 子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父 / 子型别关系的用语。

协变(covariant):保持了子类型序关系,该序关系是:子类型 ≤ 基类型;
逆变(contravariant):逆转了子类型序关系。

维基百科

什么是协变与逆变

上面说的啥说实话我没有看懂,看看别人咋解释的:

变型(variance)是针对父子类型来说的,说人话就是:

  • 协变(covariant):子类型的实例,能够赋值给父类型的变量;子类型可以默认转换为父类型(换句话说,父类型约束可以兼容子类型);

  • 逆变(contravariant):协变的相反方向,一般是协变不被允许的(除了函数的参数)(换句话说,子类型约束可以兼容父类型)。

其中需要说明的是:

  • 子类型包含所有父类型的属性,且包含额外更多的属性

  • 子类型的实例可以赋值给父类型的实例

绝大多数的语言是允许协变的,也就是子类型默认可以转成父类型,即子类型实例可以赋值给父类型实例。

举个 🌰

单个变量的父子类型我们好分辨,也好理解,但是函数类型的子类型就不太好理解了,我们看个例子,来找找函数的子类型。

一些标识:

  • A ≥ B:表示 AB 的父类型(Supertyping),BA 的子类型(Subtyping);

  • A → B:表示「入参类型为 A,返回值类型为 B」的函数类型

现约定如下类型:

  • 定义三个类型 AnimalDogGreyhound(灰狗);

  • 类型关系:AnimalDogGreyhound,即:AnimalDog 的父类型,DogGreyhound 的父类型

TypeScript
interface Animal {
  name: string;
}

interface Dog extends Animal {
  bark(): void;
}
interface Greyhound extends Dog {
  runFast(): void;
}

假设有一个函数 F()

接着,我们假设一个函数,我们设为函数 doSomethingInside

TypeScript
type F = (dog: Dog) => Dog;

function doSomethingInside(func: F) {
  // 声明了一个 dog 实例
  const dogInstance: Dog = {
    name: 'dog',
    bark: () => console.log('wang')
  };
  
  // 对入参的函数进行调用
  // 将 dogInstance 传进去,拿到一个 dog 类型的返回值
  const somethingLikeDog = func(dogInstance)
  
  // 使用返回结果中的 bark 接口
  somethingLikeDog.bark()
}

// 把下面的函数传给 `doSomethingInside()`
let f: F = (dog: Dog) => {
  // 对传进来的 dog 的 bark 接口进行调用
  dog.bark();
  
  const anotherDog: Dog = {
    name: "dog",
    bark: () => console.log('miao'),
  }
  
  // 返回一只奇怪的 Dog
  return anotherDog
}
doSomethingInside(f)

这个函数 doSomethingInside 接收一个「参数」,该参数是一个「函数」,期望的函数类型是 DogDog

将入参函数设为函数 f,也就是说函数 f 约束并期望接收一个 Dog 的入参,也期望返回一个 Dog

后来出现一个函数 G()

然后假设我们有新的函数,我们设为函数 G

这个函数的「入参」可能为 Animal 或者 Greyhound,「返回值」也可能为 Animal 或者 Greyhound

所以 G 会有四种组合(2 种入参类型 × 2 种返回类型)。

TypeScript
// 组合一
type G1 = (animal: Animal) => Animal;
const g1: G1 = (animal: Animal) => ({ name: "animal" });

// 组合二
type G2 = (animal: Animal) => Greyhound;
const g2: G2 = (animal: Animal) => ({ name: "greyhound", bark: () => {}, runFast: () => {} });

// 组合三
type G3 = (greyhound: Greyhound) => Animal;
const g3: G3 = (greyhound: Greyhound) => ({ name: "animal" });

// 组合四
type G4 = (greyhound: Greyhound) => Greyhound;
const g4: G4 = (greyhound: Greyhound) => ({ name: "greyhound", bark: () => {}, runFast: () => {} });

函数 G() 要取代函数 F()

最后,我们来讨论一下这 4 个「新函数 G」,与「目标函数类型 F」的兼容性,我们要找到函数 F() 类型的子类型;

我们先来看看如果在编辑器中,TSLint 会报什么错误:

TypeScript
type F = (dog: Dog) => Dog;
let f: F = (dog: Dog) => ({ name: "dog", bark: () => {} });

/* (animal: Animal) => Animal */
f = g1; // ❌ Error 返回值类型不匹配
// 不能将类型 “G1” 分配给类型 “F”。
//   类型 "Animal" 中缺少属性 "bark",但类型 "Dog" 中需要该属性。ts(2322)

/* (animal: Animal) => Greyhound */
f = g2; // ✅ Correct

/* (greyhound: Greyhound) => Animal */
f = g3; // ❌ Error 参数类型和返回值类型均不匹配
// 不能将类型 “G3” 分配给类型 “F”。
//   参数 “greyhound” 和 “dog” 的类型不兼容。
//     类型 "Dog" 中缺少属性 "runFast",但类型 "Greyhound" 中需要该属性。ts(2322)

/* (greyhound: Greyhound) => Greyhound */
f = g4; // ❌ Error 参数类型不匹配
// 不能将类型 “G4” 分配给类型 “F”。
//   参数 “greyhound” 和 “dog” 的类型不兼容。
//     不能将类型 “Dog” 分配给类型 “Greyhound”。ts(2322)

上面代码中,我们要用函数 g() 去取代函数 f(),函数 g() 内部和函数 f() 内部完全不一样;

在赋值过程中的报错原因:

🤔
入参类型兼容性讨论

因为函数 doSomethingInside 会传一个 Dog 类型的变量给函数 g(),对于函数 g() 而言,函数 g() 需要能够处理 Dog 类型的变量;

所以 g() 的入参类型只能是 Animal,不能是 Greyhound,因为传入的参数 Dog 类型中不会有 runFast() 接口。

🤔
返回值类型兼容性讨论

因为 doSomethingInside 要求函数 g() 要返回一个 Dog 类型的变量,即函数 g() 的返回值中要由 bark() 接口;

所以 g 的返回指类型只能是 Greyhound,不能是 Animal,因为返回 Animal 的话,返回值中没有 bark() 接口。

综上所述,函数 f() 只能被入参类型是 Animal,返回值类型是 Greyhound 的函数类型,即 g2()

总结一下子

根据提示,回想最开始的,我们总结一下:

  • 传入的参数必须包含足够多的数据,函数内部才能处理原本传入的参数类型,即可以传入子类型变量,给父类型的入参,故参数的类型是「逆变」的。

  • 返回的数据必须包含足够多的数据,函数外部才能处理函数返回的返回值,即子类型可以,故返回值的类型是「协变」的。

所以我们知道了,从父子类型的角度来看,DogDog 的子类型是 AnimalGreyhound,其中函数的参数是「逆变」的。

回到最初:绝大多数的语言是允许协变的,同时逆变一般是不允许的,除了函数的入参