- 原文地址:What is an Abstract Syntax Tree
- 原文作者:Chidume Nnamdi
AST 是抽象語法樹的縮寫詞,表示程式語言的語句和表示式中生成的 token。有了 AST,直譯器或編譯器就可以生成機器碼或者對一條指令求值。
小貼士: 通過使用 Bit,你可以將任意的 JS 程式碼轉換為一個可在專案和應用中共享、使用和同步的 API,從而更快地構建並重用更多程式碼。試一下吧。
假設我們有下面這條簡單的表示式:
1 + 2
用 AST 來表示的話,它是這樣的:
+ BinaryExpression
- type: +
- left_value:
LiteralExpr:
value: 1
- right_vaue:
LiteralExpr:
value: 2
諸如 if
的語句則可以像下面這樣表示:
if(2 > 6) {
var d = 90
console.log(d)
}
IfStatement
- condition
+ BinaryExpression
- type: >
- left_value: 2
- right_value: 6
- body
[
- Assign
- left: 'd';
- right:
LiteralExpr:
- value: 90
- MethodCall:
- instanceName: console
- methodName: log
- args: [
]
]
這告訴直譯器如何解釋語句,同時告訴編譯器如何生成語句對應的程式碼。
看看這條表示式: 1 + 2
。我們的大腦判定這是一個將左值和右值相加的加法運算。現在,為了讓計算機像我們的大腦那樣工作,我們必須以類似於大腦看待它的形式來表示它。
我們用一個類來表示,其中的屬性告訴直譯器運算的全部內容、左值和右值。因為一個二元運算涉及兩個值,所以我們給這個類命名為 Binary
:
class Binary {
constructor(left, operator, right) {
this.left = left
this.operator = operator
this.right = right
}
}
例項化期間,我們將會把 1
傳給第一個屬性,把 ADD
傳給第二個屬性,把 2
傳給第三個屬性:
new Binary('1', 'ADD', '2')
當我們把它傳遞給直譯器的時候,直譯器認為這是一個二元運算,接著檢查操作符,認為這是一個加法運算,緊接著繼續請求例項中的 left
值和 right
值,並將二者相加:
const binExpr = new Binary('1', 'ADD', '2')
if(binExpr.operator == 'ADD') {
return binExpr.left + binExpr.right
}
// 返回 `3`
看,AST 可以像大腦那樣執行表示式和語句。
單數字、字串、布林值等都是表示式,它們可以在 AST 中表示並求值。
23343
false
true
"nnamdi"
拿 1 舉例:
1
我們在 AST 的 Literal(字面量) 類中來表示它。一個字面量就是一個單詞或者數字,Literal 類用一個屬性來儲存它:
class Literal {
constructor(value) {
this.value = value
}
}
我們可以像下面這樣表示 Literal 中的 1:
new Literal(1)
當直譯器對它求值時,它會請求 Literal 例項中 value
屬性的值:
const oneLit = new Literal(1)
oneLit.value
// `1`
在我們的二元表示式中,我們直接傳遞了值
new Binary('1', 'ADD', '2')
這其實並不合理。因為正如我們在上面看到的,1
和 2
都是一條表示式,一條基本的表示式。作為字面量,它們同樣需要被求值,並且用 Literal 類來表示。
const oneLit = new Literal('1')
const twoLit = new Literal('2')
因此,二元表示式會將 oneLit
和 twoLit
分別作為左屬性和右屬性。
// ...
new Binary(oneLit, 'ADD', twoLit)
在求值階段,左屬性和右屬性同樣需要進行求值,以獲得各自的值:
const oneLit = new Literal('1')
const twoLit = new Literal('2')
const binExpr = new Binary(oneLit, 'ADD', twoLit)
if(binExpr.operator == 'ADD') {
return binExpr.left.value + binExpr.right.value
}
// 返回 `3`
其它語句在 AST 中也大多是用二元來表示的,例如 if 語句。
我們知道,在 if 語句中,只有條件為真的時候程式碼塊才會執行。
if(9 > 7) {
log('Yay!!')
}
上面的 if 語句中,程式碼塊執行的條件是 9
必須大於 7
,之後我們可以在終端上看到輸出 Yay!!
。
為了讓直譯器或者編譯器這樣執行,我們將會在一個包含 condition
、 body
屬性的類中來表示它。condition
儲存著解析後必須為真的條件,body
則是一個陣列,它包含著 if 程式碼塊中的所有語句。直譯器將會遍歷該陣列並執行裡面的語句。
class IfStmt {
constructor(condition, body) {
this.condition = condition
this.body = body
}
}
現在,讓我們在 IfStmt 類中表示下面的語句
if(9 > 7) {
log('Yay!!')
}
條件是一個二元運算,這將表示為:
const cond = new Binary(new Literal(9), "GREATER", new Literal(7))
就像之前一樣,但願你還記得?這回是一個 GREATER 運算。
if 語句的程式碼塊只有一條語句:一個函式呼叫。函式呼叫同樣可以在一個類中表示,它包含的屬性有:用於指代所呼叫函式的 name
以及用於表示傳遞的引數的 args
:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
}
因此,log("Yay!!") 呼叫可以表示為:
const logFuncCall = new FuncCall('log', [])
現在,把這些組合在一起,我們的 if 語句就可以表示為:
const cond = new Binary(new Literal(9), "GREATER", new Literal(7));
const logFuncCall = new FuncCall('log', []);
const ifStmt = new IfStmt(cond, [
logFuncCall
])
直譯器可以像下面這樣解釋 if 語句:
const ifStmt = new IfStmt(cond, [
logFuncCall
])
function interpretIfStatement(ifStmt) {
if(evalExpr(ifStmt.conditon)) {
for(const stmt of ifStmt.body) {
evalStmt(stmt)
}
}
}
interpretIfStatement(ifStmt)
輸出:
Yay!!
因為 9 > 7
:)
我們通過檢查 condition
解析後是否為真來解釋 if 語句。如果為真,我們遍歷 body
陣列並執行裡面的語句。
執行 AST
使用訪問者模式對 AST 進行求值。訪問者模式是設計模式的一種,允許一組物件的演算法在一個地方實現。
ASTs,Literal,Binary,IfStmnt 是一組相關的類,每一個類都需要攜帶方法以使直譯器獲得它們的值或者對它們求值。
訪問者模式讓我們能夠建立單個類,並在類中編寫 AST 的實現,將類提供給 AST。每個 AST 都有一個公有的方法,直譯器會通過實現類例項對其進行呼叫,之後 AST 類將在傳入的實現類中呼叫相應的方法,從而計算其 AST。
class Literal {
constructor(value) {
this.value = value
}
visit(visitor) {
return visitor.visitLiteral(this)
}
}
class Binary {
constructor(left, operator, right) {
this.left = left
this.operator = operator
this.right = right
}
visit(visitor) {
return visitor.visitBinary(this)
}
}
看,AST Literal 和 Binary 都有訪問方法,但是在方法裡面,它們呼叫訪問者例項的方法來對自身求值。Literal 呼叫 visitLiteral,Binary 則呼叫 visitBinary
。
現在,將 Vistor 作為實現類,它將實現 visitLiteral 和 visitBinary 方法:
class Visitor {
visitBinary(binExpr) {
// ...
log('not yet implemented')
}
visitLiteral(litExpr) {
// ...
log('not yet implemented')
}
}
visitBinary 和 visitLiteral 在 Vistor 類中將會有自己的實現。因此,當一個直譯器想要解釋一個二元表示式時,它將呼叫二元表示式的訪問方法,並傳遞 Vistor 類的例項:
const binExpr = new Binary(...)
const visitor = new Visitor()
binExpr.visit(visitor)
訪問方法將呼叫訪問者的 visitBinary,並將其傳遞給方法,之後列印 not yet implemented
。這稱為雙重分派。
- 呼叫
Binary
的訪問方法。 - 它 (
Binary
) 反過來呼叫Visitor
例項的visitBinary
。
我們把 visitLiteral 的完整程式碼寫一下。由於 Literal 例項的 value 屬性儲存著值,所以這裡只需返回這個值就好:
class Visitor {
visitBinary(binExpr) {
// ...
log('not yet implemented')
}
visitLiteral(litExpr) {
return litExpr.value
}
}
對於 visitBinary,我們知道 Binary 類有操作符、左屬性和右屬性。操作符表示將對左右屬性進行的操作。我們可以編寫實現如下:
class Visitor {
visitBinary(binExpr) {
switch(binExpr.operator) {
case 'ADD':
// ...
}
}
visitLiteral(litExpr) {
return litExpr.value
}
}
注意,左值和右值都是表示式,可能是字面量表示式、二元表示式、呼叫表示式或者其它的表示式。我們並不能確保二元運算的左右兩邊總是字面量。每一個表示式必須有一個用於對錶達式求值的訪問方法,因此在上面的 visitBinary 方法中,我們通過呼叫各自對應的 visit
方法對 Binary 的左屬性和右屬性進行求值:
class Visitor {
visitBinary(binExpr) {
switch(binExpr.operator) {
case 'ADD':
return binExpr.left.visit(this) + binExpr.right.visit(this)
}
}
visitLiteral(litExpr) {
return litExpr.value
}
}
因此,無論左值和右值儲存的是哪一種表示式,最後都可以進行傳遞。
因此,如果我們有下面這些語句:
const oneLit = new Literal('1')
const twoLit = new Literal('2')
const binExpr = new Binary(oneLit, 'ADD', twoLit)
const visitor = new Visitor()
binExpr.visit(visitor)
在這種情況下,二元運算儲存的是字面量。
訪問者的 visitBinary
將會被呼叫,同時將 binExpr 傳入,在 Vistor 類中,visitBinary
將 oneLit 作為左值,將 twoLit 作為右值。由於 oneLit 和 twoLit 都是 Literal 的例項,因此它們的訪問方法會被呼叫,同時將 Visitor 類傳入。對於 oneLit,其 Literal 類內部又會呼叫 Vistor 類的 visitLiteral 方法,並將 oneLit
傳入,而 Vistor 中的 visitLiteral 方法返回 Literal 類的 value 屬性,也就是 1
。同理,對於 twoLit 來說,返回的是 2
。
因為執行了 switch 語句中的 case 'ADD'
,所以返回的值會相加,最後返回 3。
如果我們將 binExpr.visit(visitor)
傳給 console.log
,它將會列印 3
console.log(binExpr.visit(visitor))
// 3
如下,我們傳遞一個 3 分支的二元運算:
1 + 2 + 3
首先,我們選擇 1 + 2
,那麼其結果將作為左值,即 + 3
。
上述可以用 Binary 類表示為:
new Binary (new Literal(1), 'ADD', new Binary(new Literal(2), 'ADD', new Literal(3)))
可以看到,右值不是字面量,而是一個二元表示式。所以在執行加法運算之前,它必須先對這個二元表示式求值,並將其結果作為最終求值時的右值。
const oneLit = new Literal(1)
const threeLit =new Literal(3)
const twoLit = new Literal(2)
const binExpr2 = new Binary(twoLit, 'ADD', threeLit)
const binExpr1 = new Binary (oneLit, 'ADD', binExpr2)
const visitor = new Visitor()
log(binExpr1.visit(visitor))
6
新增 if
語句
將 if
語句帶到等式中。為了對一個 if 語句求值,我們將會給 IfStmt 類新增一個 visit
方法,之後它將呼叫 visitIfStmt 方法:
class IfStmt {
constructor(condition, body) {
this.condition = condition
this.body = body
}
visit(visitor) {
return visitor.visitIfStmt(this)
}
}
見識到訪問者模式的威力了嗎?我們向一些類中新增了一個類,對應地只需要新增相同的訪問方法即可,而這將呼叫它位於 Vistor 類中的對應方法。這種方式將不會破壞或者影響到其它的相關類,訪問者模式讓我們遵循了開閉原則。
因此,我們在 Vistor 類中實現 visitIfStmt
:
class Visitor {
// ...
visitIfStmt(ifStmt) {
if(ifStmt.condition.visit(this)) {
for(const stmt of ifStmt.body) {
stmt.visit(this)
}
}
}
}
因為條件是一個表示式,所以我們呼叫它的訪問方法對其進行求值。我們使用 JS 中的 if 語句檢查返回值,如果為真,則遍歷語句的程式碼塊 ifStmt.body
,通過呼叫 visit
方法並傳入 Vistor,對陣列中每一條語句進行求值。
因此我們可以翻譯出這條語句:
if(67 > 90)
新增函式呼叫和函式宣告
接著來新增一個函式呼叫。我們已經有一個對應的類了:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
}
新增一個訪問方法:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
visit(visitor) {
return visitor.visitFuncCall(this)
}
}
給 Visitor
類新增 visitFuncCall
方法:
class Visitor {
// ...
visitFuncCall(funcCall) {
const funcName = funcCall.name
const args = []
for(const expr of funcCall.args)
args.push(expr.visit(this))
// ...
}
}
這裡有一個問題。除了內建函式之外,還有自定義函式,我們需要為後者建立一個“容器”,並在裡面通過函式名儲存和引用該函式。
const FuncStore = (
class FuncStore {
constructor() {
this.map = new Map()
}
setFunc(name, body) {
this.map.set(name, body)
}
getFunc(name) {
return this.map.get(name)
}
}
return new FuncStore()
)()
FuncStore
儲存著函式,並從一個 Map
例項中取回這些函式。
class Visitor {
// ...
visitFuncCall(funcCall) {
const funcName = funcCall.name
const args = []
for(const expr of funcCall.args)
args.push(expr.visit(this))
if(funcName == "log")
console.log(...args)
if(FuncStore.getFunc(funcName))
FuncStore.getFunc(funcName).forEach(stmt => stmt.visit(this))
}
}
看下我們做了什麼。如果函式名 funcName
(記住,FuncCall
類將函式名儲存在 name
屬性中)為 log
,則執行 JS console.log(...)
,並傳參給它。如果我們在函式儲存中找到了函式,那麼就對該函式體進行遍歷,依次訪問並執行。
現在看看怎麼把我們的函式宣告放進函式儲存中。
函式宣告以 fucntion
開頭。一般的函式結構是這樣的:
function function_name(params) {
// function body
}
因此,我們可以在一個類中用屬性表示一個函式宣告:name 儲存函式函式名,body 則是一個陣列,儲存函式體中的語句:
class FunctionDeclaration {
constructor(name, body) {
this.name = name
this.body = body
}
}
我們新增一個訪問方法,該方法在 Vistor 中被稱為 visitFunctionDeclaration:
class FunctionDeclaration {
constructor(name, body) {
this.name = name
this.body = body
}
visit(visitor) {
return visitor.visitFunctionDeclaration(this)
}
}
在 Visitor 中:
class Visitor {
// ...
visitFunctionDeclaration(funcDecl) {
FuncStore.setFunc(funcDecl.name, funcDecl.body)
}
}
將函式名作為鍵即可儲存函式。
現在,假設我們有下面這個函式:
function addNumbers(a, b) {
log(a + b)
}
function logNumbers() {
log(5)
log(6)
}
它可以表示為:
const funcDecl = new FunctionDeclaration('logNumbers', [
new FuncCall('log', [new Literal(5)]),
new FuncCall('log', [new Literal(6)])
])
visitor.visitFunctionDeclaration(funcDecl)
現在,我們來呼叫函式 logNumbers
:
const funcCall = new FuncCall('logNumbers', [])
visitor.visitFuncCall(funcCall)
控制檯將會列印:
5
6
結論
理解 AST 的過程是令人望而生畏並且非常消耗腦力的。即使是編寫最簡單的解析器也需要大量的程式碼。
注意,我們並沒有介紹掃描器和解析器,而是先行解釋了 ASTs 以展示它們的工作過程。如果你能夠深入理解 AST 以及它所需要的內容,那麼在你開始編寫自己的程式語言時,自然就事半功倍了。
熟能生巧,你可以繼續新增其它的程式語言特性,例如:
- 類和物件
- 方法呼叫
- 封裝和繼承
-
for-of
語句 -
while
語句 -
for-in
語句 - 其它任何你能想到的有趣特性
如果你對此有任何疑問,或者是任何我需要新增、修改、刪減的內容,歡迎評論和致郵。
感謝 !!!