聊聊 TypeScript 类型兼容,协变、逆变、双向协变以及不变性
聊聊 TypeScript 类型兼容,协变、逆变、双向协变以及不变性。
1 前言
学过集合论的同学一定知道子集的概念,使用 ES6 class 写过继承的同学一定知道子类的概念,而使用过 TypeScript 的同学,也许知道子类型的概念。
但是你知道协变 (Covariant)、逆变 (Contravariant)、双向协变 (Bivariant) 和不变 (Invariant) 这些概念吗?你知道像 TypeScript 这种强大的静态类型检查的编程语言,是怎么做类型兼容的吗?我们今天来聊聊。
2 关于 Subtyping
子类型是编程语言中一个有趣的概念,源自于数学中子集的概念:
如果集合 A 的任意一个元素都是集合 B 的元素,那么集合 A 称为集合 B 的子集。
而子类型则是面向对象设计语言里常提到的一个概念,是继承机制的一个产物,以下概念来源百度:
在编程语言理论中,子类型是一种类型多态的形式。这种形式下,子类型可以替换另一种相关的数据类型(超类型,英语:supertype)。
子类型与面向对象语言中(类或对象)的继承是两个概念。子类型反映了类型(即面向对象中的接口)之间的关系;而继承反映了一类对象可以从另一类对象创造出来,是语言特性的实现。因此,子类型也称接口继承;继承称作实现继承。
我们可以理解子类就是实现继承,子类型就是接口继承,下面这幅图更精确的定义了这个概念,很多同学应该知道这个例子:
这幅图中,猫是一种动物,所以我们说猫是动物的子集,猫是动物的子类,或者说猫这种类型是动物这种类型的子类型。
3 Co…, Contra…, Bi…, Invariant?
一下提到四个陌生的单词,很多同学肯定一下就懵了。React 开发者应该对 HOC (High Order Component) 不陌生,它就是使用一个基础组件作为参数,返回一个高阶组件的函数。React 的基础是组件 (Component),在 TypeScript 里是类型 (Type),因此我们用HOT (High Order Type) 来表示一个复杂类型,这个复杂类型接收一个泛型参数,返回一个复合类型。
下面我用一个例子来阐述这四个概念,你可以将它使用TypeScript Playground运行,查看静态错误提示,进行更深刻理解:
|
|
我们将基础类型叫做T
,复合类型叫做Comp<T>
:
- 协变 (Covariant):协变表示
Comp<T>
类型兼容和T
的一致。 - 逆变 (Contravariant):逆变表示
Comp<T>
类型兼容和T
相反。 - 双向协变 (Covariant):双向协变表示
Comp<T>
类型双向兼容。 - 不变 (Bivariant):不变表示
Comp<T>
双向都不兼容。
4 TS 类型系统
在一些其他编程语言里面,使用的是名义类型 Nominal type,比如我们在 Java 中定义了一个 class Parent
,在语言运行时就是有这个Parent
的类型。因此如果有一个继承自Parent
的Child
类型,则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 中那些“奇怪”的疑问:
4.1 为什么 TS 中的函数类型是双向协变的?
首先我们需要知道,函数这一类型是逆变的。
对于协变,我们很好理解,比如Dog
是Animal
,那Array<Dog>
自然也是Array<Animal>
。但是对于某种复合类型,比如函数。(p: Dog) => void
却不是(p: Animal) => void
,反过来却成立。这该怎么理解?我这里提供两种思路:
假设(p: Dog) => void
为Action<Dog>
,(p: Animal) => void
为Action<Animal>
。
基于函数的本质
我们知道,函数就是接收参数,然后做一些处理,最后返回结果。函数就是一系列操作的集合,而对于一个具体的类型
Dog
作为参数,函数不仅仅可以把它当成Animal
,来执行一些操作;还可以访问其作为Dog
独有的一些属性和方法,来执行另一部分操作。因此Action<Dog>
的操作肯定比Action<Animal>
要多,因此后者是前者的子集,兼容性是相反的,是逆变。基于第三方函数对该函数调用
假设有一个函数
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) => void
;fp
被调用时使用的是Cat
这一参数。这显然会使程序崩溃!因此对于函数这一特殊类型,兼容性需要和其参数的兼容性相反,是逆变。
其次我们再来看看为什么 TS 里的函数还同时支持协变,也就是双向协变的?
前面提到,TS 使用的是结构化类型。因此如果Array<Dog>
和Array<Animal>
兼容,我们可以推断:
Array<Dog>.push
与Array<Animal>.push
兼容- 也就是
(item: Dog) => number
和(item: Animal) => number
兼容((item: Dog) => number).arguments
和((item: Animal) => number).arguments
兼容Dog
和Animal
兼容
- 也就是
为了维持结构化类型的兼容性,TypeScript 团队做了一个权衡 (trade-off)。保持了函数类型的双向协变性。但是我们可以通过设置编译选项--strictFunctionTypes true
来保持函数的逆变性而关闭协变性。
4.2 为什么参数少的函数可以和参数多的函数兼容?
这个问题其实和函数类型逆变兼容一个道理,也可以用上述的两种思路理解,Dog
相当于多个参数,Animal
相当于较少的参数。
4.3 为什么返回值不是 void 的函数可以和返回值是 void 的函数兼容?
从第三方函数调用的角度,如果参数是一个非 void 的函数。则表明其不关心这个函数参数执行后的返回结果,因此哪怕给一个有返回值的函数参数,第三方的调用函数也不关系,是类型安全的,可以兼容。
4.4 怎么构造像 Java 那样的名义类型?
通常情况下,我们不需要构造名义类型。但是一定要实现的话,也有一些 trick:
名义字符串:
|
|
名义结构体:
|
|
4.5 如何在运行时检测变量的“名义”类型?
TypeScript 的类型检测只是一种编译时的转译,编译后类型是擦除的,无法使用 JavaScript 的instanceof
关键字实现类型检验:
|
|
如果要实现检测,需要我们自己实现函数判断类型内部的结构:
|
|
还有更多“奇怪”的疑问,可以参考TypeScript Wiki FAQs。
5 类型安全和不变性
最后来聊一下不变性 (Invariant) 的应用。上面我们提到Array<T>
这一复合类型是协变。但是对于可变数组,协变并不安全。同样,逆变也不安全(不过一般逆变不存在于数组)。
下面这个例子中运行便会报错:
|
|
因此,我们使用可变数组时应该避免出现这样的错误,在做类型兼容的时候尽量保持数组的不可变性 (immutable)。而对于可变数组,类型本应该做到不变性。但是编程语言中很难实现,在 Java 中数组类型也都是可变而且协变的。