Typescript中的协变和逆变

874 阅读5分钟

写TS的时候经常会遇到一些复杂类型写得不是很明白,但就是报错或者就是能运行。积累了一些场景以后,再去学习一下基础知识更有体感~

1. 基本概念

1.1 鸭子类型

当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。

在TS中,一个对象所包含的属性、方法如果一致,那么它们就是等价的。

interface A {
  run(): void;
}

interface NotA {
  run(): void;
}

function runA(a: A) {
  a.run();
}

var b: NotA = { run() {} };
runA(b); // 不报错,可以运行

1.2 里式替换原则(Liskov Substitution Principle, LSP)

所有引用基类(父类)的地方必须能透明地使用其子类的对象。

这个原则非常重要,几乎贯穿了TS中的各种类型判断。这里涉及子类(子类型)和父类(父类型)的定义,官方定义比较抽象,通俗来说:属性/方法更多的就是子类型,更严谨一点,子类型比父类型更具体。

为什么不能单纯的说属性更多是子类型呢?因为还存在联合类型,1 | 2 1 | 2 | 3 的子类型,因为,前者更“具体”。联合类型更像是集合的概念,在集合概念中,属性更少的,反而是子集。

1.3 协变、逆变、不变

首先,有如下符号“≼”,A ≼ B 意味着 A 是 B 的子类型。

𝑓(⋅)是协变,当𝐴≤𝐵时,𝑓(𝐴)≤𝑓(𝐵)成立;

𝑓(⋅)是逆变,当𝐴≤𝐵时,𝑓(𝐵)≤𝑓(𝐴)成立;

𝑓(⋅)是不变,当𝐴≤𝐵时上述两个式子均不成立,即𝑓(𝐴)与𝑓(𝐵)相互之间没有继承关系。

当然,看了上面这些定义肯定还是一知半解的,真·搞混自己、搞懵别人。这个在下面的实际例子中展开说明吧。

2 TS中的协变和逆变

协变与逆变有一个解释协变和逆变的例子,但是它太复杂了,混合了参数类型和值类型两种情况,让我这种第一次看的人原地爆炸,对于刚接触的人,还是分开解释更好些~

2.1 协变

interface Animal {
  name: number;
}
interface Dog extends Animal {
  bark(): void;
}

// Dog ≼ Animal
var animal: Animal;
var dog: Dog;
animal = dog; // 成立,根据LSP,父类型可以被子类型替换

// 协变,稍微复杂些
var animals: Animal[];
var dogs: Dog[];
animals = dogs; // 成立

// 再复杂些
var getAnimal = (): Animal => {
  return animal;
};
var getDog = (): Dog => {
  return dog;
};
getAnimal = getDog; // 成立

在值、返回值的类型中是符合协变的,子类型和父类型的所属关系保持一致。

2.2 逆变

// Dog ≼ Animal

var feedAnimal = (o: Animal) => {};
var feedDog = (o: Dog) => {
  o.bark();
};
feedDog = feedAnimal; // 成立,feedAnimal ≼ feedDog
feedAnimal = feedDog; // 严格模式下报错,因为可能animal并不能保证存在bark()

// 也就是存在如下场景
function func(f: typeof feedDog) {
  var d: Dog;
  f(d);
}
func(feedAnimal);

在函数的参数类型中,是符合逆变的,函数的关系和参数的关系是相反的。但在TS中,参数类型是双向协变的(详见下文3.1小节),如果项目里开启了"strict": true,意味着,会来带开启 strictFunctionType ,此时,才按照逆变处理。

如果理解了逆变,那TS FAQ中的why-are-functions-with-fewer-parameters-assignable-to-functions-that-take-more-parameters,也可以更清晰的理解了。

function handler(arg: string) {
    // ....
}

function doSomething(callback: (arg1: string, arg2: number) => void) {
    callback('hello', 42);
}

// Expected error because 'doSomething' wants a callback of
// 2 parameters, but 'handler' only accepts 1
doSomething(handler);

这个写法是成立的,因为,callback的参数要求是两个,显然更具体。你可以理解成,arg 是 arg1 + arg2 的父类型,也就是:

  • Parameters<typeof callback> ≼ Parameters<typeof handler>
  • 根据逆变,handler函数是 callback函数的子类型,handler ≼ callback
  • 根据里氏替换,handler可以替换callback的使用场景

3 存疑

3.1 为什么TS的函数参数类型是双向的?

why-are-function-parameters-bivariant这篇文档解释了原因。实际上我并不是太理解,不过可以先按照文档的思路说一下,它举了一个可变数组的场景:

  • 已知,Dog ≼ Animal,那么Dog[] ≼ Animal[] ?
  • 可以先全部按照之前的结论来判断,得出Dog[] ≼ Animal[]
  • 既然 Dog[] ≼ Animal[] ,那么所有属性/方法应该都满足这个关系,也就是 Dog[].push ≼ Animal[].push
  • 而 Dog[].push 的函数定义,(x: Dog) => number 和 (x: Animal) => number 的关系呢?
  • 如果继续按照之前的结论, 应该是 (x: Animal) => number ≼ (x: Dog) => number
  • 显然,两者矛盾

所以,为了绕过这个矛盾的场景,TS做了妥协,允许函数的参数类型双向协变,也就是上文逆变2.2章节里,如果 "strict": false 的场景下,feedDog 是无法赋值给 feedAnimal 的。

但,事实上,这个例子我看了很久,无法参透其中道理,感觉没有很强烈的必然性。

事实上,我感觉官方例子举得不够好,因为可变数组这个例子非常的邪乎,我觉得很难作为这个“妥协”的合理解释,因为,即使是严格模式下,它还是双向的:

var animals: Animal[] = [];
var dogs: Dog[] = [];
var feedAnimals = as.push;
var feedDogs = dogs.push;

feedDogs = feedAnimals; // 成立
feedAnimals = feedDogs; // 依然成立 

那,为什么可变数组很邪乎呢?因为即使严格按照我们之前的定义理解:

  • 已知,Dog ≼ Animal
  • 可以得出,(x: Animal) => number ≼ (x: Dog) => number
  • 那么 Dog[].push(Cat) 合法

显然,在实际业务场景中,这种结果不安全。

所以,对于这类数组场景,两者关系只有是不变时,才是最安全的做法,但鉴于JS本身并没有禁止可变数组的方式,所以只能继续妥协下去。

3.2 一个可疑的现象

function func(o: { a: string }) {}
var o = { a: 'a', b: 1 };

func({ a: 'a', b: 1 }); // 这里是报错的,提示,Argument of type '{ a: string; b: number; }' is not assignable to parameter of type '{ a: string; }'.Object literal may only specify known properties, and 'b' does not exist in type '{ a: string; }'. 

func(o); // 这里是可行的

从现象来看,应该是只有变量赋值才会触发里氏替换?

可惜,简单查了下,TS的编译器并没有debug相关信息,无法确认是否是这个原因。有大神提示了相关代码在此处:checker.ts。但,我去,这文件,github都无法直接预览(太大了),我想想还是算了,没这么硬核,差不多行了。。。

image.png

参考资料