【編譯原理】手工打造語法分析器

大数据王小皮發表於2024-04-07

重點:

  • 語法分析的原理
  • 遞迴下降演算法(Recursive Descent Parsing)
  • 上下文無關文法(Context-free Grammar,CFG)

關鍵點:

  • 左遞迴問題
  • 深度遍歷求值 - 後續遍歷

上一篇「詞法分析器」將字串拆分為了一個一個的 token。
本篇我們將 token 變成語法樹。

一、遞迴下降演算法

還是這個例子 int age = 45
我們給出這個語法的規則:

intDeclaration : Int Identifier ('=' additiveExpression)?;

如果翻譯為程式的話,虛擬碼如下

// 虛擬碼
MatchIntDeclare(){
  MatchToken(Int);        // 匹配 Int 關鍵字
  MatchIdentifier();       // 匹配識別符號
  MatchToken(equal);       // 匹配等號
  MatchExpression();       // 匹配表示式
}

輸出的 AST 類似於:

Programm Calculator
    IntDeclaration age
        AssignmentExp =
            IntLiteral 45

上面的過程,稱為「遞迴下降演算法」。
從頂部開始不斷向下生成節點,其中還會有遞迴呼叫的部分。

二、上下文無關文法

上面的例子比較簡單,還可以用正規表示式文法來表示。
但如果是個算數表示式呢?正則文法就很難表示了。

  • 2+3*5
  • 2*3+5
  • 2*3

這時我們可以用遞迴的規則來表示

additiveExpression
    :   multiplicativeExpression
    |   additiveExpression Plus multiplicativeExpression
    ;
 
multiplicativeExpression
    :   IntLiteral
    |   multiplicativeExpression Star IntLiteral
    ;

生成的 AST 為:
image.png

如果要計算表示式的值,只需要對根節點求值就可以了。
這個就叫做「上下文無關文法」

但你把上述規則翻譯為程式碼邏輯時,會發現一個問題,無限遞迴
我們先用個最簡單的示例:

	additiveExpression
    :   IntLiteral
    |   additiveExpression Plus IntLiteral
    ;

比如輸入 2+3

  • 先判斷其是不是 IntLiteral,發現不是
  • 然後匹配 additiveExpression Plus IntLiteral,此時還沒有消耗任何的 token
  • 先進入的是 additiveExpression,此時要處理的表示式還是 2+3
  • 又回到開始,無限迴圈

這裡要注意的一個問題:
並不是覺得 2+3 符合 additiveExpression Plus IntLiteral 就能直接按照 + 拆分為兩部分,然後兩部分分別去匹配。
這裡是順序匹配的,直到匹配到該語法規則的結束符為止。
additiveExpression Plus IntLiteraladditiveExpression 的部分,也是在處理完整的 token 的(2+3)。

三、左遞迴解決方案

改為右遞迴

如何處理這個左遞迴問題呢?
我們可以把表示式換個位置:

	additiveExpression
    :   IntLiteral
    |   IntLiteral Plus additiveExpression
    ;

先匹配 IntLiteral 這樣就能消耗掉一個 token,就不會無限迴圈了。
比如還是 2+3

  • 2+3 不是 IntLiteral,跳到下面
  • 2+3 的第一個字元是 2IntLiteral 消耗掉,並結束 IntLiteral 匹配
  • 然後 +Plus 消耗掉
  • 最後 3 進入 additiveExpression,匹配為第一條規則 IntLiteral

這樣就結束了,沒有無限迴圈。
改寫成演算法是:

private SimpleASTNode additive(TokenReader tokens) throws Exception {
    SimpleASTNode child1 = IntLiteral();  // 計算第一個子節點
    SimpleASTNode node = child1;  // 如果沒有第二個子節點,就返回這個
    Token token = tokens.peek();
    if (child1 != null && token != null) {
        if (token.getType() == TokenType.Plus) {
            token = tokens.read();
            SimpleASTNode child2 = additive(); // 遞迴地解析第二個節點
            if (child2 != null) {
                node = new SimpleASTNode(ASTNodeType.AdditiveExp, token.getText());
                node.addChild(child1);
                node.addChild(child2);
            } else {
                throw new Exception("invalid additive expression, expecting the right part.");
            }
        }
    }
    return node;
}

但也有問題:
比如 2+3+4,你會發現它的計算順序變為了 2+(3+4) 後面 3+4 作為一個 additiveExpression 先被計算,然後才會和前面的 2 相加。改變了計算順序。
image.png

消除左遞迴

上面右遞迴解決了無限遞迴的問題,但是又有了結合優先順序的問題。
那麼我們再改寫一下左遞迴:

additiveExpression
  :   IntLiteral additiveExpression'
  ;

additiveExpression'
  :		'+' IntLiteral additiveExpression'
  | 	ε
  ;

文法中,ε(讀作 epsilon)是空集的意思。
語法樹 AST 就變成了下圖左邊的樣子,雖然沒有無限遞迴,但是按照前面思路,使用遞迴下降演算法,結合性還是不對。
我們期望的應該是右邊的 AST 樹樣子。那麼怎麼才能變成右邊的樣子呢?
image.png

這裡我們插入一個知識點:
前面語法規則的表示方式成為:「巴科斯正規化」,簡稱 BNF
我們把下面用正規表示式簡化表達的方式,稱為「擴充套件巴科斯正規化 (EBNF)」
add -> mul (+ mul)*

那麼我們把上面的表示式改寫成 EBNF 形式,變為:

additiveExpression -> IntLiteral ('+' IntLiteral)*

這裡寫法的變化,就能讓我們的演算法邏輯產生巨大的變化。

重點:
前面左遞迴也好、右遞迴也好,變來變去都是遞迴呼叫,導致無限迴圈、結合性的問題。如果我們幹掉遞迴,用迴圈來代替,就能按照我們期待的方式來執行了。
這裡的區別是:前面遞迴計算過程是後序,把最後訪問到的節點先計算,然後再一步步的返回;而迴圈迭代是前序,先計算再往後訪問。

我們再寫出計算邏輯:

private SimpleASTNode additive(TokenReader tokens) throws Exception {
    SimpleASTNode child1 = IntLiteral(tokens);  // 應用 add 規則
    SimpleASTNode node = child1;
    if (child1 != null) {
        while (true) {                              // 迴圈應用 add'
            Token token = tokens.peek();
            if (token != null && (token.getType() == TokenType.Plus)) {
                token = tokens.read();              // 讀出加號
                SimpleASTNode child2 = IntLiteral(tokens);  // 計算下級節點
                node = new SimpleASTNode(ASTNodeType.Additive, token.getText());
                node.addChild(child1);              // 注意,新節點在頂層,保證正確的結合性
                node.addChild(child2);
                child1 = node;
            } else {
                break;
            }
        }
    }
    return node;
}

消除了遞迴,只有迴圈迭代。你可以和上面遞迴的程式碼對比下。

再提一個概念:「尾遞迴」
尾遞迴就是函式的最後一句是遞迴的呼叫自身,可以理解為先序。而這種尾遞迴通常都可以轉化為一個迴圈語句。

四、執行程式碼

前面我們已經把一個語句轉換為了一個 AST 樹,接下來我們遍歷這個語法樹,就能實現計算求值了。
2+3+4 為例,簡化後的語法樹長這樣:
image.png

遍歷的虛擬碼如下:

evaluate(node) {
    if node.type == TYPE.ADD:
        left_res = evaluate(node.getChild(0))
        right_res = evaluate(node.getChild(1))
        return left_res + right_res
    else if node.type == TYPE.INT:
        return node.val
}

五、小結

✌️至此,我們實現了一個計算器。

  • 可以實現詞法分析:對輸入的文字拆分為一個一個的 token
  • 生成語法樹:將 token 變為一個 AST 樹
  • 計算求值:遍歷 AST 樹,就能得到最終的計算結果

後面你可以在此基礎上進行擴充套件,增加更多的運算子。以及擴充為一個指令碼語言直譯器,新增變數賦值、計算等等操作咯。

相關文章