工程中的編譯原理 -- Jison入門篇
前言
在程式碼編寫中,很多時候我們都會處理字串:發現字串中的某些規律,然後將想要的部分抽取出來。對於發雜一些的場景,我們會使用正則表示式來幫忙,正則表示式強大而靈活,主流的變成語言如Java,Ruby的標準庫中都對其由很好的支援。
但是有時候,當接收到的字串結構更加複雜(往往會這樣)的時候,正則表示式要麼會變的不夠用,要麼變得超出我們能理解的複雜度。這時候,我們可能借助一些更為強大的工具。
下面是一個實際的例子,這個程式碼片段是MapServer的配置檔案,它用來描述地圖中的一個層,其中包含了巢狀的CLASS,而CLASS自身又包含了一個巢狀的STYLE節。顯然,正則表示式在解釋這樣複雜的結構化資料方面,是無法滿足需求的。
123456789101112131415 | LAYERNAME"counties"DATA"counties-in-shaanxi-3857"STATUSdefaultTYPEPOLYGONTRANSPARENCY70CLASSNAME"polygon"STYLECOLOR255255255OUTLINECOLOR404452ENDENDEND |
在UNIX世界,很早的時候,人們就開發出了很多用來生成直譯器
(parser)的工具,比如早期的lex)/yacc之類的工具和後來的
注意,我們這裡說的直譯器不是一個編譯器,編譯器有非常複雜的後端(抽象語法樹的生成,虛擬機器器指令,或者機器碼的生成等等),我們這裡僅僅討論一個編譯器的前端。
一點理論知識
本文稍微需要一點理論知識,當年編譯原理課的時候,各種名詞諸如規約,推導式,終結符,非終結符等等,
上下文無關文法(Context Free Grammar)
先看看維基上的這段定義:
在電腦科學中,若一個形式文法 G = (N, Σ, P, S) 的產生式規則都取如下的形式:V -> w,則稱之為上下文無關文法(英語:context-free grammar,縮寫為CFG),其中 V∈N ,w∈(N∪Σ)* 。上下文無關文法取名為“上下文無關”的原因就是因為字元 V 總可以被字串 w 自由替換,而無需考慮字元 V 出現的上下文。
基本上跟沒說一樣。要定義一個上下文無關文法,數學上的精確定義是一個在4元組:G = (N, Σ, P, S),其中
- N是“非終結符”的集合
- Σ是“終結符”的集合,與N的交集為空(不想交)
- P表示規則集(即N中的一些元素以何種方式)
- S表示起始變數,是一個“非終結符”
其中,規則集P是重中之重,我們會在下一小節解釋。經過這個形式化的解釋,基本還是等於沒說,在繼續之前,我們先來看一下BNF,然後結合一個例子來幫助理解。
話說我上一次寫這種學院派的文章還是2009年,時光飛逝。
巴科斯正規化(Backus Normal Form)
維基上的解釋是:
巴科斯正規化(英語:Backus Normal Form,縮寫為 BNF),又稱為巴科斯-諾爾正規化(英語:Backus-Naur Form,也譯為巴科斯-瑙爾正規化、巴克斯-諾爾正規化),是一種用於表示上下文無關文法的語言,上下文無關文法描述了一類形式語言。它是由約翰·巴科斯(John Backus)和彼得·諾爾(Peter Naur)首先引入的用來描述計算機語言語法的符號集。
簡而言之,它是由推導公式的集合組成,比如下面這組公式:
Shell1234 | S->T+T|T-T|TT->F*F|F/F|FF->NUMBER|'('S')'NUMBER->0|1|2|3|4|5|6|7|8|9 |
可以被“繼續分解”的元素,我們稱之為“非終結符”,如上式中的S, T, NUMBER,而無法再細分的如0..9,(,)則被稱之為終結符。|表示或的關係。在上面的公式集合中,S可以被其右邊的T+T替換,也可以被T-T替換,還可以被T本身替換。回到上一小節最後留的懸疑,在這裡:
- N就是{S, T, F, NUMBER}
- Σ就是{0, 1, …, 9, (, ), +, -, *, /}
- P就是上面的BNF式子
- S就是這個的S(第一個等式的左邊狀態)
上面的BNF其實就是四則運算的形式定義了,也就是說,由這個BNF可以解釋一切出現在四則運算中的文法,比如:
Shell123 | 1+18*2+3(10-6)*4/2 |
而所謂上下文無關,指的是在推導式的左邊,都是非終結符,並且可以無條件的被其右邊的式子替換。此處的無條件就是上下文無關。
實現一個四則運算計算器
我們這裡要使用jison,jison是一個npm包,所以安裝非常容易:
Shell1 | npm install-gjison |
安裝之後,你本地就會有一個命令列工具jison,這個工具可以將你定義的jison檔案編譯成一個.js檔案,這個檔案就是直譯器的原始碼。我們先來定義一些符號(token),所謂token就是上述的終結符:
第一步:識別數字
建立一個新的文字檔案,假設就叫calc.jison,在其中定義一段這樣的符號表:
Shell1234 | \s+/*skip whitespace*/[0-9]+("."[0-9]+)?return'NUMBER'<<EOF>>return'EOF'.return'INVALID' |
這裡我們定義了4個符號,所有的空格(\s+),我們都跳過;如果遇到數字,則返回NUMBER;如果遇到檔案結束,則返回EOF;其他的任意字元(.)都返回INVALID。
定義好符號之後,我們就可以編寫BNF了:
Shell1234567 | expressions:NUMBEREOF{console.log($1);return$1;}; |
這裡我們定義了一條規則,即expressions -> NUMBER EOF。在jison中,當匹配到規則之後,可以執行一個程式碼塊,比如此處的輸出語句console.log($1)。這個產生式的右側有幾個元素,就可以用$加序號來引用,如$1表示NUMBER實際對應的值,$2為EOF。
通過命令
Shell1 | jison calc.jison |
可以在當前目錄下生成一個calc.js檔案,現在來建立一個檔案expr,檔案內容為一個數字,然後執行:
Shell1 | node calc.jsexpr |
來測試我們的直譯器:
Shell123 | $echo"3.14">expr$node calc.jsexpr3.14 |
目前我們完整的程式碼僅僅20行:
Shell1234567891011121314151617181920212223242526 | /*lexicalgrammar*/%lex%%\s+/*skip whitespace*/[0-9]+("."[0-9]+)?return'NUMBER'<<EOF>>return'EOF'.return'INVALID'/lex%startexpressions%%/*languagegrammar*/expressions:NUMBEREOF{console.log($1);return$1;}; |
加法
我們的解析器現在只能計算一個數字(輸入給定的數字,給出同樣的輸出),我們來為它新增一條新的規則:加法。首先我們來擴充套件目前的BNF,新增一條新的規則:
Shell123456789101112131415 | expressions:statementEOF{console.log($1);return$1;};statement:NUMBERPLUSNUMBER{$$=$1+$3}|NUMBER{$$=$1}; |
即,expressions由statement組成,而statement可以有兩個規則規約得到,一個就是純數字,另一個是數字 加號 數字,這裡的PLUS是我們定義的一個新的符號:
Shell1 | "+"return"PLUS" |
當輸入匹配到規則數字 加號 數字時,對應的塊{$$ = $1 + $3}會被執行,也就是說,兩個NUMBER對應的值會加在一起,然後賦值給整個表示式的值,這樣就完成了語義的翻譯。
我們在檔案expr中寫入算式:3.14+1,然後測試:
Shell123 | $jison calc.jison$node calc.jsexpr13.14 |
嗯,結果有點不對勁,兩個數字都被當成了字串而拼接在一起了,這是因為JavaScript中,+的二義性和弱型別的自動轉換導致的,我們需要做一點修改:
Shell1234567 | statement:NUMBERPLUSNUMBER{$$=parseFloat($1)+parseFloat($3)}|NUMBER{$$=$1}; |
我們使用JavaScript內建的parseFloat將字串轉換為數字型別,再做加法即可:
Shell123 | $jison calc.jison$node calc.jsexpr4.140000000000001 |
更多的規則
剩下的事情基本就是把BNF翻譯成jison的語法了:
Shell1234 | S->T+T|T-T|TT->F*F|F/F|FF->NUMBER|'('S')'NUMBER->0|1|2|3|4|5|6|7|8|9 |
1234567 |