重點:
- 語法分析的原理
- 遞迴下降演算法(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 為:
如果要計算表示式的值,只需要對根節點求值就可以了。
這個就叫做「上下文無關文法」。
但你把上述規則翻譯為程式碼邏輯時,會發現一個問題,無限遞迴。
我們先用個最簡單的示例:
additiveExpression
: IntLiteral
| additiveExpression Plus IntLiteral
;
比如輸入 2+3
:
- 先判斷其是不是
IntLiteral
,發現不是 - 然後匹配
additiveExpression Plus IntLiteral
,此時還沒有消耗任何的 token - 先進入的是
additiveExpression
,此時要處理的表示式還是2+3
- 又回到開始,無限迴圈
這裡要注意的一個問題:
並不是覺得 2+3
符合 additiveExpression Plus IntLiteral
就能直接按照 +
拆分為兩部分,然後兩部分分別去匹配。
這裡是順序匹配的,直到匹配到該語法規則的結束符為止。
在 additiveExpression Plus IntLiteral
中 additiveExpression
的部分,也是在處理完整的 token 的(2+3)。
三、左遞迴解決方案
改為右遞迴
如何處理這個左遞迴問題呢?
我們可以把表示式換個位置:
additiveExpression
: IntLiteral
| IntLiteral Plus additiveExpression
;
先匹配 IntLiteral
這樣就能消耗掉一個 token,就不會無限迴圈了。
比如還是 2+3
2+3
不是IntLiteral
,跳到下面2+3
的第一個字元是2
被IntLiteral
消耗掉,並結束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
相加。改變了計算順序。
消除左遞迴
上面右遞迴解決了無限遞迴的問題,但是又有了結合優先順序的問題。
那麼我們再改寫一下左遞迴:
additiveExpression
: IntLiteral additiveExpression'
;
additiveExpression'
: '+' IntLiteral additiveExpression'
| ε
;
文法中,ε(讀作 epsilon)是空集的意思。
語法樹 AST 就變成了下圖左邊的樣子,雖然沒有無限遞迴,但是按照前面思路,使用遞迴下降演算法,結合性還是不對。
我們期望的應該是右邊的 AST 樹樣子。那麼怎麼才能變成右邊的樣子呢?
這裡我們插入一個知識點:
前面語法規則的表示方式成為:「巴科斯正規化」,簡稱 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
為例,簡化後的語法樹長這樣:
遍歷的虛擬碼如下:
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 樹,就能得到最終的計算結果
後面你可以在此基礎上進行擴充套件,增加更多的運算子。以及擴充為一個指令碼語言直譯器,新增變數賦值、計算等等操作咯。