聊聊 TypeScript 类型兼容,协变、逆变、双向协变以及不变性

聊聊 TypeScript 类型兼容,协变、逆变、双向协变以及不变性。

更新
2020-12-06:近期在团队技术分享上,提炼了一个 Playground,里面包含了更直观易懂的例子对全文的一些概念进行了补充说明。可以配合全文查看。

学过集合论的同学一定知道子集的概念,使用 ES6 class 写过继承的同学一定知道子类的概念,而使用过 TypeScript 的同学,也许知道子类型的概念。

但是你知道协变 (Covariant)、逆变 (Contravariant)、双向协变 (Bivariant) 和不变 (Invariant) 这些概念吗?你知道像 TypeScript 这种强大的静态类型检查的编程语言,是怎么做类型兼容的吗?我们今天来聊聊。

./mindmap.png

子类型是编程语言中一个有趣的概念,源自于数学中子集的概念:

如果集合 A 的任意一个元素都是集合 B 的元素,那么集合 A 称为集合 B 的子集。

./subset.png

而子类型则是面向对象设计语言里常提到的一个概念,是继承机制的一个产物,以下概念来源百度:

在编程语言理论中,子类型是一种类型多态的形式。这种形式下,子类型可以替换另一种相关的数据类型(超类型,英语:supertype)。

子类型与面向对象语言中(类或对象)的继承是两个概念。子类型反映了类型(即面向对象中的接口)之间的关系;而继承反映了一类对象可以从另一类对象创造出来,是语言特性的实现。因此,子类型也称接口继承;继承称作实现继承。

我们可以理解子类就是实现继承,子类型就是接口继承,下面这幅图更精确的定义了这个概念,很多同学应该知道这个例子:

./subtyping.png

这幅图中,猫是一种动物,所以我们说猫是动物的子集,猫是动物的子类,或者说猫这种类型是动物这种类型的子类型。

一下提到四个陌生的单词,很多同学肯定一下就懵了。React 开发者应该对 HOC (High Order Component) 不陌生,它就是使用一个基础组件作为参数,返回一个高阶组件的函数。React 的基础是组件 (Component),在 TypeScript 里是类型 (Type),因此我们用HOT (High Order Type) 来表示一个复杂类型,这个复杂类型接收一个泛型参数,返回一个复合类型。

下面我用一个例子来阐述这四个概念,你可以将它使用TypeScript Playground运行,查看静态错误提示,进行更深刻理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
interface SuperType {
  base: string;
}
interface SubType extends SuperType {
  addition: string;
}

// subtype compatibility
let superType: SuperType = { base: "base" };
let subType: SubType = { base: "myBase", addition: "myAddition" };
superType = subType;

// Covariant
type Covariant<T> = T[];
let coSuperType: Covariant<SuperType> = [];
let coSubType: Covariant<SubType> = [];
coSuperType = coSubType;

// Contravariant --strictFunctionTypes true
type Contravariant<T> = (p: T) => void;
let contraSuperType: Contravariant<SuperType> = function (p) {};
let contraSubType: Contravariant<SubType> = function (p) {};
contraSubType = contraSuperType;

// Bivariant --strictFunctionTypes false
type Bivariant<T> = (p: T) => void;
let biSuperType: Bivariant<SuperType> = function (p) {};
let biSubType: Bivariant<SubType> = function (p) {};
// both are ok
biSubType = biSuperType;
biSuperType = biSubType;

// Invariant --strictFunctionTypes true
type Invariant<T> = { a: Covariant<T>; b: Contravariant<T> };
let inSuperType: Invariant<SuperType> = { a: coSuperType, b: contraSuperType };
let inSubType: Invariant<SubType> = { a: coSubType, b: contraSubType };
// both are not ok
inSubType = inSuperType;
inSuperType = inSubType;

我们将基础类型叫做T,复合类型叫做Comp<T>

  • 协变 (Covariant):协变表示Comp<T>类型兼容和T的一致。
  • 逆变 (Contravariant):逆变表示Comp<T>类型兼容和T相反。
  • 双向协变 (Covariant):双向协变表示Comp<T>类型双向兼容。
  • 不变 (Bivariant):不变表示Comp<T>双向都不兼容。

在一些其他编程语言里面,使用的是名义类型 Nominal type,比如我们在 Java 中定义了一个 class Parent,在语言运行时就是有这个Parent的类型。因此如果有一个继承自ParentChild类型,则Child类型和Parent就是类型兼容的。但是如果两个不同的 class,即使他们内部结构完全一样,他俩也是完全不同的两个类型。

但是我们知道 JavaScript 的复杂数据类型 Object,是一种结构化的类型。哪怕使用了 ES6 的 class 语法糖,创建的类型本质上还是 Object,因此 TypeScript 使用的也是一种结构化的类型检查系统 structural typing

