這篇文章包含兩個部分:
A 部分:型別系統編譯器概述(包括 TypeScript)
- 語法 vs 語義
- 什麼是 AST?
- 編譯器的型別
- 語言編譯器是做什麼的?
- 語言編譯器是如何工作的?
- 型別系統編譯器職責
- 高階型別檢查器的功能
B 部分:構建我們自己的型別系統編譯器
- 解析器
- 檢查器
- 執行我們的編譯器
- 我們遺漏了什麼?
A 部分:型別系統編譯器概述
語法 vs 語義
語法和語義之間的區別對於早期的執行很重要。
語法 - Syntax
語法通常是指 JavaScript 本機程式碼。本質上是詢問給定的 JavaScript 程式碼在執行時是否正確。
例如,下面的語法是正確的:
var foo: number = "not a number";
語義 - Semantics
這是特定於型別系統的程式碼。本質上是詢問附加到程式碼中的給定型別是否正確。
例如,上面的程式碼在語法上是正確的,但在語義上是錯誤的(將變數定義為一個數字型別,但是值是一個字串)。
接下來是 JavaScript 生態系統中的 AST 和編譯器。
什麼是 AST?
在進一步討論之前,我們需要快速瞭解一下 JavaScript 編譯器中的一個重要機制 AST。
關於 AST 詳細介紹請看這篇文章。
AST 的意思是抽象語法樹 ,它是一個表示程式程式碼的節點樹。Node 是最小單元,基本上是一個具有 type
和 location
屬性的 POJO(即普通 JavaScript 物件)。所有節點都有這兩個屬性,但根據型別,它們也可以具有其他各種屬性。
在 AST 格式中,程式碼非常容易操作,因此可以執行新增、刪除甚至替換等操作。
例如下面這段程式碼:
function add(number) {
return number + 1;
}
將解析成以下 AST:
編譯器型別
在 JavaScript 生態系統中有兩種主要的編譯器型別:
1. 原生編譯器(Native compiler)
原生編譯器將程式碼轉換為可由伺服器或計算機執行的程式碼格式(即機器程式碼)。類似於 Java 生態系統中的編譯器 - 將程式碼轉換為位元組碼,然後將位元組碼轉換為本機程式碼。
2. 語言編譯器
語言編譯器扮演著不同的角色。TypeScript 和 Flow 的編譯器在將程式碼輸出到 JavaScript 時都算作語言編譯器。
語言編譯器與原生編譯器的主要區別在於,前者的編譯目的是 tooling-sake
(例如優化程式碼效能或新增附加功能),而不是為了生成機器程式碼。
語言編譯器是做什麼的?
在型別系統編譯器中,總結的兩個最基本的核心職責是:
1. 執行型別檢查
引入型別(通常是通過顯式註解或隱式推理),以及檢查一種型別是否匹配另一種型別的方法,例如 string
和 number
。
2. 執行語言伺服器
對於一個在開發環境中工作的型別系統(type system)來說,最好能在 IDE 中執行任何型別檢查,併為使用者提供即時反饋。
語言伺服器將型別系統連線到 IDE,它們可以在後臺執行編譯器,並在使用者儲存檔案時重新執行。流行的語言,如 TypeScript 和 Flow 都包含一個語言伺服器。
3. 程式碼轉換
許多型別系統包含原生 JavaScript 不支援的程式碼(例如不支援型別註解) ,因此它們必須將不受支援的 JavaScript 轉換為受支援的 JavaScript 程式碼。
關於程式碼轉換更詳細的介紹,可以參考原作者的這兩篇文章 Web Bundler 和 Source Maps。
語言編譯器是如何工作的?
對於大多數編譯器來說,在某種形式上有三個共同的階段。
1. 將原始碼解析為 AST
- 詞法分析 -> 將程式碼字串轉換為令牌流(即陣列)
- 語法分析 -> 將令牌流轉換為 AST 表示形式
解析器檢查給定程式碼的語法。型別系統必須有自己的解析器,通常包含數千行程式碼。
Babel 解析器 中的 2200+
行程式碼,僅用於處理 statement
語句(請參閱此處)。
Hegel
解析器將 typeAnnotation
屬性設定為具有型別註解的程式碼(可以在這裡看到)。
TypeScript 的解析器擁有 8900+
行程式碼(這裡是它開始遍歷樹的地方)。它包含了一個完整的 JavaScript 超集,所有這些都需要解析器來理解。
2. 在 AST 上轉換節點
- 操作 AST 節點
這裡將執行應用於 AST 的任何轉換。
3. 生成原始碼
- 將 AST 轉換為 JavaScript 原始碼字串
型別系統必須將任何非 js 相容的 AST 對映回原生 JavaScript。
型別系統如何處理這種情況呢?
型別系統編譯器(compiler)職責
除了上述步驟之外,型別系統編譯器通常還會在解析之後包括一個或兩個額外步驟,其中包括特定於型別的工作。
順便說一下,TypeScript 的編譯器實際上有 5
個階段,它們是:
- 語言服務前處理器 - Language server pre-processor
- 解析器 - Parser
- 結合器 - Binder
- 檢查器 - Checker
- 發射器 - Emitter
正如上面看到的,語言伺服器包含一個前處理器,它觸發型別編譯器只在已更改的檔案上執行。這會監聽任意的 import
語句,來確定還有哪些內容可能發生了更改,並且需要在下次重新執行時攜帶這些內容。
此外,編譯器只能重新處理 AST 結構中已更改的分支。關於更多 lazy compilation
,請參閱下文。
型別系統編譯器有兩個常見的職責:
1. 推導 - Inferring
對於沒有註解的程式碼需要進行推斷。關於這點,這裡推薦一篇關於何時使用型別註解和何時讓引擎使用推斷的文章。
使用預定義的演算法,引擎將計算給定變數或者函式的型別。
TypeScript 在其 Binding 階段(兩次語義傳遞中的第一次)中使用最佳公共型別演算法。它考慮每個候選型別並選擇與所有其他候選型別相容的型別。上下文型別在這裡起作用,也會做為最佳通用型別的候選型別。在這裡的 TypeScript 規範中有更多的幫助。
let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];
TypeScript 實際上引入了 Symbols
(interface)的概念,這些命名宣告將 AST 中的宣告節點與其他宣告進行連線,從而形成相同的實體。它們是 TypeScript 語義系統的基本構成。
2. 檢查 - Checking
現在型別推斷已經完成,型別已經分配,引擎可以執行它的型別檢查。他們檢查給定程式碼的 semantics
。這些型別的檢查有很多種,從型別錯誤匹配到型別不存在。
對於 TypeScript 來說,這是 Checker (第二個語義傳遞) ,它有 20000+
行程式碼。
我覺得這給出了一個非常強大的 idea
,即在如此多的不同場景中檢查如此多的不同型別是多麼的複雜和困難。
型別檢查器不依賴於呼叫程式碼,即如果一個檔案中的任何程式碼被執行(例如,在執行時)。型別檢查器將處理給定檔案中的每一行,並執行適當的檢查。
高階型別檢查器功能
由於這些概念的複雜性,我們今天不深入探討以下幾個概念:
懶編譯 - Lazy compilation
現代編譯的一個共同特徵是延遲載入。他們不會重新計算或重新編譯檔案或 AST 分支,除非絕對需要。
TypeScript 預處理程式可以使用快取在記憶體中的前一次執行的 AST 程式碼。這將大大提高效能,因為它只需要關注程式或節點樹的一小部分已更改的內容。
TypeScript 使用不可變的只讀資料結構,這些資料結構儲存在它所稱的 look aside tables
中。這樣很容易知道什麼已經改變,什麼沒有改變。
穩健性
在編譯時,有些操作編譯器不確定是安全的,必須等待執行時。每個編譯器都必須做出困難的選擇,以確定哪些內容將被包含,哪些不會被包含。TypeScript 有一些被稱為不健全的區域(即需要執行時型別檢查)。
我們不會在編譯器中討論上述特性,因為它們增加了額外的複雜性,對於我們的小 POC 來說不值得。
現在令人興奮的是,我們自己也要實現一個編譯器。
B 部分:構建我們自己的型別系統編譯器
我們將構建一個編譯器,它可以對三個不同的場景執行型別檢查,併為每個場景丟擲特定的資訊。
我們將其限制在三個場景中的原因是,我們可以關注每一個場景中的具體機制,並希望到最後能夠對如何引入更復雜的型別檢查有一個更好的構思。
我們將在編譯器中使用函式宣告和表示式(呼叫該函式)。
這些場景包括:
1. 字串與數字的型別匹配問題
fn("craig-string"); // throw with string vs number
function fn(a: number) {}
2. 使用未定義的未知型別
fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type
3. 使用程式碼中未定義的屬性名
interface Person {
name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}
實現我們的編譯器,需要兩部分:解析器和檢查器。
解析器 - Parser
前面提到,我們今天不會關注解析器。我們將遵循 Hegel
的解析方法,假設一個 typeAnnotation
物件已經附加到所有帶註解的 AST 節點中。我已經硬編碼了 AST 物件。
場景 1
將使用以下解析器:
字串與數字的型別匹配問題
function parser(code) {
// fn("craig-string");
const expressionAst = {
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "Identifier",
name: "fn"
},
arguments: [
{
type: "StringLiteral", // Parser "Inference" for type.
value: "craig-string"
}
]
}
};
// function fn(a: number) {}
const declarationAst = {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "fn"
},
params: [
{
type: "Identifier",
name: "a",
// 引數標識
typeAnnotation: {
// our only type annotation
type: "TypeAnnotation",
typeAnnotation: {
// 數字型別
type: "NumberTypeAnnotation"
}
}
}
],
body: {
type: "BlockStatement",
body: [] // "body" === block/line of code. Ours is empty
}
};
const programAst = {
type: "File",
program: {
type: "Program",
body: [expressionAst, declarationAst]
}
};
// normal AST except with typeAnnotations on
return programAst;
}
可以看到場景 1
中,第一行 fn("craig-string")
語句的 AST 對應 expressionAst
,第二行宣告函式的 AST 對應 declarationAst
。最後返回一個 programmast
,它是一個包含兩個 AST 塊的程式。
在AST中,您可以看到引數識別符號 a
上的 typeAnnotation
,與它在程式碼中的位置相匹配。
場景 2
將使用以下解析器:
使用未定義的未知型別
function parser(code) {
// fn("craig-string");
const expressionAst = {
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "Identifier",
name: "fn"
},
arguments: [
{
type: "StringLiteral", // Parser "Inference" for type.
value: "craig-string"
}
]
}
};
// function fn(a: made_up_type) {}
const declarationAst = {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "fn"
},
params: [
{
type: "Identifier",
name: "a",
typeAnnotation: {
// our only type annotation
type: "TypeAnnotation",
typeAnnotation: {
// 引數型別不同於場景 1
type: "made_up_type" // BREAKS
}
}
}
],
body: {
type: "BlockStatement",
body: [] // "body" === block/line of code. Ours is empty
}
};
const programAst = {
type: "File",
program: {
type: "Program",
body: [expressionAst, declarationAst]
}
};
// normal AST except with typeAnnotations on
return programAst;
}
場景 2
的解析器的表示式、宣告和程式 AST 塊非常類似於場景 1
。然而,區別在於 params
內部的 typeAnnotation
是 made_up_type
,而不是場景 1
中的 NumberTypeAnnotation
。
typeAnnotation: {
type: "made_up_type" // BREAKS
}
場景 3
使用以下解析器:
使用程式碼中未定義的屬性名
function parser(code) {
// interface Person {
// name: string;
// }
const interfaceAst = {
type: "InterfaceDeclaration",
id: {
type: "Identifier",
name: "Person",
},
body: {
type: "ObjectTypeAnnotation",
properties: [
{
type: "ObjectTypeProperty",
key: {
type: "Identifier",
name: "name",
},
kind: "init",
method: false,
value: {
type: "StringTypeAnnotation",
},
},
],
},
};
// fn({nam: "craig"});
const expressionAst = {
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "Identifier",
name: "fn",
},
arguments: [
{
type: "ObjectExpression",
properties: [
{
type: "ObjectProperty",
method: false,
key: {
type: "Identifier",
name: "nam",
},
value: {
type: "StringLiteral",
value: "craig",
},
},
],
},
],
},
};
// function fn(a: Person) {}
const declarationAst = {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "fn",
},
params: [
{
type: "Identifier",
name: "a",
//
typeAnnotation: {
type: "TypeAnnotation",
typeAnnotation: {
type: "GenericTypeAnnotation",
id: {
type: "Identifier",
name: "Person",
},
},
},
},
],
body: {
type: "BlockStatement",
body: [], // Empty function
},
};
const programAst = {
type: "File",
program: {
type: "Program",
body: [interfaceAst, expressionAst, declarationAst],
},
};
// normal AST except with typeAnnotations on
return programAst;
}
除了表示式、宣告和程式 AST 塊之外,還有一個 interfaceAst
塊,它負責儲存 InterfaceDeclaration
AST。
在declarationAst
塊的 typeAnnotation
節點上有一個 GenericType
,因為它接受一個物件識別符號,即 Person
。在這個場景中,programAst
將返回這三個物件的陣列。
解析器的相似性
從上面可以得知,這三種有共同點, 3
個場景中儲存所有的型別註解的主要區域是 declaration
。
檢查器
現在來看編譯器的型別檢查部分。
它需要遍歷所有程式主體的 AST 物件,並根據節點型別進行適當的型別檢查。我們將把所有錯誤新增到一個陣列中,並返回給呼叫者以便列印。
在我們進一步討論之前,對於每種型別,我們將使用的基本邏輯是:
- 函式宣告:檢查引數的型別是否有效,然後檢查函式體中的每個語句。
- 表示式:找到被呼叫的函式宣告,獲取宣告上的引數型別,然後獲取函式呼叫表示式傳入的引數型別,並進行比較。
程式碼
以下程式碼中包含 typeChecks
物件(和 errors
陣列) ,它將用於表示式檢查和基本的註解(annotation)檢查。
const errors = [];
// 註解型別
const ANNOTATED_TYPES = {
NumberTypeAnnotation: "number",
GenericTypeAnnotation: true
};
// 型別檢查的邏輯
const typeChecks = {
// 比較形參和實參的型別
expression: (declarationFullType, callerFullArg) => {
switch (declarationFullType.typeAnnotation.type) {
// 註解為 number 型別
case "NumberTypeAnnotation":
// 如果呼叫時傳入的是數字,返回 true
return callerFullArg.type === "NumericLiteral";
// 註解為通用型別
case "GenericTypeAnnotation": // non-native
// 如果是物件,檢查物件的屬性
if (callerFullArg.type === "ObjectExpression") {
// 獲取介面節點
const interfaceNode = ast.program.body.find(
node => node.type === "InterfaceDeclaration"
);
const properties = interfaceNode.body.properties;
//遍歷檢查呼叫時的每個屬性
properties.map((prop, index) => {
const name = prop.key.name;
const associatedName = callerFullArg.properties[index].key.name;
// 沒有匹配,將錯誤資訊存入 errors
if (name !== associatedName) {
errors.push(
`Property "${associatedName}" does not exist on interface "${interfaceNode.id.name}". Did you mean Property "${name}"?`
);
}
});
}
return true; // as already logged
}
},
annotationCheck: arg => {
return !!ANNOTATED_TYPES[arg];
}
};
讓我們來看一下程式碼,我們的 expression
有兩種型別的檢查:
- 對於
NumberTypeAnnotation;
呼叫時型別應為AnumericTeral
(即,如果註解為數字,則呼叫時型別應為數字)。場景1
將在此處失敗,但未記錄任何錯誤資訊。 - 對於
GenericTypeAnnotation;
如果是一個物件,我們將在 AST 中查詢InterfaceDeclaration
節點,然後檢查該介面上呼叫者的每個屬性。之後將所有錯誤資訊都會被存到errors
陣列中,場景3
將在這裡失敗並得到這個錯誤。
我們的處理僅限於這個檔案中,大多數型別檢查器都有作用域的概念,因此它們能夠確定宣告在執行時的準確位置。我們的工作更簡單,因為它只是一個 POC
。
以下程式碼包含程式體中每個節點型別的處理。這就是上面呼叫型別檢查邏輯的地方。
// Process program
ast.program.body.map(stnmt => {
switch (stnmt.type) {
case "FunctionDeclaration":
stnmt.params.map(arg => {
// Does arg has a type annotation?
if (arg.typeAnnotation) {
const argType = arg.typeAnnotation.typeAnnotation.type;
// Is type annotation valid
const isValid = typeChecks.annotationCheck(argType);
if (!isValid) {
errors.push(
`Type "${argType}" for argument "${arg.name}" does not exist`
);
}
}
});
// Process function "block" code here
stnmt.body.body.map(line => {
// Ours has none
});
return;
case "ExpressionStatement":
const functionCalled = stnmt.expression.callee.name;
const declationForName = ast.program.body.find(
node =>
node.type === "FunctionDeclaration" &&
node.id.name === functionCalled
);
// Get declaration
if (!declationForName) {
errors.push(`Function "${functionCalled}" does not exist`);
return;
}
// Array of arg-to-type. e.g. 0 = NumberTypeAnnotation
const argTypeMap = declationForName.params.map(param => {
if (param.typeAnnotation) {
return param.typeAnnotation;
}
});
// Check exp caller "arg type" with declaration "arg type"
stnmt.expression.arguments.map((arg, index) => {
const declarationType = argTypeMap[index].typeAnnotation.type;
const callerType = arg.type;
const callerValue = arg.value;
// Declaration annotation more important here
const isValid = typeChecks.expression(
argTypeMap[index], // declaration details
arg // caller details
);
if (!isValid) {
const annotatedType = ANNOTATED_TYPES[declarationType];
// Show values to user, more explanatory than types
errors.push(
`Type "${callerValue}" is incompatible with "${annotatedType}"`
);
}
});
return;
}
});
讓我們再次遍歷程式碼,按型別對其進行分解。
FunctionDeclaration (即 function hello(){}
)
首先處理 arguments/params
。如果找到型別註解,就檢查給定引數的型別 argType
是否存在。如果不進行錯誤處理,場景 2
會在這裡報錯誤。
之後處理函式體,但是我們知道沒有函式體需要處理,所以我把它留空了。
stnmt.body.body.map(line => {
// Ours has none
});
ExpressionStatement (即 hello()
)
首先檢查程式中函式的宣告。這就是作用域將應用於實際型別檢查器的地方。如果找不到宣告,就將錯誤資訊新增到 errors
陣列中。
接下來,我們針對呼叫時傳入的引數型別(實參型別)檢查每個已定義的引數型別。如果發現型別不匹配,則向 errors
陣列中新增一個錯誤。場景 1
和場景 2
在這裡都會報錯。
執行我們的編譯器
原始碼存放在這裡,該檔案一次性處理所有三個 AST 節點物件並記錄錯誤。
執行它時,我得到以下資訊:
總而言之:
場景 1:
fn("craig-string"); // throw with string vs number
function fn(a: number) {}
我們定義引數為 number 的型別,然後用字串呼叫它。
場景 2:
fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type
我們在函式引數上定義了一個不存在的型別,然後呼叫我們的函式,所以我們得到了兩個錯誤(一個是定義的錯誤型別,另一個是型別不匹配的錯誤)。
場景 3:
interface Person {
name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}
我們定義了一個介面,但是使用了一個名為 nam
的屬性,這個屬性不在物件上,錯誤提示我們是否要使用 name
。
我們遺漏了什麼?
如前所述,型別編譯器還有許多其他部分,我們在編譯器中省略了這些部分。其中包括:
- 解析器:我們是手動編寫的 AST 程式碼,它們實際上是在型別的編譯器上解析生成。
- 預處理/語言編譯器: 一個真正的編譯器具有插入 IDE 並在適當的時候重新執行的機制。
- 懶編譯:沒有關於更改或記憶體使用的資訊。
- 轉換:我們跳過了編譯器的最後一部分,也就是生成本機 JavaScript 程式碼的地方。
- 作用域:因為我們的 POC 是一個單一的檔案,它不需要理解作用域的概念,但是真正的編譯器必須始終知道上下文。
非常感謝您的閱讀和觀看,我從這項研究中瞭解了大量關於型別系統的知識,希望對您有所幫助。以上完整程式碼您可以在這裡找到。(給原作者 start)
備註:
原作者在原始碼中使用的 Node 模組方式為 ESM(ES Module),在將原始碼克隆到本地後,如果執行不成功,需要修改 start
指令,新增啟動引數 --experimental-modules
。
"start": "node --experimental-modules src/index.mjs",