- 原文地址:What is an Abstract Syntax Tree
- 原文做者:Chidume Nnamdi
- 譯者:Chor
AST 是抽象語法樹的縮寫詞,表示編程語言的語句和表達式中生成的 token。有了 AST,解釋器或編譯器就能夠生成機器碼或者對一條指令求值。git
小貼士: 經過使用 Bit,你能夠將任意的 JS 代碼轉換爲一個可在項目和應用中共享、使用和同步的 API,從而更快地構建並重用更多代碼。試一下吧。github
假設咱們有下面這條簡單的表達式:算法
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
。咱們的大腦斷定這是一個將左值和右值相加的加法運算。如今,爲了讓計算機像咱們的大腦那樣工做,咱們必須以相似於大腦看待它的形式來表示它。bash
咱們用一個類來表示,其中的屬性告訴解釋器運算的所有內容、左值和右值。由於一個二元運算涉及兩個值,因此咱們給這個類命名爲 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
值,並將兩者相加:ui
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 進行求值。訪問者模式是設計模式的一種,容許一組對象的算法在一個地方實現。
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
語句若是你對此有任何疑問,或者是任何我須要添加、修改、刪減的內容,歡迎評論和致郵。
感謝 !!!