Subtyping in TypeScript

2,323 阅读9分钟

很多人认为继承是面向对象的核心,然后总结出来了“封装继承多态是面向对象核心特性”这样的结论。但是,不应该是这样子的。Grady Booch 曾经说过:“Inheritance is highly overrated”。有些人认为面向对象的核心是应该是藏在继承背后的子类型关系。

本文将会介绍 Subtyping,并且介绍 TS 中相应的语法细节。

Subtyping 和 Inheritance 的区别

千里冰封小时候,在知乎上提过一个问题——subtyping 和 inheritance 的区别是什么?,大家可以看看这里的回答。

两句话总结一下:

  • Subtyping refers to compatibility of interfaces. A type B is a subtype of A if every function that can be invoked on an object of type A can also be invoked on an object of type B.
  • Inheritance refers to reuse of implementations. A type B inherits from another type A if some functions for B are written in terms of functions of A.

java 的 class extends 同时声明了类型上的关系 —— subtyping 和实现上的重用 —— inheritance,所以容易引起大家的混淆。

类型运算符

类型运算符(type operator), 我的理解就是,部分内容是类型,它整体还是类型的东西。

比如:TS 中 number 是类型,number[] 也是类型,这里面有个固定的方法,把某个类型变成另一个类型,就是 ?₁ [] 这里的 ?₁ 可以换成任意的类型。

我们把这个类型运算符写出来,这是一元运算符 ?₁[],其中

  • ?₁ 是一个 meta variable,类似于我们之前学的形式参数。可以被实际参数替换,比如 number。这里我们叫它 meta variable,是为了和编程语言里的 variable 做区分。
  • 这个类型构造器接受一个参数,所以是「一元」
  • 在之前的例子中,它的作用是把 number 类型转变成为 number 数组类型。number 是一个类型,number []是一个类型,number [] 是这个构造器接受 number 类型后生成的新类型。

我们给一些常见的类型运算符的例子:

  • readonly [?₁, ?₂,?₃], 就是一个三元组类型运算符
  • readonly ?₁[], 就是一个数组类型构造器,是一元类型运算符,有一个 meta variable ?₁
  • { readonly a: ?₁, readonly b: ?₂ }, 就是一个对象类型运算符,是二元类型运算符,有两个 meta variable —— ?₁,?₂
  • type aliastype Foo<X, Y> = X, 这条 type alias 声明了Foo<?₁, ?₂> 这个二元的类型运算符。

上面四个例子中,前三个不是 type alias,它构造出了的全新的类型,我们也可以叫它类型构造器。

简单类型

我们介绍一下简单类型 lambda 演算 (符号是 λ\lambda_{\rightarrow}) 在 TS 中对应的语法。为了简单起见,我这里只考虑函数单参数的情况。

首先,系统中存在最基本的类型, boolean,string,number,null,undefined 等。

然后,我们系统中还存在一个二元类型构造器 ?₁ → ?₂, 但是用问号不好看,我们用字母的形式写,就是 T1T2T_1 \to T_2, 其中的 T1T_1T2T_2 是两个 meta variable,都代表此处应该填入一个类型。而且两个类型可以不同。 这个类型构造器我们叫函数类型构造器,代表构造了参数类型是 T1,返回值类型是 T2 的函数类型。

对应的 TS 语法是 (arg: ?₁) => ?₂,用字母的形式写就是 (arg: T1) => T2, 这里的 T1 和 T2 也是两个类型,比如实际例子 —— (arg: number) => string 或者 (arg: string) => boolean

函数类型我们也可以叫它 Arrow Type(箭头类型,类似 TS 中的「箭头函数」的命名)

至于其中的 typing rule,我就省略不写了,因为大家都知道,只有参数类型匹配上才能正确地函数调用。

其中还有个重要的概念叫做 typing context 或者 typing environment,我们经常简称它为 context 或者上下文。就是在一堆变量和类型组成的列表。给个直观例子:比如

const a: number = 1;
const b: boolean = true;
(c: string) => {
  // 这是一个函数
  console.log(c);
};

在第一行开头处的上下文就是 []。 在第一行结尾处的上下文就是 [['a', number]]。 在第二行结尾处的上下文就是 [['a', number], ['b', boolean]]。 在第三行结尾处的上下文就是 [['a', number], ['b', boolean], ['c', string]]

简单理解,就是当前位置能知道的所有变量和变量的类型。

Subtyping

定义

我们在 λ\lambda_{\rightarrow} 上加上一个扩展叫做 subtyping,这个扩展添加了两个内容

  • 新引入了一个类型与类型之间的关系,称为子类型关系, 我们用符号 <: 来表示。A 是 B 的子类型表示为 A <: B。
  • 新引入一个类型 unknown

