Subtyping in TypeScript

很多人认为继承是面向对象的核心,然后总结出来了“封装继承多态是面向对象核心特性”这样的结论。但是,不应该是这样子的。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,所以容易引起大家的混淆。

类型构造器

简单类型

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

首先,系统中存在最基本的类型, boolean,string,number,null,undefined 等。
然后,我们系统中还存在一个二元类型构造器 T1T2T_1 \rightarrow T_2, 其中的 T1T_1T2T_2 是两个 meta variable,都代表此处应该填入一个类型。而且两个类型可以不同。
这个类型构造器我们叫函数类型构造器,代表构造了参数类型是 T1,返回值类型是 T2 的函数类型。
对应的 TS 语法是 (arg: T1) => T2, 这里的 T1 和 T2 也是两个类型,比如实际例子 —— (arg: number) => string 或者 (arg: string) => boolean

函数类型我们也可以叫它 Arrow Type。

至于其中的 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 <: 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<:unknownnumber <: 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,你们可以自己推一下。

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

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

如果在 TypeScript 中开启 strict mode,我们会同时开启了 strictFunctionTypes

如果不开启它,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

但是这个处理逻辑存在严重的问题

我们可以给一个例子:

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 方法。

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 中,我们可以发现:

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

额外的,考虑一下下面的类型构造器:

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>不变」。

有个简单的理解方法,T 不仅出现在参数位置,还出现在返回值位置,所以 T 相对于整体不仅逆变,而且协变,不仅逆变而且协变就是不变。

当然如果关闭 strictFunctionTypes,它就变成了协变了。

Object Type

语法详细见 TS 的 object 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,我们就能推出所有 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 没有任何子类型关系,非常安全。

参考资料:

  • Paul Zhu 在清华实验室分享的幻灯片
  • Types and Programming Languages
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享