类型兼容性

Type Compatibility

简介

TypeScript中的类型兼容性,是基于结构化子类型赋予的。结构化的类型赋予,是一种仅依靠类型的成员,而将这些类型联系起来的方式。这一点与名义上的类型赋予有所不同(Type compatibility in TypeScript is based on structural subtyping. Structural typing is a way of relating types based solely on their members. This is in contrast with nominal typing)。请考虑以下代码:

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;

// 没有问题,因为这里的结构化类型赋予
p = new Person();

在诸如C#或Java这样的 名义类型语言 中,等效代码将报出错误,因为类Person并未显式地将其描述为是Named接口的一个 实现器 (In nominally-typed languages like C# or Java, the equivalent code would be an error because the Person class does not explicity describe itself as being an an implementor of the Named interface)。

TypeScript的结构化类型系统,是基于JavaScript代码的一般编写方式而设计的。因为JavaScript广泛用到诸如函数表达式及对象字面值这样的匿名对象,因此使用结构化类型系统而非名义类型系统,对于表示JavaScript的那些库中所发现的关系种类,就更加自然一些(TypeScript's structural type system was designed based on how JavaScript code is typically written. Because JavaScript widely uses anonymous objects like function expressions and object literals, it's much more natural to represent the kinds of relationships found in JavaScript libraries with a structural type system instead of a nominal one)。

关于可靠性/健全性的说明(A Note on Soundness)

TypeScript的类型系统,令到一些在编译时无法知晓的操作是安全的。当某个类型系统具备了此种属性时,就说其不是“健全的”。至于TypeScript允许在哪里存在不健全行为,则是被仔细考虑过的,贯穿本文,这里将对这些特性于何处发生,以及它们背后的动机场景,加以解释(TypeScript's type system allows certain operations that can't be known at compile-time to be safe. When a type system has this property, it is said to not be "sound". The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we'll explain where these happen and the motivating scenarios behind them)。

开始(Starting out)

TypeScript的结构化类型系统的基本规则,就是在y具备与x相同成员时,x就兼容y。比如:

interface Named {
    name: string;
}

let x: Named;

// y 所引用的类型是 { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };

x = y;

编译器要检查这里的y是否可以被赋值给x,就会对x的各个属性进行检查,以在y中找到相应的兼容属性。在本例中,y必须具有一个名为name的字符串成员。而它确实有这样的一个成员,因此该赋值是允许的。

interface Named {
    name: string;
    age: number;
}

let x: Named;

// y 所引用的类型是 { name: string; location: string; }
let y = { name: "Alice", location: "Seattle" };

// TSError: ⨯ Unable to compile TypeScript
// src/main.ts (12,1): Type '{ name: string; location: string; }' is not assignable to type 'Named'.
// Property 'age' is missing in type '{ name: string; location: string; }'. (2322)
x = y;

在对函数调用参数进行检查时,也使用到通用的赋值规则(The same rule for assignment is used when checking function call arguments):

function greet (n: Named) {
    alert ("Hello, " + n.name);
}

greet(y); // 没有问题

注意这里的y有着一个额外的location属性,但这并不会造成错误。在对兼容性进行检查时,仅会考虑目标类型(这里也就是Named)的那些成员。

该比较过程是递归进行的,对每个成员及子成员进行遍历(This comparison process proceeds recursively, exploring the type of each member and sub-member)。

两个函数的比较(Comparing two functions)

可以看出,对原生类型与对象类型的比较是相对直接的,而何种函数应被看着是兼容的这个问题,就牵扯到更多方面了(While comparing primitive types and object types is relatively straightforward, the question of what kinds of functions should be considered is a bit more involved)。下面就以两个仅在参数清单上不同的函数的基本示例开始:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // 没有问题

// TSError: ⨯ Unable to compile TypeScript
// src/main.ts (9,1): Type '(b: number, s: string) => number' is not assignable to type '(a: number) => number'. (2322)
x = y; // 错误

为检查x是否可被赋值给y,首先要看看参数清单。x中的每个参数,在y中都必须有一个类型兼容的参数与其对应。注意参数名称是不考虑的,考虑的仅是它们的类型。在本示例中,函数x的每个参数,在y中都有一个兼容的参数与其对应,因此该赋值是允许的。

第二个赋值是错误的赋值,因为y有着必要的第二个参数,x并没有,因此该赋值是不允许的。

对于示例中y = x之所以允许“丢弃”参数的原因,在JavaScript中,此种忽略额外函数参数的赋值,实际上是相当常见的。比如Array#forEach方法就提供了3个参数给回调函数:数组元素、数组元素的索引,以及所位处的数组。不过,给其一个仅使用首个参数的回调函数,仍然是很有用的:

let items = [1, 2, 3];

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

// 这样也是可以的
items.forEach(item => console.log(item));

现在来看看返回值类型是如何加以对待的,下面使用两个仅在放回值类型上有所区别的函数:

let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});

