[译]TypeScript 的可赋值性

本文翻译自:TypeScript-New-Handbook # Assignability

基础

可赋值性是决定一个变量是否可以赋值给另一个变量的函数。编译器不仅会检查赋值和函数调用,还会检查返回语句。例如:

ts 复制代码
var x: number;
var y: number;
x = y;

function f(s: string): number {
  return s.length;
}
f(false);

这段代码有三个可赋值性检查:

  1. x = y,检查 y 是否可以赋值给 x
  2. return s.length,检查 s.length 是否可以从 f 返回。
  3. f(false),检查 false 是否可以赋值给 f 的第一个参数。

为了实现这一点,编译器会找出 xys.length 的类型,以及 f 的参数和返回类型。

这样我们就能做出以下判断:

  1. y: number 可赋值给 x: number
  2. s.length: number 可赋值给 f 的返回 :number
  3. false: boolean 不可赋值给 s: string

对于仅由原始类型组成的简单类型系统来说,可赋值性就是相等性:

ts 复制代码
function isAssignableTo(source: Type, target: Type): boolean {
  return source === target;
}

为简洁起见,本文使用运算符 ⟹ 来表示 isAssignableTo,如下所示:

number ⟹ number

事实上,编译器代码中的一个常见 bug 是应该用 做正确校验的地方使用了 ===。之所以说这是一个 bug,是因为 Typescript 除了支持原始类型外,还支持更多类型。很好的例子包括类和接口、联合和交叉、字面类型和枚举。这 3 对类型也就是本文其余部分的三个类别的示例:

  • 结构类型
  • 使用代数运算符创建的类型
  • 特例类型(字面和枚举)

注意,泛型是一个复杂的主题,既涉及结构类型,也涉及代数类型。

结构可赋值性

结构可赋值性是 Typescript 的一大特色,大多数语言都不具备这一功能。但它的代价很高:它是最后一种可赋值性检查,因为它非常缓慢和麻烦。当所有其他类型的可分配性都无法返回 true 时,结构可赋值性就会作为后备功能。

结构可赋性适用于任何具有属性或签名的东西:基本上包括类、接口和对象字面类型。如果没有其他比较方法,交叉类型也会尝试结构可赋值性。

比较本身并不复杂。它首先会检查目标中的每个属性是否都存在于源类型中。下面是一个失败的例子:

ts 复制代码
var source: { a, b };
var target: { a, b, c};
target = source;

{ a, b } ⟹ { a, b, c } 不为真,因为源没有名为 c 的属性。但是源类型可以有多余的属性,所以 { a, b, c } ⟹ { a, b } 为 true。

如果每个源属性都有一个匹配的目标属性,那么匹配属性会递归检查其类型是否可从源属性分配到目标属性:

ts 复制代码
var source: { a: number, b: string };
var target: { a: number };
target = source;

在这里,当检查 a 时,可赋值性会进行递归调用,以检查 number 是否可赋值给 number

{ a: number, b: string } ⟹ { a: number }
number ⟹ number

当然,第二个检查一旦进入 === 快速通道,就会返回 true。但其他类型在成功或失败前可能会重复多次:

ts 复制代码
var source: { a: { b: { c: null } } };
var target: { a: { b: { c: string } } };
target = source;

调用、构造和索引签名的工作原理都类似:

ts 复制代码
var source: (a: number, b: unknown) => boolean;
var target: (a: never, b: string) => void;
target = source;

只不过签名检查的是正确的参数数量,而不是缺失的属性,并且遍历的是参数而不是属性。此外,参数以逆变的方式进行比较,也就是源和目标的方向是相反的。这是因为当一个回调被赋值给另一个回调变量时,参数 实际上并没有被赋值。相反,是一个可调用变量被赋值给另一个可调用变量。下面是一个例子,对于 (a: unknown) => void ⟹ (a: string) => void 实际为 string ⟹ unknown 。例如:

ts 复制代码
var source: string;
var target: unknown;
target = source; // fine, target can be anything, including a string

var source: (a: string) => void;
var target: (a: unknown) => void;
target = source; // should be an error, because:
target(1); // oops, can't pass numbers to source

继承

有了结构可赋值性,就不需要特殊规则来处理继承问题。可赋值性只需检查派生类是否可赋值给基类即可。接口和实现的检查也一样。换句话说,对于这样的类:

ts 复制代码
class Base {
  constructor(public b: number) {
  }
}
class Derived extends Base {
  b = 12
  constructor(public d: string) {
    super(this.b);
  }
}

声明使用这些类型的变量:

ts 复制代码
var derived: Derived;
var base: Base;
base = derived;

然后像这样比较:

ts 复制代码
var derived: { b: number, d: string };
var base: { b: number };
base = derived;

DerivedBase 之间没有继承联系。当然,可赋值性检查会缓存其结果,因此一旦编译器知道 Derived 可赋值给 Base,就不必再运行昂贵的检查了。事实上,只要编译器看到 class Derived extends Base,这种检查就会立即运行,因此结构化方法的成本最终与基于声明的方法差不多。

代数可赋值性

具有代数类型运算符的类型可以代数地进行检查。有时这种检查只是比结构检查快,但如果类型包含类型变量,代数检查可以在结构检查失败的地方成功。例如:

ts 复制代码
function f<T>(source: T | never, target: T) {
   target = source;
}

检查器知道 never 不会改变它所在的联合,所以它可以消除它并递归地使用一个更小的类型:

T | never ⟹ T
T ⟹ T

由于 T === T,所以 T | never ⟹ T 为 true。

有些代数关系非常明显。例如,当一个联合为目标时,当源可赋值给联合的任何元素时,它就是可赋值的:

B ⟹ A | B | C
B ⟹ B

但是,当一个联合是源时,每个元素都需要可赋值给目标。所以一般来说,A | B | C ⟹ B 并不成立。只有当目标是源的所有元素的超类型时,它才是正确的。一个例子是字面量类型:'x' | 'y' | 'z' ⟹ string

还有更复杂的关系。例如,交叉类型和联合类型混合:

A & (B | C) ⟹ (A & B) | (A & C)
(A & B) | (A & C) ⟹ (A & B) | (A & C)

映射类型、索引访问类型和 keyof 类型也以多种方式相互作用。首先,如果 B 可赋值给 A,那么 keyof A 也可赋值给 keyof B

keyof A ⟹ keyof B
B ⟹ A

给出 AB 的示例类型来说明为什么方向会颠倒:

A = { a, b }
B = { a, b, c }

在这里,B 多了一个属性 c,因此可以将 B 赋值给属性较少的 A。但这并不意味着可以将 B 的键赋值给 A 的键。也就是 "a" | "b" | "c" ⟹ "a" | "b",对 "c" 来说是失败的。然而,"a" | "b" ⟹ "a" | "b" | "c" 是正确的,这与 keyof A ⟹ keyof B 完全相同。

对于映射类型,任何类型 T 都可以分配给恒等映射:

T ⟹ { [P in X]: T[P] }

这是从映射类型的定义中得出的。请注意,反之则不成立,因为映射类型会丢失一些信息。

最后,有些复杂关系涉及所有三种类型。例如,对于某个键类型 K 和重写类型 X,如果 T 的键可赋值给 KX 可赋值给 T 的所有属性:{ [P in K]:X } 是可赋值给 T 的,前提是 T 的键可赋值给 K,且 X 可赋值给 T 的所有属性:

{ [P in K]: X } ⟹ T
keyof T ⟹ K and X ⟹ T[K]

反之亦然:

T ⟹ { [P in K]: X }
K ⟹ keyof T and T[K] ⟹ X

举例说明这一规则的合理性:

T = { a: C, b: D, c: C | D }
K = "a" | "b"
X = C | D

T ⟹ { [P in K]: X }
{ a: C, b: D, c: C | D } ⟹ { [P in "a" | "b"]: C | D }
{ a: C, b: D, c: C | D } ⟹ { "a": C | D, "b": C | D }

当以下两个条件都成立时,上述关系就是真的:

K ⟹ keyof T
"a" | "b" ⟹ "a" | "b" | "c"

T[K] ⟹ X
T["a" | "b"] ⟹ C | D
C | D ⟹ C | D

特殊情况

在简单的相等和结构比较之间,Typescript 有许多特殊情况可以快速处理特定类型。

  1. 单位类型(Unit types)和原始类型
  2. 过量属性和弱类型
  3. 递归截断

译者注:单位类型,Unit types,表示只能分配一个值的类型,比如

ts 复制代码
type Name = "John";
type Zero = 0;
type True = true;
enum Color {Red, Green, Blue}
type Red = Color.Red;

简单类型

  1. 单位和原始类型:原始类型和单位类型都通过一长串特定规则进行比较。因为所有这些类型都用位标志进行标记所以比较非常快,典型的代码行看起来像这样:
ts 复制代码
if (source.flags & TypeFlags.StringLike && target.flags & TypeFlags.String) return true;

有一些规则因为历史限制而显得有些奇怪。例如,任何数字都可以赋值给数字枚举,但这对于字符串枚举来说并不成立。只有已知是字符串枚举的一部分的字符串才可以赋值给它。这是因为数字枚举在联合类型和字面量类型之前就存在了,所以它们的规则最初是比较宽松的。

以下是用简单规则进行比较的类型:

  • string
  • number
  • boolean
  • symbol
  • object
  • any
  • void
  • null
  • undefined
  • never
  • string enums
  • number enums
  • string literals
  • number literals
  • boolean literals

要注意,源和目标类型都必须是简单的,简单比较才能成功。如果源是一个字符串,而目标是某种对象类型,那么编译器就不得不使用结构比较法,并且必须 首先 获取字符串的所有方法和属性。

  1. 多余属性和弱类型检查

多余属性检查和弱类型检查紧随简单类型检查之后运行。它们之所以运行得这么早,是因为虽然它们的成本很高,但如果它们能在早期阶段就发现不符合可赋值的情况,它就比进行完整的结构比较节省了时间。

多余属性检查仅适用于对象字面量类型。其唯一目的是使其他情况合法的赋值失败。在结构可赋值中,有多余的属性是没有问题的,所以 { a, b } ⟹ { a }。然而,当作者将一个对象字面量赋值给一个类型为 { a } 的变量时,他可能不太想包含除了 a 之外的其他属性。因此多余属性检查检查会禁止这种赋值。

弱类型检查只有在源只包含可选属性的情况下才适用。在结构赋值性中,任何类型都可以赋值给弱类型。所有这些赋值在结构上都是合理的:

{ a } ⟹ { a?, b? }
{ a, b, e } ⟹ { a?, b? }
{ c, d } ⟹ { a?, b? }
{ } ⟹ { a?, b? }

然而,只有前两个赋值在某种程度上是有意义的,因此弱类型检查要求源与弱目标至少共享一个属性。

结构截断

结构可赋性在处理递归类型时会遇到麻烦。具体来说,对于这样的类:

ts 复制代码
declare class Box<T> {
  t: T
  m(b: Box<T>): void
}
var source = new Box<string>();
var target = new Box<unknown>();
target = source;

要知道目标是否可分配给源:

Box<string> ⟹ Box<unknown>
{ t: string, m(b: Box<string>): void } ⟹ { t: unknown, m(b: Box<unknown>): void }

现在,结构可赋值性必须证明 tm 的类型是可赋值的,其中 t 很简单:

string ⟹ unknown

m 遇到了问题:

(b: Box<string>) => void ⟹ (b: Box<unknown>) => void
Box<string> ⟹ Box<unknown>
{ t: string, m(b: Box<string>): void } ⟹ { t: unknown, m(b: Box<unknown>): void }

这看起来就像一个无限循环。

实际上,对于这个问题,Typescript 有两种解决方案。最简单的是注意到 Box === Box 将类型参数视为代数关系。然后把 Box<T> ⟹ Box<U> 简化为 T ⟹ U。这比使用结构赋值能力来决定 Box<T>.t 是否可赋值给 Box<U>.t 要快得多。

Box<string> ⟹ Box<unknown>
string ⟹ unknown

这种方法一般可以奏效,因为大多数情况下,即使是在结构类型系统中,人们也会编写名义代码(nominal code)。但是,如果有人带着一个类似的类出现,而他们刚刚更新了这个类,以便与 Box 一起使用,那该怎么办呢?

译者注

在编程语言中,类型系统通常被分为两种:名义类型(Nominal Typing)和结构类型(Structural Typing)。

Nominal Typing,也被称为名义类型系统或标签类型系统,是一种类型检查机制,它要求两个变量或对象必须显式地声明为相同的类型,才能被认为是相同的类型。即使两个对象的结构完全相同,如果它们的类型标签(或称为类型名)不同,那么它们就被认为是不同的类型。

例如,在 Java(一种使用名义类型系统的语言)中,即使两个类的字段和方法完全相同,如果它们的类名不同,那么它们就被认为是不同的类型。

Nominal Code,即名义代码,通常指的是在名义类型系统中编写的代码。

相对的,Structural Typing 是一种类型检查机制,它只关注对象的结构,只要两个对象的结构相同(即它们有相同的属性和方法),那么它们就被认为是相同的类型,无论它们的类型标签是否相同。TypeScript 就是一种使用结构类型系统的语言。

ts 复制代码
declare class Ref<T> {
  private item: T
  deref(): T
  // 为了兼容 Box:
  readonly t: T
  m(b: Ref<T>): void
}

现在,如果源是 Ref<string>,目标是 Box<unknown>,编译器就必须从结构上对整个代码进行比较。一切都很顺利,直到编译器再次尝试检查 m

Ref<string> ⟹ Box<unknown>

...
(b: Ref<string>) => void ⟹ (b: Box<unknown>) => void
Ref<string> ⟹ Box<unknown>

尽管 Ref !== Box,但这是 Ref<string> ⟹ Box<unknown> 的第二次出现,这意味着第二次递归检查不会提供任何新信息来证明或反驳 Ref<string> ⟹ Box<unknown>。然而,结构可赋值性返回的不是 True,而是三元值 Maybe(真、假和未确定)。如果所有非 Maybe 的结果都为真,则 Maybe 结果变为真。否则,它将保持 Maybe。

这就使得 Ref<string> ⟹ Box<unknown> 成功,因为 Ref.t 事实上可赋值给 Box.t,而 itemderef 对于赋值并不重要。

不过,仍然 有一些类型是这两种检查无法捕捉到的。具体来说

ts 复制代码
declare class Functor<T> {
  fmap<U>(f: (t: T) => U): Functor<U>;
}
declare class Mappable<T> {
  fmap<U>(f: (t: T) => U): Mappable<U>;
}

在这种情况下,对于 Functor<string> ⟹ Mappable<unknown>,可赋值性在检查 fmap 的返回类型时必须检查 Functor<U> ⟹ Mappable<U>。一旦它开始检查 Functor<U> ⟹ Mappable<U>,它就会陷入困境:对 fmap 的每次递归检查都会创建一个新的 U,因此它必须为这个新的 U 检查 Functor<U> ⟹ Mappable<U>

为了防止出现这种情况,结构可赋值性在比较相同的类型对(即使它们的参数是不同的)时有一个任意的 5 深度截断。也就是说,如果可赋值性检查遇到 (Functor, Mappable) 类型对 5 次,那么该特定比较将返回 Maybe。

总结

为了按照从最简单到最复杂的顺序介绍概念,本文介绍的可赋值性检查部分的顺序与实际出现的顺序不同。实际算法如下:

  1. 如果源类型等于目标类型,返回真。
  2. 如果源类型和目标类型都是简单的并且可以赋值,返回真。
  3. 如果源类型是对象类型并且目标类型有多余的属性,返回假。
  4. 如果源类型是弱类型并且目标类型没有共享的属性,返回假。
  5. 如果源类型或目标类型是代数类型,尽可能简化类型并递归。
  6. 如果源类型可以结构化地赋值给目标类型,返回真。
  7. 否则,返回假。

如前一节所解释的,第6步可能返回"Maybe",这被视为真。

这份文档也跳过了一些小细节,比如如何处理私有属性。要查看实际的代码,可以在 GitHub 上的 TypeScript 仓库中的 src/compiler/checker.ts 文件中查看 checkTypeRelatedTo 函数。

最后,它没有涵盖其他关系,如子类型或可比较性,因为它们都是赋值性的变体。

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch7 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光7 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   7 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   7 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web8 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery