TypeScript之类型兼容性

类型兼容性

介绍

TypeScript里的类型兼容性是基于结构子类型的。结构类型是一种只使用其成员来描述类型的方式。它正好与名义类型形成对比。(译者注:在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。)看下面的例子:

interface Named {
    name: string;
}

class Person {
    name: string;
}

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

在使用基于名义类型的语言,比如c#或Java中,这段代码会报错,因为Person类没有明确说明其实现了Named接口。

TypeScript的结构性子类型是根据JavaScript代码的经典写法来设计的。因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好

关于可靠性的注意事项

TypeScript的类型系统允许某些在编译阶段无法确认其安全性的操作。当一个类型系统具此属性时,被当做是“不可靠”的。TypeScript允许这种不可靠行为的发生是经过仔细考虑的。通过这篇文章,我们会解释什么时候会发生这种情况和其有利的一面。

开始

TypeScript结构化类型系统的基本规则是,如果x要兼容y,那么y至少具有与x相同的属性。比如:

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必须包含名字是name的string类型成员。y满足条件,因此赋值正确

检查函数参数时使用相同的规则:

function greet (n: Named) {
    console.log("Hello" + n.name);
}
greet(y); //okay

注意,y有个额外的location属性,但这不会引发错误。只有目标类型(这里是Named) 的成员会被一一检查是否兼容。

这个比较过程是递归进行的,检查每个成员及子成员

比较两个函数

相对来讲,在比较原始类型和对象类型的时候是比较容易理解的,问题是如何判断两个函数是兼容的。下面我们从两个简单的函数入手,它们仅是参数列表略有不同:

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

y = x; // ok
x = y; // error

要查看x是否能赋值给y,首先看它们的参数列表。x的每个参数必须能在y里找到对应类型的参数。注意的是参数的名字相同与否无所谓,只看它们的类型。这里,x 的每个参数在 y 中都能找到对应的参数,所以允许赋值。

第二个赋值错误,因为y有个必须的第二参数,但是x 没有,所以不允许赋值。

你可能会疑惑为什么允许忽略参数,像例子 y = x 中那样。原因是忽略额外的参数在JavaScript中是很常见的。 例如,Array#forEach给回调函数传3个参数:数组元素,索引和整个数组。 尽管如此,传入一个只使用第一个参数的回调函数也是很有用的:

let items= [1,2,3];
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; // ok
y = x; // error,because x() lacks a location property

类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型。

函数参数双向协变

当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。实际上,这几少会发生错误,并且能够实现很多JavaScript里的常见模式。例如:

// 定义事件类型的枚举
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) {
    /* ... */
}

// 非严格类型检查,但是很有用,也常见
// 这里,我们把一个接受 MouseEvent 的函数作为参数传给了 listenEvent 函数
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));

// 当你需要严格的类型检查时,会有一些不那么理想的替代方案。
// 这两种方法都需要对 e 进行类型断言,以便在 TypeScript 中使用 MouseEvent 的属性
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)));

// 因为 number 类型和 Event 类型完全不兼容,所以下面的代码将会报错
listenEvent(EventType.Mouse, (e: number) => console.log(e));
 
// 这就是 TypeScript 的类型系统保证了类型安全性,对完全不兼容的类型,它将严格执行类型检查。

可选参数及剩余参数

比较函数兼容性的时候,可选参数与必须参数是可互换的。源类型上有额外的可选参数不是错误,目标类型的可选参数在源类型里没有对应的参数也不是错误。

当一个函数有剩余参数时,它被当做无限个可选参数。 这对于类型系统来说是不稳定的,但从运行时的角度来看,可选参数一般来说是不强制的,因为对于大多数函数来说相当于传递了一些undefined。

有一个好的例子,常见的函数接收一个回调函数并用对于程序员来说是可预知的参数但对类型系统来说是不确定的参数来调用:

// 定义了一个名为 invokeLater 的函数,此函数接受两个参数,一个是任意类型的数组,另一个是回调函数
// 回调函数的参数也是一个任意类型的数组
function invokeLater(args: any[], callback: (...args: any[]) => void) {
    /* ... 使用 'args' 调用回调函数 ... */
}

// 这个调用可能是不安全的 - invokeLater "可能"提供任意数量的参数
invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));

// 这个调用可能造成混淆 (x 和 y 其实是必须的) 而且很难发现问题
invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

函数重载

对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。这确保了目标函数可以在所有源函数可调用的地方调用。

枚举

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同的枚举类型之间是不兼容的。比如:

enum Status {Ready, waiting};
enum Color {Red, Bule, Green };;

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

类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。比较这两个类类型的对象时,只有实例的成员会被比较。静态成员和构造函数不在比较的范围内。

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是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型,比如:

interface Empty<T>{
    
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // ok,because y matches structure of x

上面代码里,x和y是兼容的,因为它们使用结构类型参数时并没有什么不同。把这个例子改变一下,增加一个成员就能看出是如何工作的了:

interface NotEmpty<T> {
    data: T;
}
let x: NotEmpty<number>
let y: NotEmpty<string>
    x = y; // error,because x and y are not compatible

在这里,泛型类型在使用时就好比不是一个泛型类型

对于没指定泛型类型的泛型参数时,会把所有泛型参数当成any比较。然后用结果类型进行比较,就像上面第一个例子。比如:

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来回赋值,以及enum和对应数字值之间的来回赋值。

语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在implementsextends语句也不例外。

在 TypeScript 中,子类型和赋值的兼容性确实有所不同。子类型通常是基于严格的类型兼容性,对于某种类型的变量,我们只能赋予它该类型的值或该类型的子类型的值。

然而,赋值兼容性更为宽松。它允许我们在不同类型之间进行来回赋值,只要这种赋值在逻辑上是合理的。例如,我们可以把枚举类型的值赋给数字类型的变量,反之也可以。但这并不意味着枚举类型是数字类型的子类型。

正如你所说的,在 TypeScript 中,不仅 any 类型的变量可以接受任何类型的值,而且在 implementsextends 这样的关键字中,也遵循了赋值兼容性。

这是因为 TypeScript 在设计时,主要考虑的是如何使开发者可以在保证类型安全的同时,还能享受 JavaScript 的灵活性。因此,TypeScript 接受了一些 JavaScript 中常见但是在静态类型语言中不被接受的模式,比如 any 类型和赋值兼容性。

将赋值兼容性应用于整个语言(包括 implementsextends 关键字)是 TypeScript 技术设计的一部分,旨在将静态类型的严谨性和 JavaScript 的灵活性结合起来,为开发者提供更好的开发体验。

  • 8
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值