x = y; // 没有问题

// TSError: ⨯ Unable to compile TypeScript
// src/main.ts (6,1): Type '() => { name: string; }' is not assignable to type '() => { name: string; location: string; }'.
// Type '{ name: string; }' is not assignable to type '{ name: string; location: string; }'.
// Property 'location' is missing in type '{ name: string; }'. (2322)
y = x; // 错误,因为`x`缺少一个location属性

类型系统强制要求 源函数 的返回值类型,是 目标函数 返回值类型的一个子集(The type system enforces that the source function's return type be a subtype of the target type's return type)。

函数参数的双向协变(Funtion Parameter Bi-variance)

在对函数参数的类型进行比较时,加入源参数可被赋值给目标参数,或目标参数可赋值给源参数,那么函数间的赋值将成功。这是不完备的,因为某个调用器可能以被给予一个取更为具体类型的函数,却以不那么具体类型,来触发该函数而结束。在实践中,此类错误很少见,同时此特性带来了很多常见的JavaScript模式(When comparing the types of function parameters, assignment succeeds if either the source parameter is assignable to the target parameter, or vice versa. This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the funtion with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns)。下面是一个简要的示例:

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) {
    //...
}

//不完备,却是有用且常见的做法
listenEvent(EventType.Mouse, (e.MouseEvent) => console.log(e.x + "," + e.y));

// 具备完备性的不可取做法
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + "," + (<MouseEvent>e>).y);
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + "," + e.y));

// 下面这样写是仍然不允许的(肯定是错的)。因为完全不兼容类型,而强制开启类型安全(Still disallowed (clear erro). Type safety enforced for wholly incompatible types)
listenEvent(EventType.Mouse, (e: number) => console.log(e));

可选参数与其余参数(Optional Parameters and Rest Parameters)

在出于兼容性而对函数加以比较时,可选参数与必需参数是通用的。源类型的额外可选参数并不是一个错误,同时目标类型的、在源类型中没有对应参数的可选参数也不是一个错误(When comparing functions for compatibility, optional and required parameters are interchangeable. Extra optional parameters of the source type are not an error, and optional parameters of the target type without corresponding parameters in the source type are not an error)。

在某个函数具有其余参数时,其余参数就被当成是有无限个可选参数加以对待(When a function has a rest parameter, it is treated as if it were an infinite series of optional parameters)。

这一点从类型系统角度看是不完备的,但因为对于大多数函数来数,在那个位置传递undefined都是等效的,因此从运行时角度,可选参数这一概念通常并不是精心构思的(This is unsound from a type system perspective, but from a runtime point of view the idea of an optional parameter is generally not well-enforced since passing undefined in that position is equivalent for most functions)。

下面的示例就是某个取一个回调函数,并以可预测(对于程序员)却未知(对于类型系统)数量的参数来触发该回调函数的函数的常见模式(The motivating example is the common pattern of a function that takes a callback and invokes it with some predictable(to the programmer) but unknown(to the type system) number of arguments):

function invokeLater(args: any[], callback: (...args: any[]) => void) {
    /* 以 args 来触发回调函数 */
}

