类型系统
编程的本质是操作数据,而类型系统旨在于限制对数据的操作,类型的本质就是数据的操作空间。
操作空间
操作空间指一个数据被允许的所有操作,比如:数字只能和数字相乘,而不能和字符串相乘。把类型看作操作空间好像又复杂又多余,但在后面的章节中你将通过这种理解收获良多。
interface Cat {
name: string;
walk(): void;
}
如上,Cat
类型的含义是:当一个数据的类型为 Cat
时, 它能执行的操作有:被读取 name
属性并返回 string
类型的数据、执行 walk
方法并返回 void
类型的数据,这些操作组成了这个数据的操作空间。
类型继承
interface Animal {
eat(): void;
}
interface Cat extends Animal {
name: string;
walk(): void;
}
通过对 Animal
的继承,现在类型 Cat
的数据可以执行 eat
方法了,这说明 Cat
的操作空间扩大了。 可见类型继承的本质就是操作空间的融合。
类型兼容
类型兼容就是把一种类型的数据当作另一种类型的数据来操作。 当类型 A 继承于类型 B 时,类型 A 其实就包含了类型 B 允许的所有操作,也就是说可以把类型 A 的数据当作类型 B 的数据来操作,所以我们可以放心地把类型 A 的数据赋给类型 B 的变量。
变量类型
这里还需要明确的一点是:既然类型对数据来说表示这个数据的操作空间,那么对变量来说意味着什么? 我们可以把变量看作数据的容器,变量的类型表示这个容器内的数据应该能够执行的操作,如果外部数据的操作空间不包含这些操作,则数据不能进入该容器。 也就是说,变量的类型是表示该变量所承载数据的最小操作空间,它是决定数据能否被赋给变量最低标准。
综上,类型兼容的条件可以概括为:
- 分别确定发送端和接收端的操作空间
- 对比操作空间大小
接下来看一些简单的例子:
let a = { name: 'a' };
let b: { name: string; age: number } = a;
变量 a 的数据的操作空间是读取 name
属性并返回 string
类型,变量 b 要求的最小操作空间还有读取 age
属性并返回 number
类型。 显然变量 a 不满足变量 b 的最小操作空间,所以赋值报错。
let a = { name: 0 };
let b: { name: string } = a;
变量 a 的数据的操作空间是读取 name
属性并返回 number
类型,变量 b 要求的最小操作空间是读取 name
属性并返回 string
类型。 虽然它们都能被读取 name
属性,但是 number
类型的数据不能当作 string
来操作,所以赋值报错。 但是如果把变量 a 数据的 name
改成 string
的子类型,就能赋值成功,因为子类型总是兼容父类型。
函数
函数的特殊之处在于它既能输入数据(传参)也能输出数据(返回值),对于不同的数据流向需要分别讨论。
传参
let a = (e: { name: string }) => {};
let b: (e: { name: string; age: number }) => void = a;
在分析前,我们首先得关注一个问题:类型对参数来说意味着什么?参数的本质就是函数内变量,所以参数类型和变量类型的含义是一样的,表示接收端所要求的最小操作空间。
明白了这个后,再看当 a 赋给 b 后,是谁在给 ab 传参:
根据类型兼容原理,从外部传给 b 的数据需要通过 b 的参数类型检查,检查通过后直接发送给 a。 所以需要事先确保:经过 b 参数类型检查的数据也能通过 a 参数类型检查,也就是说 b 的参数类型表示的操作空间应该比 a 的大。
综上,函数参数类型兼容的条件是:
返回值
let a = () => ({ name: string });
let b: () => { name: string; age: number } = a;
既然参数传递是从 b 到 a,那么返回值传递很自然地就是从 a 到 b。 既然想把 a 的返回值传递给 b 的返回值,就需要通过类型检查,此时只需要确保 a 的返回值的操作空间比 b 的大就行了。
即函数返回值类型兼容的条件是: