跳到主要内容

函数的子类型

函数之间产生子类型关系的条件

f: (x: T1) => T2g: (x: S1) => S2,不妨令gf的子类型,且x: T1

我们接下来分析此时T1, T2, S1, S2需要满足什么必要条件。我们从函数参数和返回值两个角度来分析。

  • 函数参数。

gf的子类型,所以,g应当能够安全替换所有f的使用处。对于x: T1时的任何取值,计算f(x)不会出错,那计算g(x)也不应该出错。

g的参数类型又是S1,那么就知道,T1类型需要可以出现在任何S1出现的位置。

这等价于需有T1<:S1这个条件。

  • 返回值。

gf的子类型,所以g应当能够安全替换所有f的使用处。

也就是如f(x): T2的使用处,都能被g(x): S2安全替换。这就是T2类型都需要能被S2类型安全替换。

这等价于需有S2<:T2这个条件。


函数的子类型规则

根据我们刚才的推导,我们得到了函数之间产生子类型关系的必要条件。 我们可以验证发现,这个必要条件同时也是充分条件。

那么就可以得出完整的函数的子类型规则:

T1<:S1S2<:T2S1S2<:T1T2(S-Arrow){ T_1 <: S_1 \quad S_2 <: T_2 \over S_1 \Rightarrow S_2 <: T_1 \Rightarrow T_2 } \tag{S-Arrow}

若是理解了上面这个式子,就理解了函数的子类型关系。


类型构造器

函数类型,其实是一种 类型构造器

这个说法可能有点陌生,但是大家应该都知道TypeScriptArray这个类型吧。

你不能通过const a: Array = []来使用Array,因为你还必须给Array传上一个 类型参数,比如

const a: Array<number> = [];

这种接受若干类型,吐出另外一个类型的 类型,就叫做类型构造器(Type Constructor),也叫做类型算子(Type Operator)。

函数类型,其实也是一个类型构造器。它接受两个类型参数:入参的类型,函数返回值的类型,吐出一个函数类型。之所以我们察觉不到,是因为函数类型的标注用了箭头来当语法糖,例如,(x:number)=>string

其实完全可以把函数类型的形式做得和Array一样,比如用Func<number,string>来表示(x:number)=>string

你可以把类型构造器,看成类型版本的函数。它接受类型返回类型,本身也是类型;正如函数接受数值返回数值,本身也是数值。只是,它们所在的层次不同,前者在类型的世界里,后者在项的世界里。


函数类型构造器的逆变、协变

对于每一个类型构造器,我们都需要定义其子类型规则。 一般来说,我们会根据这个类型构造器在语言中的语义来确定。

比如,我们刚刚就确定了函数类型的子类型规则:

T1<:S1S2<:T2S1S2<:T1T2(S-Arrow){ T_1 <: S_1 \quad S_2 <: T_2 \over S_1 \Rightarrow S_2 <: T_1 \Rightarrow T_2 } \tag{S-Arrow}

我们说函数类型对参数是逆变(Contravariant)的——它反转了子类型关系的方向,对返回值是协变(Covariant)的——它维持了子类型关系的方向。

例如在TypeScript中,有:

number=>unknown <: 1 => unknown // 参数逆变
number=>string <: 1 => unknown // 参数逆变+返回值协变
number=>string <: number => unknown // 返回值协变

注:若无特别指出,关于TypeScript的结论都是开启--strictFunctionTypes开关的结果。若不开启此开关,函数是双变的。


函数类型构造器的双变

所谓双变(Bivariant),就是既逆变,也协变。试看如下代码:

declare let f1: (x: {}) => void;
declare let f2: (x: { dog: true }) => void;
declare let f3: (x: { cat: true }) => void;
f1 = f2; // Error with --strictFunctionTypes
f2 = f1; // Ok
f2 = f3; // Error

若不开启--strictFunctionTypes,TypeScript函数的参数位置是双变的。


数组类型构造器的协变和不变

我们考察了TypeScript中的函数类型构造器,再来考察一下TypeScript对Array这个类型构造器的设计。

Array是协变的,因为有:

Array<number> <: Array<unknown>

但是,TypeScript采用的这个设计是不安全的,如下代码所示, c这个变量,推导出的类型是number。但是运行时的值是个string

const a: Array<number> = [];
const b: Array<unknown> = a;
b.push('haha string!');
const c = a[0]; // 这里实际获得了一个string类型的值

因此,对于可变数组来说,协变是不安全的,应当使用不变(Invariant)。

不变是指,Array<S>Array<T>之间若ST不同,则没有子类型关系,不能互相替代。但是这会使得数组使用起来不够方便,因此TypeScript也作出了妥协。 不可变数组ReadonlyArray的协变则是安全的。