类型兼容性

状态: 初稿

介绍

TypeScript 中的类型兼容性基于结构子类型.
结构类型是一种只考察成员分析两类型相关性的方法.
它显著区别于名义类型.
考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
interface Named {
name: string;
}

class Person {
name: string;
}

let p: Named;
// OK, because of structural typing
p = new Person();

可以看到, Person 类没有显式声明它是 Named 接口的一个实现, 在运用名义类型的语言看来, 如 C#, Java, 等同代码是错误的.

TypeScript 受 JavaScript 典型编码风格启发设计出结构类型系统.
鉴于函数表达式和对象字面量等匿名对象在 JavaScript 中被广泛使用, 以结构类型代替名义类型表示 JavaScript 库中那种类型关系更加自然.

健全性说明

TypeScript 的类型系统让在编译期不完全了解的某些操作得以安全运行. 如果一个类型系统具有此属性, 就说它是不”健全”的. 那些 TypeScript 允许不 “健全” 行为的地方都经过严格推敲, 对它们在何处发生和背后促发情景的解释贯穿本文.

那就开始吧

TypeScript 结构类型的基本规则可以这样描述: 为使类型 x 兼容类型 y, y 至少应该包含 x 所有成员. 例如:

1
2
3
4
5
6
7
8
interface Named {
name: string;
}

let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };
x = y;

要判断 y 能否赋值给 x, 对每个 x 中属性, 编译器在 y 中为之查找相兼容的成员属性.
在这个例子中, y 必须有一个叫做 namestring 类型成员. 而它满足条件, 赋值成立.

对函数实参的检查采用相同赋值规则:

1
2
3
4
function greet(n: Named) {
console.log("Hello, " + n.name);
}
greet(y); // OK

不难发现 y 有一个额外属性 location, 但它不会引起错误.
兼容性检查只考虑所有目标类型(本例 Named)成员.

这个比较过程是递归的, 及至每个成员和成员的成员.

比较两个函数

比较原始和对象类型相对直接, 而怎样的两个函数才是兼容的这个问题就不是那么好说清了.
作为开始, 我们研究两个只有参数列表有差异的函数:

1
2
3
4
5
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

为了弄清楚 x 能否赋值给 y, 首先考察它们的参数列表.
y 中为每个 x 参数找到类型兼容的对应参数.
匹配过程忽略参数名, 只关注类型.
本例第一条赋值语句, x 每个参数都在 y 中找到匹配, 因此它是正确的.

第二条赋值语句是错的, 在 x 中找不到 y 第二个必选参数 s, 即 x 不兼容 y.

或许你想知道对于 y = x, 为何舍弃参数可行的.
这是因为忽略额外函数参数在 JavaScript 中十分普遍.
比方说, Array#forEach 往回调函数传三个参数: 数组元素, 下标, 数组本身.
不过, 多数回调函数只保留第一个参数:

1
2
3
4
5
6
7
let items = [1, 2, 3];

// Don't force these extra parameters
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach(item => console.log(item));

现在来了解返回值类型所扮演角色, 以两个仅返回值不同的函数为例:

1
2
3
4
5
let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});

x = y; // OK
y = x; // Error, because x() lacks a location property

类型系统规定源函数的返回值类型应当是目标函数返回值的子类型.

译注: 等号右侧为源函数

函数参数协变

默认设置下, 对每对函数参数的要求较为宽松, 只要源参数能赋值给目标参数, 或反向成立.
这是不健全的, 调用者可能传一个类型不够具体的实参去调用参数类型更具体的给定函数.
实践中, 这种错误很少见, 而该原则使众多 JavaScript 设计模式成为可能. 一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum EventType { Mouse, Keyboard }

interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}

// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));

// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) => console.log((e as MouseEvent).x + "," + (e as MouseEvent).y));
listenEvent(EventType.Mouse, ((e: MouseEvent) => console.log(e.x + "," + e.y)) as (e: Event) => void);

// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));

打开 strictFunctionTypes 编译器选项让编译器报错.

可选参数, 变长参数

考虑函数兼容性, 可选和必选参数是可互换的.
源函数额外可选参数不会引起错误, 目标函数多出的可选参数也不会引起错误.

在类型系统眼里, 函数的变长参数相当一个无限可选参数序列.

以类型系统的角度来看, 这是不健全的, 但站在运行时的立场, 可选参数的想法并没有得到充分执行, 因为对多数函数传递 undefined 等同于可选参数.

一个以回调函数作为参数, 再以程序员可知而类型系统不可知的实参数量调用它的函数, 算是激发性示例, 同时, 它也是个常见模式.

1
2
3
4
5
6
7
8
9
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

// Confusing (x and y are actually required) and undiscoverable
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

函数重载

如果一个函数有重载, 源函数每个重载都必须被目标函数的一个签名匹配.
这条规则确保我们可以按源函数所有重载情形调用目标函数.

译注: 这里源函数 / 目标函数概念似与前文有出入, 下例为我编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface MyPrint {
(val: boolean) : void;
(val: number): void;
(val: string): void;
}

function myPrintImpl(val: boolean): void;
function myPrintImpl(val: number): void;
function myPrintImpl(val: any): void {
console.log(val);
}

let printIt: MyPrint = myPrintImpl; // Error, Type '{ (val: boolean): void; (val: number): void; }' is not assignable to type 'MyPrint'.

枚举的情况

枚举与数值类型互相兼容. 来自不同枚举集的枚举值不互相兼容:

1
2
3
4
5
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green; // Error

类的情况

类与字面量对象类型, 接口工作方式差不多, 只有一个例外: 它区分静态面, 实例面.
编译器在比较两个类对象时, 只比较实例面成员.
静态成员和构造器不影响类对象兼容性.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
feet: number;
constructor(name: string, numFeet: number) { }
}

class Size {
feet: number;
constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s; // OK
s = a; // OK

类的私有和保护成员

类的私有和保护成员影响它们的兼容性.
在比较类实例时, 如果目标对象有一个私有成员, 源对象就要有一个同源私有成员.
如果目标对象有一个保护成员, 源对象就要有一个同源保护成员.
它强调一个类与它父类兼容, 与有相同形体但来自不同继承分支的类兼容.

泛型的情况

TypeScript 是一个结构类型系统, 只有类型参数作为成员类型的一部分使用时, 才影响结果类型. 例如,

1
2
3
4
5
6
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y; // OK, because y matches structure of x

上例, 类型参数并没有造成 x, y 结构上的差异, 所以它们是兼容的.
稍作修改, 为 Empty<T> 添加一个成员, 用上类型参数:

1
2
3
4
5
6
7
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; // Error, because x and y are not compatible

类型参数特化后的泛型可看作普通类型.

对于类型参数未特化的泛型类型, 类型系统假定每个类型参数都是 any.
再和普通类型一样, 检查结果类型的兼容性.

1
2
3
4
5
6
7
8
9
let identity = function<T>(x: T): T {
// ...
}

let reverse = function<U>(y: U): U {
// ...
}

identity = reverse; // OK, because (x: any) => any matches (y: any) => any

高级话题

两种兼容性机制

谈了这么多, 我们使用的术语 “兼容性” 其实并未在语言标准定义.
在 TypeScript 中, 兼容性有两种类型: 子类型, 赋值.
两者仅区别在赋值用规则扩展了子类型兼容性使得任何类型可以与 any 相互转换, 枚举可以和相应数值类型相互转换.

语言依情况为不同场合选择兼容性机制.
为了实用, 即便是 implementsextends 子句, 类型兼容性也受制于赋值兼容性.

阅读 TypeScript spec 了解更多信息.

译注: 这段我没读懂

如果这篇文章对您有用,可以考虑打赏:)