TypeScript uses structural typing. This system is different than the type system employed by some other popular languages you may have used (e.g. Java, C#, etc.)

The idea behind structural typing is that two types are compatible if their members are compatible.

因此在 TypeScript 中,判断两个类型是否兼容,只需要判断他们的“结构”是否一致,也就是说结构属性名和类型是否一致。而不需要关心他们的“名字”是否相同。

基于上面这点,我们可以来看看 TypeScript 中那些“奇怪”的疑问:

首先我们需要知道,函数这一类型是逆变的。

对于协变,我们很好理解,比如DogAnimal,那Array<Dog>自然也是Array<Animal>。但是对于某种复合类型,比如函数。(p: Dog) => void却不是(p: Animal) => void,反过来却成立。这该怎么理解?我这里提供两种思路:

假设(p: Dog) => voidAction<Dog>(p: Animal) => voidAction<Animal>

  1. 基于函数的本质

    我们知道,函数就是接收参数,然后做一些处理,最后返回结果。函数就是一系列操作的集合,而对于一个具体的类型Dog作为参数,函数不仅仅可以把它当成Animal,来执行一些操作;还可以访问其作为Dog独有的一些属性和方法,来执行另一部分操作。因此Action<Dog>的操作肯定比Action<Animal>要多,因此后者是前者的子集,兼容性是相反的,是逆变。

  2. 基于第三方函数对该函数调用

    假设有一个函数F,其参数为Action<Animal>,也就是type F = (fp: Action<Animal>) => void。我们假设Action<Dog>Action<Animal>兼容,此时我们如果传递Action<Dog>来调用函数F,会不会有问题呢?

    答案是肯定的,因为在函数F的内部,会对其参数fp也就是(p: Animal) => void进行调用,此时F也可以使用Cat这一Animal对其进行调用。而此时我们传递的参数fp(p: Dog) => voidfp被调用时使用的是Cat这一参数。这显然会使程序崩溃!

    因此对于函数这一特殊类型,兼容性需要和其参数的兼容性相反,是逆变。

其次我们再来看看为什么 TS 里的函数还同时支持协变,也就是双向协变的?

前面提到,TS 使用的是结构化类型。因此如果Array<Dog>Array<Animal>兼容,我们可以推断:

  • Array<Dog>.pushArray<Animal>.push兼容
    • 也就是(item: Dog) => number(item: Animal) => number兼容
      • ((item: Dog) => number).arguments((item: Animal) => number).arguments兼容
        • DogAnimal兼容

为了维持结构化类型的兼容性,TypeScript 团队做了一个权衡 (trade-off)。保持了函数类型的双向协变性。但是我们可以通过设置编译选项--strictFunctionTypes true来保持函数的逆变性而关闭协变性。

这个问题其实和函数类型逆变兼容一个道理,也可以用上述的两种思路理解,Dog相当于多个参数,Animal相当于较少的参数。

从第三方函数调用的角度,如果参数是一个非 void 的函数。则表明其不关心这个函数参数执行后的返回结果,因此哪怕给一个有返回值的函数参数,第三方的调用函数也不关系,是类型安全的,可以兼容。

通常情况下,我们不需要构造名义类型。但是一定要实现的话,也有一些 trick:

名义字符串:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Strings here are arbitrary, but must be distinct
type SomeUrl = string & { "this is a url": {} };
type FirstName = string & { "person name": {} };

// Add type assertions
let x = <SomeUrl>"";
let y = <FirstName>"bob";
x = y; // Error

// OK
let xs: string = x;
let ys: string = y;
xs = ys;

名义结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
interface ScreenCoordinate {
  _screenCoordBrand: any;
  x: number;
  y: number;
}
interface PrintCoordinate {
  _printCoordBrand: any;
  x: number;
  y: number;
}

function sendToPrinter(pt: PrintCoordinate) {
  // ...
}
function getCursorPos(): ScreenCoordinate {
  // Not a real implementation
  return { x: 0, y: 0 };
}

// Error
sendToPrinter(getCursorPos());

TypeScript 的类型检测只是一种编译时的转译,编译后类型是擦除的,无法使用 JavaScript 的instanceof关键字实现类型检验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
interface SomeInterface {
  name: string;
  length: number;
}
interface SomeOtherInterface {
  questions: string[];
}

function f(x: SomeInterface | SomeOtherInterface) {
  // Can't use instanceof on interface, help?
  if (x instanceof SomeInterface) {
    // ...
  }
}

如果要实现检测,需要我们自己实现函数判断类型内部的结构:

1
2
3
4
5
6
7
8
function isSomeInterface(x: any): x is SomeInterface {
  return typeof x.name === 'string' && typeof x.length === 'number';

function f(x: SomeInterface|SomeOtherInterface) {
  if (isSomeInterface(x)) {
    console.log(x.name); // Cool!
  }
}

还有更多“奇怪”的疑问,可以参考TypeScript Wiki FAQs

最后来聊一下不变性 (Invariant) 的应用。上面我们提到Array<T>这一复合类型是协变。但是对于可变数组,协变并不安全。同样,逆变也不安全(不过一般逆变不存在于数组)。

下面这个例子中运行便会报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Animal {}

class Cat extends Animal {
  meow() {
    console.log("cat meow");
  }
}

class Dog extends Animal {
  wow() {
    console.log("dog wow");
  }
}

let catList: Cat[] = [new Cat()];
let animalList: Animal[] = [new Animal()];
let dog = new Dog();

// covariance is not type safe
animalList = catList;
animalList.push(dog);
catList.forEach((cat) => cat.meow()); // cat.meow is not a function

// contravariance is also not type safe, if it exist here
catList = animalList;
animalList.push(dog);
catList.forEach((cat) => cat.meow());

因此,我们使用可变数组时应该避免出现这样的错误,在做类型兼容的时候尽量保持数组的不可变性 (immutable)。而对于可变数组,类型本应该做到不变性。但是编程语言中很难实现,在 Java 中数组类型也都是可变而且协变的

  1. What are covariance and contravariance?
  2. Covariance, contravariance and a little bit of TypeScript
  3. TypeScript Deep Dive
  4. Type System Behavior

相关内容