「譯」什麼是抽象語法樹

Chor發表於2019-06-16

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')

這其實並不合理。因為正如我們在上面看到的,12 都是一條表示式,一條基本的表示式。作為字面量,它們同樣需要被求值,並且用 Literal 類來表示。

const oneLit = new Literal('1')  
const twoLit = new Literal('2')

因此,二元表示式會將 oneLittwoLit 分別作為左屬性和右屬性。

// ...  
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!!

為了讓直譯器或者編譯器這樣執行,我們將會在一個包含 conditionbody 屬性的類中來表示它。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。這稱為雙重分派。

  1. 呼叫 Binary 的訪問方法。
  2. 它 (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 語句
  • 其它任何你能想到的有趣特性

如果你對此有任何疑問,或者是任何我需要新增、修改、刪減的內容,歡迎評論和致郵。

感謝 !!!

相關文章