我们有时候叫 unknown 为 顶类型(Top Type),而且对于新引入的 <:unknown ,我们同时添加了四条 Subtyping Rule 和一条 Typing Rule

Subtyping Rule:

  • S-Top: 对于任何类型 SSS<:unknownS <: \text{unknown}
  • S-Arrow: 对于任何类型 T1,T2,S1,S2T_1,T_2,S_1,S_2: 如果 T1<:S1T_1 <: S_1 而且 S2<:T2S_2 <: T_2,那么 S1S2<:T1T2S_1 \to S_2 <: T_1 \to T_2
  • S-Refl: 对于任何类型 SSS<:SS <: S
  • S-Trans: 对于任何类型 S,U,TS, U, T: 如果 S<:US <: U 而且 U<:TU <: T,那么 S<:TS <: T

Typing Rule:

  • T-Sub:如果在上下文中我们得知 tt 的类型是 SS, 并且 S<:TS <: T,那么我们可以知道,在当下上下文中,能推出 tt 的类型也是 TT

当然还有 λ\lambda_{\rightarrow} 之前就带有 T-Var, T-Abs,T-App 等多条规则,太数学了,我们就不描述了。

例子

const foo: number = 1;

const bar: unknown = foo;

下面的 h1 h2 h3 的 h 是 hypothesis 的意思,就是假设。

  • 当类型检查器,开始检查第三行代码时候,我们得到的信息是
  • h1: 在当前上下文中,foo 的类型是 number
  • 根据 S-Top,把 meta variable「S」 替换为 实际的类型「number」得到 h2
  • h2: number<:unknown\text{number} <: \text{unknown}
  • 根据 T-Sub,把 meta variable 「t」 换成 实际的项 「foo」,把 meta variable「S」 替换为 实际的类型「number」,把 meta variable「T」 替换为 实际的类型「unknown」得到 h3
  • h3: 在当前上下文中,foo 的类型是 unknown
  • bar 标注的类型是 unknown,它需要接受一个 unknown 的值,刚刚好 h3 告诉我们 foo 的类型是 unknown,所以 const bar: unknown = foo; 能通过类型检查
declare const foo: (x: unknown) => string;

const bar: (x: number) => unknown = foo;

这个例子的推导过程类似上面的例子,只不过多用到了一条 S-Arrow,你们可以自己推一下。

这个过程用文字描述很复杂,我们也可以用简洁的数学语言和数学公式表示,但是此处略去了。

函数类型的协变逆变不变与双变

首先我们来介绍什么是协变逆变。

定义

我们先给出 协变逆变不变双变的定义

对于某个一元类型运算符 Foo<?₁>

  • 对于任何类型 TS: 如果 T <:<: S ,那么 Foo<T> <:<:Foo<S> ,称为 ?₁ 关于 Foo<?₁> 协变
  • 对于任何类型 TS: 如果 S <:<: T ,那么 Foo<T> <:<:Foo<S> ,称为 ?₁ 关于 Foo<?₁> 逆变
  • 对于任何类型 TS: 如果 (T <:<: S 而且 S <:<: T),那么 Foo<T> <:<:Foo<S> ,称为 ?₁ 关于 Foo<?₁> 不变
  • 对于任何类型 TS: 如果 (T <:<: S 或者 S <:<: T),那么 Foo<T> <:<:Foo<S> ,称为 ?₁ 关于 Foo<?₁> 双变

如果要扩展定义到多元,用下面的推广方法:

对于某个n元类型运算符 Foo<?₁,?₂,...,?ₘ,...,?ₙ>

  • 对于任何 n + 1 个类型 T₁T₂,...,Tₘ,...,Tₙ, Sₘ: 如果 Tₘ <:<: Sₘ ,那么 Foo<T₁,T₂,...,Tₘ,...,Tₙ> <:<:Foo<T₁,T₂,...,Sₘ,...,Tₙ> ,称为 ?ₘ 关于 Foo<?₁,?₂,...,?ₘ,...,?ₙ> 协变
  • 对于任何 n + 1 个类型 T₁T₂,...,Tₘ,...,Tₙ, Sₘ: 如果 Sₘ <:<: Tₘ ,那么 Foo<T₁,T₂,...,Tₘ,...,Tₙ> <:<:Foo<T₁,T₂,...,Sₘ,...,Tₙ> ,称为 ?ₘ 关于 Foo<?₁,?₂,...,?ₘ,...,?ₙ> 逆变
  • 对于任何 n + 1 个类型 T₁T₂,...,Tₘ,...,Tₙ, Sₘ: 如果 (Sₘ <:<: Tₘ 而且Tₘ <:<: Sₘ ) ,那么 Foo<T₁,T₂,...,Tₘ,...,Tₙ> <:<:Foo<T₁,T₂,...,Sₘ,...,Tₙ> ,称为 ?ₘ 关于 Foo<?₁,?₂,...,?ₘ,...,?ₙ> 不变
  • 对于任何 n + 1 个类型 T₁T₂,...,Tₘ,...,Tₙ, Sₘ: 如果 (Sₘ <:<: Tₘ 或者Tₘ <:<: Sₘ ) ,那么 Foo<T₁,T₂,...,Tₘ,...,Tₙ> <:<:Foo<T₁,T₂,...,Sₘ,...,Tₙ> ,称为 ?ₘ 关于 Foo<?₁,?₂,...,?ₘ,...,?ₙ> 双变

