前言
我們在使用 TypeScript 的過程中,經常會寫出形如這樣的代碼:
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 中進行了解答:
這是設計上的限制,當使用或
let
聲明局部變量時,控制流分析假設在建立引用變量的閉包後,還可能會對該變量進行指派。是以,縮小後的類型可能不再正确。如果在聲明中使用
var
const
, 那麼就可以知道接下來不會有任何指派,并且可以安全的将縮小後的類型攜帶到閉包中。
從理論上講,我們可以在控制流分析中進行更多工作,以确定在建立引用變量的函數閉包沒有對特定變量進行指派,但這并不是一件容易的事。
這其實還涉及到 TypeScript 控制流分析中的一些權衡。在 TypeScript 的 issue 中有詳細的描述。本文将與大家一同分享相關内容。
政策
核心問題:當調用一個函數時,我們應該認為它的副作用是什麼?
一種悲觀的政策是重置所有縮小(narrowing)操作,假設任何函數都可能會修改它可達的任何對象。另一種政策是保持樂觀,假設函數不會修改任何狀态。這些政策都有一些問題。
這個問題主要涉及局部變量(locals)以及對象字段(object fields)。
樂觀政策:局部變量的錯誤行為
TypeScript 編譯器編譯如下代碼:
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”:
// 其他的一些函數
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:
enum Kind { Good, Bad, Ugly }
let kind: Kind = ...;
function f() {
if (kind) {
log('Doing some work');
switch (kind) {
case Kind.Good:
// unreachable!
}
}
}
這裡我們檢測到一個 bug,即
case
标簽中的
Kind.Good
(它等于假值
)并不在
kind
的值域中。但是如果我們完全采用悲觀政策的話,我們将不知道全局
log
函數并不會修改全局變量
kind
,進而錯誤的放過這段有問題的代碼。
悲觀政策:字段的錯誤行為 1
Stackoverflow 上一個關于 flowtype 的問題是一個很好的例子,有一個更小的例子來示範它:
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));
}
}
問題在于在悲觀政策中,可能發生如下情況:
let a = { x: 'ok' };
function alert() {
a.x = null;
}
fn(a);
悲觀政策:字段的錯誤行為 2
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 為了改進這些錯誤的品質進行了一些指派分析,但它顯然缺少完整的内聯解決方案,這幾乎不可能實作。有關如何繞過這些分析的例子:
// 非 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));
}
}
// 内聯隻會進行一層分析
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
一個易于實作的辦法是允許
const
修飾符修飾參數,它可以快速的修複如下的代碼:
function fn(const x: string | number) {
if (typeof x === 'string') {
thisFunctionCannotMutateX();
x.substr(0); // ok
}
}
通過 readonly
字段解決
readonly
在上面的
visitChildren
示例中可以認為,即便存在中間函數調用的情況,
readonly
字段也可以保留其類型縮小效果。從技術角度上說這是不可靠的,因為同一個屬性可以同時擁有
readonly
和 非
readonly
的别名,但實際上這是很罕見的場景。
其他解決方案
未被采用的方案 :
- 函數上的
修飾符,用于表示該函數不會修改任何東西。這有點不切實際,因為我們實際上希望在所有函數中都使用它。并且它并不能真正解決問題,因為許多函數隻會修改一個東西,是以您可能更想要pure
(除了pure expect for m
之外m
)。pure
-
屬性修飾符,用于表示 "這個屬性将會被修改,并且沒有其他通知"。我們不是volatile
,并且這可能讓您對在是否要使用它感到困惑。C++
現狀
基于
RWC
測試,Anders 隻能找到一種情況,即額外的縮小會導緻非預期的錯誤 #9407。即使會存在很多真正的 bug 和前後沖突的地方,例如:兩次檢查同樣的值;假設值為
的枚舉通過了真值測試;
if
和
switch
語句中出現了無效分支等。但總的來說,Anders 認為最佳的折衷方案是樂觀的政策,即類型保護不受函數調用的影響。
順便一說:編譯器本身依賴于對解析器中
token
變量進行修改的副作用,例如:
if (token === SyntaxKind.ExportKeyword) {
nextToken();
if (token === SyntaxKind.DefaultKeyword) {
// We have "export default"
}
...
}
這在 #9407 中成為了一個錯誤,因為編譯器依然認為在調用
nextToken
之後,
token
的值是
SyntaxKind.ExportKeyword
,是以在将
token
與
SyntaxKind.DefaultKeyword
比較時報錯。
我們将改為使用一個函數來擷取目前
token
:
if (token() === SyntaxKind.ExportKeyword) {
nextToken();
if (token() === SyntaxKind.DefaultKeyword) {
// We have "export default"
}
...
}
這個函數非常簡單:
function token(): SyntaxKind {
return currentToken;
}
由于所有現代
JavaScript
虛拟機都内聯了這樣的簡單函數,是以不會降低程式的性能。
Anders 認為這種通過使用函數通路可變狀态來抑制類型縮小的模式是合理的。
結語
TypeScript 中的控制流分析還有很多可以改進的地方,讓我們共同期待将來的更新。
感謝閱讀。