# TypeScript - 控制流分析中的权衡 - RC ### 前言 我们在使用 TypeScript 的过程中,经常会写出形如这样的代码: ```ts declare function foo(): string | undefined; function bar () { let v1 = foo(); const v2 = foo(); if (!v1) return if (!v2) return let v3 = v1 return () => { v1.charAt(0) // error v2.charAt(0) // ok v3.charAt(0) // ok } } ``` 可以看到,在返回的 lambda 中,对 `v1`, `v2`, `v3` 属性的访问出现了不同的行为,而造成不同行为的原因,TypeScript 的作者 Anders 在一个 [issue](https://github.com/microsoft/TypeScript/issues/38755#issuecomment-633335959) 中进行了解答: >这是设计上的限制,当使用 `let` 或 `var` 声明局部变量时,控制流分析假设在创建引用变量的闭包后,还可能会对该变量进行赋值。因此,缩小后的类型可能不再正确。如果在声明中使用 `const`, 那么就可以知道接下来不会有任何赋值,并且可以安全的将缩小后的类型携带到闭包中。 > >从理论上讲,我们可以在控制流分析中进行更多工作,以确定在创建引用变量的函数闭包没有对特定变量进行赋值,但这并不是一件容易的事。 这其实还涉及到 TypeScript 控制流分析中的一些权衡。在 TypeScript 的 [issue](https://github.com/microsoft/TypeScript/issues/9998) 中有详细的描述。本文将与大家一同分享相关内容。 ### 策略 **核心问题**:当调用一个函数时,我们应该认为它的副作用是什么? 一种悲观的策略是重置所有缩小(narrowing)操作,假设任何函数都可能会修改它可达的任何对象。另一种策略是保持乐观,假设函数不会修改任何状态。这些策略都有一些问题。 这个问题主要涉及局部变量(locals)以及对象字段(object fields)。 ##### 乐观策略:局部变量的错误行为 TypeScript 编译器编译如下代码: ```ts enum Token { Alpha, Beta, Gamma } let token = Token.Alpha; function nextToken() { token = Token.Beta; } function maybeNextToken() { if (... something ...) { nextToken(); } } function doSomething() { if (token !== Token.Alpha) { maybeNextToken(); } // 是否可能为真? if (token === Token.Alpha) { // 一些操作 } } ``` 乐观的策略将认为 `token` 没有被 `maybeNextToken` 修改,并错误地认为 `token === Token.Alpha` 永远不成立。总之在一些情况下,这是一个不错的检查。 ##### 乐观策略:字段的错误行为 RWC 工具发现了一个这样的 “bug”: ```ts // 其他的一些函数 declare function tryDoSomething(x: string, result: { success: boolean; value: number; }): void; function myFunc(x: string) { let result = { success: false, value: 0 }; tryDoSomething(x, result); if (result.success === true) { // %% return result.value; } tryDoSomething(x.trim(), result); if (result.success === true) { // ?? return result.value; } return -1; } ``` 在用户的代码中,虽然 `??` 所在的一行并不是 bug,但是我们会认为它是。因为在 `%%` 这一行运行后 `result.success` 值域中唯一剩下的值为 `false`。 ##### 悲观策略:局部变量的错误行为 我们在伙伴的代码中发现了(很多)实际上的 bug: ```ts enum Kind { Good, Bad, Ugly } let kind: Kind = ...; function f() { if (kind) { log('Doing some work'); switch (kind) { case Kind.Good: // unreachable! } } } // 笔者注: 例如 function log (v: string) { kind = Kind.Ugly } ``` 这里我们检测到一个 bug,即 `case` 标签中的 `Kind.Good` (它等于假值 `0`)并不在 `kind` 的值域中。但是如果我们完全采用悲观策略的话,我们将不知道全局 `log` 函数并不会修改全局变量 `kind`,从而错误的放过这段有问题的代码。 ##### 悲观策略:字段的错误行为 1 Stackoverflow 上一个[关于 flowtype 的问题](https://stackoverflow.com/questions/38531182/weird-method-cannot-be-called-on-possibly-null-undefined-value)是一个很好的例子,有一个更小的例子来演示它: ```ts function fn(arg: { x: string | null }) { if (arg.x !== null) { alert('All is OK!'); // Flow: Not OK, arg.x could be null console.log(arg.x.substr(3)); } } ``` 问题在于在悲观策略中,可能发生如下情况: ```ts let a = { x: 'ok' }; function alert() { a.x = null; } fn(a); ``` ##### 悲观策略:字段的错误行为 2 TypeScript 编译器有一段看上去如下的代码(简化后): ```typescript function visitChildren(node: Node, visit: (node: Node) => void) { switch(node.kind) { case SyntaxKind.BinaryExpression: visit(node.left); visit(node.right); // Unsafe? break; case SyntaxKind.PropertyAccessExpression: visit(node.expr); visit(node.name); // Unsafe? break; } } // 笔者注: 例如 function visit (node: Node) { // ... node.kind = SyntaxKind.StringLiteral // ... } ``` 在这里,我们根据 `kind` 字段在联合类型中判断了 `Node` 的类型。悲观策略会认为第二次调用是不安全的,因为调用 visit 可能会通过间接引用修改 `node.kind` 从而使之前的判断无效。 ### 尝试 ##### 通过 inline(shallow)分析解决 Flow 为了改进这些错误的质量进行了一些赋值分析,但它显然缺少完整的内联解决方案,这几乎不可能实现。有关如何绕过这些分析的例子: ```ts // 非 null 赋值同样可以触发 null 警告 Non-null function fn(x: string | null) { function check1() { x = 'still OK'; } if (x !== null) { check1(); // Flow: Error, x could be null console.log(x.substr(0)); } } ``` ```ts // 内联只会进行一层分析 function fn(x: string | null) { function check1() { check2(); } function check2() { x = null; } if (x !== null) { check1(); // Flow: No error console.log(x.substr(0)); // crashes } } ``` ##### 通过 `const` 参数解决 一个易于实现的办法是允许 `const` 修饰符修饰参数,它可以快速的修复如下的代码: ```ts function fn(const x: string | number) { if (typeof x === 'string') { thisFunctionCannotMutateX(); x.substr(0); // ok } } ``` ##### 通过 `readonly` 字段解决 在上面的 `visitChildren` 示例中可以认为,即便存在中间函数调用的情况, `readonly` 字段也可以保留其类型缩小效果。从技术角度上说这是不可靠的,因为同一个属性可以同时拥有 `readonly` 和 非 `readonly` 的别名,但实际上这是很罕见的场景。 ##### 其他解决方案 被抛弃的 的方案 : - 函数上的 `pure` 修饰符,用于表示该函数不会修改任何东西。这有点不切实际,因为我们实际上希望在所有函数中都使用它。并且它并不能真正解决问题,因为许多函数只会修改一个东西,因此您可能更想要 `pure expect for m` (除了 `m` 之外 `pure`)。 - `volatile` 属性修饰符,用于表示 "这个属性将会被修改,并且没有其他通知"。我们不是 `C++`,并且这可能让您对在是否要使用它感到困惑。 ### 现状 基于 `RWC` 测试,Anders 只能找到一种情况,即额外的缩小会导致非预期的错误 [#9407](https://github.com/microsoft/TypeScript/pull/9407)。即使会存在很多真正的 bug 和前后矛盾的地方,例如:两次检查同样的值;假设值为`0` 的枚举通过了真值测试;`if` 和 `switch` 语句中出现了无效分支等。但总的来说,Anders 认为最佳的折衷方案是乐观的策略,即类型保护不受函数调用的影响。 顺便一说:编译器本身依赖于对解析器中 `token` 变量进行修改的副作用,例如: ```ts if (token === SyntaxKind.ExportKeyword) { nextToken(); if (token === SyntaxKind.DefaultKeyword) { // We have "export default" } ... } ``` 这在 [#9407](https://github.com/microsoft/TypeScript/pull/9407) 中成为了一个错误,因为编译器依然认为在调用 `nextToken` 之后, `token` 的值是 `SyntaxKind.ExportKeyword`,因此在将 `token` 与 `SyntaxKind.DefaultKeyword` 比较时报错。 我们将改为使用一个函数来获取当前 `token`: ```ts if (token() === SyntaxKind.ExportKeyword) { nextToken(); if (token() === SyntaxKind.DefaultKeyword) { // We have "export default" } ... } ``` 这个函数非常简单: ```ts function token(): SyntaxKind { return currentToken; } ``` 由于所有现代 `JavaScript` 虚拟机都内联了这样的简单函数,因此不会降低程序的性能。 Anders 认为这种通过使用函数访问可变状态来抑制类型缩小的模式是合理的。 ### 结语 TypeScript 中的控制流分析还有很多可以改进的地方,让我们共同期待将来的更新。 感谢阅读。