S-Arrow

如果在 TypeScript 中开启 strict mode,我们会同时开启了 strictFunctionTypes。开启它后,TS 对函数的处理遵循规则 S-Arrow:

S-Arrow 中,我们可以发现,函数类型构造器中

  • 「如果 T1<:S1T_1 <: S_1 那么 S1U<:T1US_1 \to U <: T_1 \to U」,参数位置的子类型关系被逆转了,我们一般称它为「函数类型构造器中,参数类型相对于整个类型构造器逆变」,或者简单说就是「参数逆变」
  • 「如果 S2<:T2S_2 <: T_2,那么 US2<:UT2U \to S_2 <: U \to T_2」,返回值位置的子类型关系被保留了,我们一般称它为「函数类型构造器中,返回值类型相对于整个类型构造器协变」,或者简单说就是「返回值协变」

S-Arrow-Bivariant

如果不开启它,TypeScript 会按照这个逻辑处理:

  • S-Arrow-Bivariant: 对于任何类型 T1,T2,S1,S2T_1,T_2,S_1,S_2: 如果 ((T1<:S1T_1 <: S_1 或者 S1<:T1S_1 <: T_1) 而且 S2<:T2S_2 <: T_2),那么 S1S2<:T1T2S_1 \to S_2 <: T_1 \to T_2

S-Arrow-Bivariant 中,我们可以发现:

  • 「如果 (T1<:S1T_1 <: S_1 或者 S1<:T1S_1 <: T_1) 那么 S1U<:T1US_1 \to U <: T_1 \to U」,参数位置的子类型关系,或者被逆转了,或者保留了。我们叫它「参数双变」

但是 S-Arrow-Bivariant 处理逻辑存在严重的问题

我们可以给一个例子:

const foo: (x: number) => unknown = x => {
  console.log(x.toFixed(2));
};

const bar: (x: unknown) => unknown = foo;

bar(false); // Uncaught TypeError: x.toFixed is not a function

这个代码的第五行的赋值,只有在 strictFunctionTypes 开启的情况下才会报错。 但是最后的函数调用,运行时肯定会出错。因为 boolean 不存在 toFixed 方法。

不变

额外的,考虑一下下面的类型运算符:

type SomeFunctionTypeConstructor<T> = (x: T) => T;

请问 SomeFunctionTypeConstructor<string>SomeFunctionTypeConstructor<unknown> 的子类型关系。

如果去套之前的 S-Arrow,我们发现没法套进去。所以他们之间没有任何子类型关系。用之前的写法就是,「如果 S<:TS <: T 而且 T<:ST <: S ,那么 TT<:SST \to T <: S \to S」我们一般称为 「T 相对于 SomeFunctionTypeConstructor<T>不变」。

当然如果关闭 strictFunctionTypes,我们推导规则变成了S-Arrow-Bivariant,我们可以推导出来,它协变了,推导过程可以读者自行尝试。

Object Type

语法详细见 TS 的 object type 文档,在某些语言里,也叫它 Record type。

type Person = {
  readonly name: string;
  readonly age: number;
};

约定我们把 Object Type 的第 n 个 key 写成 knk_n,把取 A 类型中的 knk_n 对应的 value 写成 A[kn]A[k_n]

我们来口头描述一下它的 Subtyping Rule:

  • S-RcdPerm,如果 A 中的每一个 key 和 value 都和 B 中匹配上,只是出现顺序调换了,那么 A<:BA <: B
  • S-RcdDepth,如果 A 和 B 中的 key 数量和出现顺序都相同,而且相同的 key。对于任何的 knk_n, A[kn]<:B[kn]A[k_n] <: B[k_n],那么 A<:BA <: B
  • S-RcdWidth,如果 B 中的 key 都在 A 中出现,而且出现顺序一致,而且对应的 value 也都一致。但是 A 中还有多余的 key 加在后面,那么 A<:BA <: B

例子

  • S-RcdPerm 的例子: { readonly name: string; readonly age: number } <:<:{ readonly age: number; readonly name: string }
  • S-RcdDepth 的例子: { readonly name: string; readonly age: number } <:<: { readonly name: unknown; readonly age: unknown }
  • S-RcdWidth 的例子: { readonly name: string; readonly age: number, readonly id: number } <:<: { readonly name: string; readonly age: number }

所有 rule 可以自由组合。

比如我们要判断 { readonly age: number; readonly name: string } { readonly name: unknown; readonly age: unknown } 的子类型关系:

  • 根据 S-RcdPerm,我们可以得到 h1
  • h1: { readonly age: number; readonly name: string } <:<: { readonly name: string; readonly age: number }
  • 根据 S-RcdDepth ,我们可以得到 h2
  • h2: { readonly name: string; readonly age: number } <: { readonly name: unknown; readonly age: unknown }
  • 根据 S-Trans,代入h1,h2,我们可以得到
  • { readonly age: number; readonly name: string } <: { readonly name: unknown; readonly age: unknown }

根据这几条 rule,我们就能推出所有 object type 的子类型关系了。

我们用一句话总结一下: object type 的 key 出现的顺序无所谓,所有的 key 对应的 value 相对于整体协变,子类型允许有冗余字段。

Mutable ref

注意,我在上面写的例子都加上了 readonly,因为它如果是 mutable 的,他就不安全了。

下面给个运行时错误的例子:

const ref1: {
  current: number;
} = {
  current: 1,
};

const ref2: {
  current: unknown;
} = ref1;

ref2.current = '';

console.log(ref1.current.toFixed());

所以,理论上,我们只能让 object readonly 的字段协变,而 mutable 的字段应该是不变。

但是 TypeScript 为了大家学习简单一些,这里做了协变处理。

Method 参数双变

我们下面的讨论都开启 strictFunctionTypes。

我们观察到 strictFunctionTypes 的文档给了一个例子

type Methodish = {
  func(x: string | number): void;
};

function fn(x: string) {
  console.log('Hello, ' + x.toLowerCase());
}

// Ultimately an unsafe assignment, but not detected
const m: Methodish = {
  func: fn,
};
m.func(10);

这个例子说明了,现在大量的人使用了不安全的继承,所以 TS 特别允许了 method 的参数双变,虽然它是不安全的

这个行为影响到了很多地方,举个例子

class List<T> {
  push(x: T): void {
    throw new Error('unimplemented');
  }
  pop(): T {
    throw new Error('unimplemented');
  }
}

提问: List<number>List<unknown> 的子类型关系

我们观察一下这里的泛型参数出现在了 push 的参数和 pop 的返回值。而 method 的参数双变,也就是 T 应该或者逆变或者协变。T 出现在 pop 的返回值,所以 T 应该协变。结合一下两条要求,得到 T 是协变的,所以 List<number>List<unknown> 的子类型

class List2<T> {
  push: (x: T) => void {
    throw new Error('unimplemented');
  }
  pop(): T {
    throw new Error('unimplemented');
  }
}

提问: List2<number>List2<unknown> 的子类型关系

这里的 T 出现在了 push 的参数位置,但是 push 不是 method,只是普通的 field,而且 这个 field 是函数类型,函数的参数逆变,而 pop 要求 T 协变,所以 T 不变。也就是 List2<number>List2<unknown> 无子类型关系。

这个 demo 解释了 为什么 TS 中的 Array<T> 的 T 是协变的。你们可以点进 Array 的 .d.ts 看,你会发现 Array<T> 的 T 出现在 method 的参数和返回值,还有 mutable field 的值。而 TS 专门做了两个不安全的设定,method 参数双变,mutable 的字段的类型协变,导致了 Array<T> 的 T 是协变的。

在这里我建议大家开启一下 eslint 的这条 rule method-signature-style,并且选择 property 选项。这才能使自己的代码更安全。

如果开启了这条 rule,你需要做类型继承时候,可以这样子做

interface T1 {
  readonly func: (arg: unknown) => unknown;
  readonly bar: string;
  readonly baz: string;
}

interface T2 extends Omit<T1, 'func'> {
  readonly func: (arg: string) => string;
}

这时候 T1 和 T2 没有任何子类型关系,非常安全。

参考资料: