原文地址:蘋果梨的博客html
今天就要開始正式寫表達式解析器了,第一章的核心代碼一共二十行都不到,包簡單包學會,可是裏面涉及的原理知識可能要花點時間講一講。git
首先爲了能快速簡單的開始寫咱們的解析器,先要對錶達式的規則作必定簡化:github
而後咱們會採用遞歸加循環的方式來解析表達式,還玩不轉遞歸的同窗必需要先過遞歸這道坎。bash
咱們學習語法分析先得從文法入手,文法是解析表達式的關鍵,一個優秀的文法能夠指導咱們輕鬆的寫出解析表達式的遞歸邏輯。這裏的文法通常說的是上下文無關文法,後面就簡稱文法了。文法具體是什麼,翻開書本或者打開wiki,看了半天定義可能仍是一頭霧水。咱們來舉個例子說明:函數
sum -> num '+' num
num -> '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
複製代碼
這是一個解析兩數和的文法。文法包含4個元素:學習
終結符號:就是例子裏的'+'
、'0'
、'1'
…'9'
,他們都是肯定的字符。spa
一段可解析的表達式,最終總歸能分解成這些終結符號且只能包含這些終結符號。什麼意思呢?好比說1+2
在這個例子的文法裏就是可解析的,+1+
僅包含終結符因此多是可解析的,可是a+1
或者1?2
這種包含了別的符號因此確定是不可解析的。3d
非終結符號:就是例子裏的'sum'
和'num'
,他們都是由終結符或者非終結符組成的組合。指針
提及來他們就是描述文法時用的中間變量,最後在遞歸邏輯中的表現可能就是對應到一個遞歸函數上,要儘可能使他們可複用。code
產生式:例子的兩行,每行就是一個產生式,他表達了一個非終結符是由什麼組合成的,也就是說是用來描述符號之間關係的。
開始符號:文法須要指定一個非終結符號爲開始符號,這個例子裏面就是'sum'
。
開始符號的意義就是明確一下你最後要解析出來的是個什麼。在這個例子裏,最終想要解析出來的是一個求兩數和的表達式,而不是一個數字。
好了,接着用這個例子演示下怎麼用文法來進行推導,人肉推導一下1+2
:
過程:
由於 '1' = num,且 '2' = num
因此 '1+2' = num '+' num
由於 num '+' num = sum
因此 '1+2' = sum
結論:
sum(1+2) = num(1) '+' num(2) = '1+2'
複製代碼
人腦推導這種簡單的文法仍是很簡單的,可是電腦可作不到。爲了讓電腦能夠按照文法解析表達式,咱們要用到遞歸的辦法,示例的僞代碼以下:
int num(表達式) {
解析 '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
}
int sum(表達式) {
num(表達式);
解析 '+'
num(表達式);
}
複製代碼
這樣調用sum('1+2')
就能夠正常的依次解析'1'
、'+'
、'2'
了,是否是很直觀?用文法配合遞歸的方法,就能夠很輕鬆的解析表達式。固然僞代碼裏還省略了不少細節,後面咱們再補充。
首先列出來加減法表達式的文法:
① 正確示範
expr -> expr '+' num | expr '-' num | num
num -> '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
② 錯誤示範
expr -> num '+' expr | num '-' expr | num
num -> '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
複製代碼
這裏把正確示範和錯誤示範都列出來了,雖然二者均可以解析加減法表達式,可是②裏的優先級是有問題的。具體是什麼問題,咱們用3-2+1
爲例子分析一下:
① 正確優先級
推導過程:
由於 expr = expr '+' num | expr '-' num | num,且 num 是隻能包含數字的,只有 expr 能夠繼續展開
因此 expr(3-2+1) = expr(3-2) '+' num(1)
繼續推導得 expr(3-2) = expr(3) '-' num(2)
繼續推導得 expr(3) = num(3)
遞歸求解:
expr(3) = num(3) = 3
expr(3-2) = expr(3) '-' num(2) = 3 - 2 = 1
expr(3-2+1) = expr(3-2) '+' num(1) = 1 + 1 = 2
② 錯誤優先級
推導過程:
expr(3-2+1) = num(3) '-' expr(2+1)
expr(2+1) = num(2) '+' expr(1)
expr(1) = num(1)
遞歸求解:
expr(1) = num(1) = 1
expr(2+1) = num(2) '+' expr(1) = 2 + 1 = 3
expr(3-2+1) = num(3) '-' expr(2+1) = 3 - 3 = 0
複製代碼
雖然乍看兩個文法均可以解析加減法表達式,可是仔細推導後就會發現只有文法①是正確的。
接下來就要試一試用遞歸來實現這個文法了,可是剛開始寫就會發現悲催了:
int expr(表達式) {
expr(表達式);
...
}
複製代碼
這不就是一個死循環式的無限遞歸麼!?
這裏就要引入一個消除左遞歸的概念。
咱們如今遇到的狀況是一種直接左遞歸,也就是相似A->Aα
這種文法,這裏用大寫英文字母表示非終結符,小寫希臘字母表示終結符,在一個產生式裏的第一個元素就是產生式左側的非終結符自身,這就叫作直接左遞歸。A->Aα
只是舉個例子哈,可是它實際上是個非法的文法,由於永遠包含非終結符,因此永遠停不下來😂。
最簡單且合法的直接左遞歸文法應該是A->Aα|β
,咱們人肉分析一下它的「特點」能夠得出:A
能夠匹配的表達式必定是一個β
後面跟着0到無限個α
。因此提及來這個文法能夠轉換成:
A -> β {α} // {} 表示內部元素能夠出現 0 - N 次
複製代碼
這樣的話就能夠用遞歸配合循環來寫解析邏輯了,後面咱們實際上就會用這種方式來寫解析邏輯。
可是這種文法看起來不是很直觀,括號嵌套多了看着眼花,因此咱們還要討論另外一種變換形式的文法:
A -> β B
B -> α B | null // null 是什麼意思就不用解釋了吧?……
複製代碼
你們本身思考一下,應該就能發現這個文法和A->Aα|β
實際上是如出一轍的。拿一個βαα
解析下做個實驗:
A(βαα) = β B(αα) = β α B(α) = β α α B(null) = β α α
複製代碼
把加減法表達式的文法消除左遞歸,獲得:
expr -> num expr1
expr1 -> '+' num expr1 | '-' num expr1 | null
num -> '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
複製代碼
而後咱們就能夠去寫對應的邏輯代碼了,前面也說過了實際寫的時候咱們會把expr
函數寫成遞歸配合循環式的,也就是相似這樣:
int number(const char *expStr) {
return *expStr - '0';
}
int expr(const char *expStr) {
int result = number(expStr++);
while (*expStr == '+' || *expStr == '-') {
char op = *expStr; expStr++;
if (op == '+') {
result += number(expStr++);
} else {
result -= number(expStr++);
}
}
return result;
}
複製代碼
真的沒有騙大家,核心代碼一共就這麼點。由於咱們限定了表達式的規則,因此每個字符都是一個元素,每識別一個元素後對字符串指針作自增操做就能夠移動到下一個元素繼續進行分析,邏輯能夠寫得十分的簡單。由於咱們也不用糾結運算符優先級,因此每識別一個運算符,就能夠直接進行計算獲得進一步的結果。
這麼短的代碼應該難不倒各位吧?在理解了文法的原理後,就能夠理解這短短十幾行代碼裏的奧祕。完整的代碼放在SlimeExpressionC,須要的同窗能夠自取,今天的入門課就到這裏啦。