TypeScript中的类型兼容是基于结构归类的。在普通分类的相比之下,结构归类是一种纯粹用于将其成员的类型进行关联的方法。思考下面的代码:
interface Named { name: string; } class Person { name: string; } var p: Named; // 正确, 因为这里编译器自动进行结构归类 p = new Person();
如C#、Java这些表面上的类型语言(这里指的“表面上的类型语言”,指C#和Java需要使用“implements”关键字明确指出类实现某个接口才能对应得上其类型),以上的代码便会被当作错误的,因为没有明确指出Person类实现(implements)Named接口。
TypeScript的结构类型系统就是基于JavaScript代码典型的写法设计的。因为JavaScript广泛使用匿名对象如函数表达式和字面量对象,使用结构类型系统代替表面上处理将使JavaScript中这些关系体现的更自然。
TypeScript类型系统允许执行某些在编译阶段无法确定安全性的操作。当一个类型系统有这个属性的时候,我们视之为“不健全的”。TypeScript中允许执行这些操作这一机制是经过仔细考虑的,通过文档我们将解释什么情况下会发生这种事和允许这些操作后所带来的好的一面。
跟着代码出发(老司机,带带我...)
TypeScript结构类型系统的基本规则如:如果x是兼容y的,那么y至少具有和x相同的属性成员。例如:
interface Named { name: string; } var x: Named; // 推断出y的类似是{ name: string; location: string; } var y = { name: 'Alice', location: 'Seattle' }; x = y;
为了检查y是否能够赋值给x,编译器需要检查x的每个属性,并且在y中找到对应的兼容属性。在这种情况下,y必须有个名为name并且值是字符串的属性。而y满足了这条件,所以能够赋值给x。
同样的规则也适用在检查函数调用参数时:
// 接着上面的代码 function greet(n: Named) { alert('Hello, ' + n.name); } greet(y); // ok
注意,y有一个额外的"location'属性,但这并未产生错误。只有目标类型的成员(这里的目标类型指“Named”)会被检查是否兼容。
比较的过程是递归进行的,检查每个成员及其子成员的类型。
两个函数之间的比较
原始类型和对象类型的比较是相对简单的,但问题是被认为是兼容的函数是怎么样的呢。让我们从一个最基本的例子开始吧,以下两个函数的不同之处仅仅在于他们的参数列表:
var x = (a: number) => 0; var y = (b: number, s: string) => 0; y = x; // ok x = y; // 错误
若要检查x是否可以赋值给y,首先看参数列表。y中的每个参数必须在x中都有相应并且类型兼容的参数。主意,参数名可不考虑,只要类型能够对应上。在这个案例中,x的每个参数在y中都有相应的参数,所以是允许赋值的。第二个赋值是错误的,因为y的第二个属性是必须的,但是x没这个属性,所以不被允许赋值。
你可能对为什么在y=x中允许第二个参数而感到疑惑。赋值的过程允许忽略函数额外的参数,这在JavaScript中实际上很常见。例如Array的forEach函数为他的回调函数提供了三个参数:数组元素、索引、包含它的数列。然而,大多用到的也就第一个参数。
var items = [1, 2, 3]; // 这些参数不是强制要求的 items.forEach((item, index, array) => console.log(item)); // 这样也可以 items.forEach((item) => console.log(item));
现在让我们来看看返回值类型是如何处理的,使用两个仅返回值类型不同的函数:
var x = () => ({name: 'Alice'}); var y = () => ({name: 'Alice', location: 'Seattle'}); x = y; // ok y = x; // 错误,因为x()缺少一个属性
类型机制强制要求源函数的返回值类型是目标函数返回值类型的子类型。
可选参数及剩余参数
当对函数的兼容进行比较时,可选和必须的参数是可以互换的。源类型有额外的可选参数不会造成错误,目标类型的可选参数中不存在对应参数也不会产生错误。
当函数有其余的参数,将会被当作无限的可选参数一样来处理。
从类型机制来看这是不健全的,但从代码运行的角度看,可选参数不是强制要求的,因为它相当于在函数参数的对应位置传入一个"undefined"。
下面的例子是个普遍模式的函数,该函数需要传入个回调函数,并且在调用的时候传入可预知(对于开发者而言)但是未知数量(对于类型机制)的参数。
function invokeLater(args: any[], callback: (...args: any[]) => void):void { callback.apply(null,args); } // invokeLater"可能"任何数量的参数 invokeLater([1, 2], (x, y) => console.log(x , y)); invokeLater([3], (x?, y?) => console.log(x , y)); invokeLater([4,5,6,7], (x?, y?) => console.log(x , y));
重载的函数
当一个函数具有重载情况时,源类型的每次重载必须在目标类型上可找到匹配的签名。这确保了目标函数可以在所有源函数可调用的地方调用。当做兼容性检查时,带有特殊签名的函数重载(那些重载时使用字符串)将不会使用他们特殊签名。(详情可见:TypeScript Declaration Merging(声明合并)中的接口合并第二个案例)
枚举
枚举和number相互兼容。不同枚举类型的枚举值之间是不兼容的。例如:
enum Status { Ready, Waiting }; enum Color { Red, Blue, Green }; var status = Status.Ready; status = Color.Green; // 错误
类
类的兼容和对象字面量类型还有接口的兼容相似,只是有一个不同:它具有静态类型和实例类型。比较两个类类型的对象时,只比较实例部分的成员。静态部分的成员和构造函数不影响兼容性。
class Animal { feet: number; constructor(name: string, numFeet: number) { } } class Size { feet: number; constructor(numFeet: number) { } } var a: Animal; var s: Size; a = s; // ok s = a; // ok
类的私有成员
类中的私有成员会影响其兼容性。当一个类的实例进行兼容性检查时,如果它包含一个私有成员,那么目标类型必须也包含一个来源与同一个类的私有成员(详情可参阅:TypeScript Class(类)中的理解Private(私有))。这也造成了一个类可以被赋值为其父类的实例,但是却不能被赋值成另一个继承其父类的类(虽然他们是同一个类型)。
泛型
因为TypeScript是一个结构类型系统,参数类型只影响将其作为部分成员类型的目标类型(比如有个函数fn(a:string),然后函数中有个变量的某属性值是a,那么a对这个目标类型将产生影响)。
案例:
interface Empty<T> { } var x: Empty<number>; var y: Empty<string>; x = y; // ok, y可以和x的结构相匹配
在上述例子中,x和y是兼容的,因为他们的结构在使用类型参数并没什么不同之处。改变这个例子,给Empty<T>加个成员,看看会是什么情况:
interface NotEmpty<T> { data: T; } var x: NotEmpty<number>; var y: NotEmpty<string>; x = y; // 错误,x和y不兼容
对于那些没有指定其参数类型的泛型类型,兼容性的检查是通过为所有没指定参数类型的参数使用"any"代替的。然后目标类型检查兼容性,就和非泛型的案例一般。
案例:
var identity = function<T>(x: T): T { // ... } var reverse = function<U>(y: U): U { // ... } identity = reverse; // ok,因为(x: any)=>any和(y: any)=>any可以匹配
深层探讨
子类VS赋值
至此为止,我们了解了兼容性,它并未在语言规范里定义。在TypeScript中有两种兼容:子类和赋值。它们的不同点在于,赋值扩展了子类型的兼容性并且可对"any"进行取值和赋值、对枚举进行取对应数值。
根据情况的不同,TypeScript语言会在不同的地方使用两种兼容机制中的一种。对于实际运用而言,类型的兼容性是由赋值时的兼容检查或者implements和extends关键字来控制的。更多详情,请参照TypeScript spec (友情提醒,是doc文件下载)