很多人认为继承是面向对象的核心,然后总结出来了“封装继承多态是面向对象核心特性”这样的结论。但是,不应该是这样子的。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 演算 (符号是 ) 在 TS 中对应的语法。为了简单起见,我这里只考虑函数单参数的情况。
首先,系统中存在最基本的类型, boolean,string,number,null,undefined 等。
然后,我们系统中还存在一个二元类型构造器 , 其中的 和 是两个 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
定义
我们在 上加上一个扩展叫做 subtyping,这个扩展添加了两个内容
- 新引入了一个类型与类型之间的关系,称为子类型关系, 我们用符号
<:
来表示。A 是 B 的子类型表示为 A <: B。 - 新引入一个类型
unknown
。
我们有时候叫 unknown 为 顶类型(Top Type),而
而且对于新引入的 <:
和 unknown
,我们同时添加了四条 Subtyping Rule 和一条 Typing Rule
Subtyping Rule:
- S-Top: 对于任何类型 :
- S-Arrow: 对于任何类型 : 如果 而且 ,那么
- S-Refl: 对于任何类型 :
- S-Trans: 对于任何类型 : 如果 而且 ,那么
Typing Rule:
- T-Sub:如果在上下文中我们得知 的类型是 , 并且 ,那么我们可以知道,在当下上下文中,能推出 的类型也是
当然还有 之前就带有 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:
- 根据 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: 对于任何类型 : 如果 (( 或者 ) 而且 ),那么
但是这个处理逻辑存在严重的问题
我们可以给一个例子:
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 中,我们可以发现,函数类型构造器中
- 「如果 那么 」,参数位置的子类型关系被逆转了,我们一般称它为「函数类型构造器中,参数类型相对于整个类型构造器逆变」,或者简单说就是「参数逆变」
- 「如果 ,那么 」,返回值位置的子类型关系被保留了,我们一般称它为「函数类型构造器中,返回值类型相对于整个类型构造器协变」,或者简单说就是「返回值协变」
S-Arrow-Bivariant 中,我们可以发现:
- 「如果 ( 或者 ) 那么 」,参数位置的子类型关系,或者被逆转了,或者保留了。我们叫它「参数逆变或者协变」,「参数双变」
额外的,考虑一下下面的类型构造器:
type SomeFunctionTypeConstructor<T> = (x: T) => T;
复制代码
请问 SomeFunctionTypeConstructor<string>
和 SomeFunctionTypeConstructor<unknown>
的子类型关系。
如果去套之前的 S-Arrow,我们发现没法套进去。所以他们之间没有任何子类型关系。用之前的写法就是,「如果 而且 ,那么 」我们一般称为 「T
相对于 SomeFunctionTypeConstructor<T>
不变」。
有个简单的理解方法,T 不仅出现在参数位置,还出现在返回值位置,所以 T 相对于整体不仅逆变,而且协变,不仅逆变而且协变就是不变。
当然如果关闭 strictFunctionTypes,它就变成了协变了。
Object Type
语法详细见 TS 的 object type 文档
type Person = {
readonly name: string;
readonly age: number;
};
复制代码
约定我们把 Object Type 的第 n 个 key 写成 ,把取 A 类型中的 对应的 value 写成 。
我们来口头描述一下它的 Subtyping Rule:
- S-RcdPerm,如果 A 中的每一个 key 和 value 都和 B 中匹配上,只是出现顺序调换了,那么
- S-RcdDepth,如果 A 和 B 中的 key 数量和出现顺序都相同,而且相同的 key。对于任何的 , ,那么
- S-RcdWidth,如果 B 中的 key 都在 A 中出现,而且出现顺序一致,而且对应的 value 也都一致。但是 A 中还有多余的 key 加在后面,那么
例子:
- 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