// 不完备 -- invokeLater "可能" 提供任意数量的参数
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

// 混乱(x与y实际上是必需的)且无法发现(Confusing ( x and y are actually required ) and undiscoverable )
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));

带过载的函数(Functions with overloads)

当函数有着过载时,那么源类型中的每个过载,在目标类型上都必须有一个兼容的签名与其匹配。这样才能确保目标函数可与源函数所在的同样场合进行调用(When a function has overloads, each overload in the source type must be matched by a compatible signature on the target type. This ensures that the target function can be called in all the same situation as the source function)。

枚举的兼容性

枚举与数字兼容,同时数字也与枚举兼容。不同枚举类型的枚举值,被看着是兼容的。比如:

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green; // 没毛病

类的兼容性(Classes)

类的兼容性与对象字面值及接口类似,但有一个例外:类同时有着静态与实例类型(Classes have both a static and an instance type)。在对某个类类型的两个对象进行比较时,仅比较实例的成员。静态成员与构造器并不影响兼容性。

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

类中的私有与受保护成员

类中的私有与受保护成员,对类的兼容性有影响。在对类的某个实例进行兼容性检查时,如目标类型包含了一个私有成员,那么源类型也必须要有一个从同样类继承的私有成员。与此类似,同样的规则也适用与有着受保护成员的实例。这就令到类可被兼容的赋值给其超类,但却 不能 兼容的赋值给那些来自不同继承层次、除此之外有着同样外形的类(This allows a class to be assignment compatible with its super class, but not with classes from a different inheritance hierarchy which otherwise have the same shape)。

泛型(Generics)

因为TypeScript是一个结构化的类型系统(a structural type system),所以类型参数在作为某成员类型一部分而被消费是,其仅影响最终类型。比如:

interface Empty<T> {}

let x: Empty<number>;
let y: Empty<string>;

x = y; //没有问题,y 与 x 的解构匹配

在上面的代码中,xy是兼容的,因为它们的解构没有以各异的方式来使用那个类型参数。如通过加入一个成员到Empty<T>中,而对此示例进行修改,就可以反映出这一点:

interface NotEmpty<T> {
    data: T;
}

let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; //错误,x 与 y 不兼容

这种情况下,有着上面这种指定类型参数的泛型,与一个非通用类型的表现一致(In this way, a generic type that has its type arguments specified acts just like a non-generic type)。

对于没有指定类型参数的泛型,兼容性的检查,是通过在所有未指定类型参数的地方指定any进行的。随后对最终类型进行兼容性检查,就跟非通用类型一样(For generic types that do not have their type arguments specified, compatibility is checked by specifying any in place of all unspecified type arguments. Then resulting types are then checked for compatibility, just as in the non-generic case)。

比如,

let identity = function<T>(x: T): T {
    //...
}

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

identity = reverse; //没有问题,因为(x: any)=>any 与(y: any)=>any是匹配的

高级话题(Advanced Topics)

子类型与赋值语句(Subtype vs Assignment)

到目前为止,都使用的是“兼容性”一词,但这个说法在语言规格中并没有对其进行定义。在TypeScript中,兼容有两种:子类型与赋值。它们的不同仅在于赋值以允许赋值给与从any,以及赋值给及从有着对应的数字值的枚举,这两个规则,对子类型进行了拓展(In TypeScript, there are two kinds of compatibility: subtype and assignment. These differ only in that assignment extends subtype compatibility with rules to allow assignment to and from any and to and from enum with corresponding numeric values)。

根据不同情况,语言中的不同地方会使用这两种兼容性机制之一。实际来看,就算有着implementsextends关键字,类型兼容性仍按赋值兼容性看待(Different places in the language use one of the two compatibility mechanisms, depending on the situation. For practical purposes, type compatibility is dicated by assignment compatibility even in the cases of the implements and extends clauses)。更多信息,请查阅TypeScript规格。

Last change: 2023-03-28, commit: 4e70b88