1. 程式人生 > >工程中的編譯原理 -- Jison入門篇

工程中的編譯原理 -- Jison入門篇

前言

在程式碼編寫中,很多時候我們都會處理字串:發現字串中的某些規律,然後將想要的部分抽取出來。對於發雜一些的場景,我們會使用正則表示式來幫忙,正則表示式強大而靈活,主流的變成語言如Java,Ruby的標準庫中都對其由很好的支援。

但是有時候,當接收到的字串結構更加複雜(往往會這樣)的時候,正則表示式要麼會變的不夠用,要麼變得超出我們能理解的複雜度。這時候,我們可能借助一些更為強大的工具。

下面是一個實際的例子,這個程式碼片段是MapServer的配置檔案,它用來描述地圖中的一個層,其中包含了巢狀的CLASS,而CLASS自身又包含了一個巢狀的STYLE節。顯然,正則表示式在解釋這樣複雜的結構化資料方面,是無法滿足需求的。

Ruby
123456789101112131415 LAYERNAME"counties"DATA"counties-in-shaanxi-3857"STATUSdefaultTYPEPOLYGONTRANSPARENCY70CLASSNAME"polygon"STYLECOLOR255255255OUTLINECOLOR404452ENDENDEND

在UNIX世界,很早的時候,人們就開發出了很多用來生成直譯器(parser)的工具,比如早期的lex)/yacc之類的工具和後來的

bison。通過這些工具,程式設計師只需要定義一個結構化的文法,工具就可以自動生成直譯器的C程式碼,非常容易。在JavaScript世界中,有一個非常類似的工具,叫做jison。在本文中,我將以jison為例,說明在JavaScript中自定義一個直譯器是何等的方便。

注意,我們這裡說的直譯器不是一個編譯器,編譯器有非常複雜的後端(抽象語法樹的生成,虛擬機器器指令,或者機器碼的生成等等),我們這裡僅僅討論一個編譯器的前端。

一點理論知識

本文稍微需要一點理論知識,當年編譯原理課的時候,各種名詞諸如規約,推導式,終結符,非終結符等等,

上下文無關文法(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),其中

  1. N是“非終結符”的集合
  2. Σ是“終結符”的集合,與N的交集為空(不想交)
  3. P表示規則集(即N中的一些元素以何種方式)
  4. S表示起始變數,是一個“非終結符”

其中,規則集P是重中之重,我們會在下一小節解釋。經過這個形式化的解釋,基本還是等於沒說,在繼續之前,我們先來看一下BNF,然後結合一個例子來幫助理解。

話說我上一次寫這種學院派的文章還是2009年,時光飛逝。

巴科斯正規化(Backus Normal Form)

維基上的解釋是:

巴科斯正規化(英語:Backus Normal Form,縮寫為 BNF),又稱為巴科斯-諾爾正規化(英語:Backus-Naur Form,也譯為巴科斯-瑙爾正規化、巴克斯-諾爾正規化),是一種用於表示上下文無關文法的語言,上下文無關文法描述了一類形式語言。它是由約翰·巴科斯(John Backus)和彼得·諾爾(Peter Naur)首先引入的用來描述計算機語言語法的符號集。

簡而言之,它是由推導公式的集合組成,比如下面這組公式:

Shell
1234 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本身替換。回到上一小節最後留的懸疑,在這裡:

  1. N就是{S, T, F, NUMBER}
  2. Σ就是{0, 1, …, 9, (, ), +, -, *, /}
  3. P就是上面的BNF式子
  4. S就是這個的S(第一個等式的左邊狀態)

上面的BNF其實就是四則運算的形式定義了,也就是說,由這個BNF可以解釋一切出現在四則運算中的文法,比如:

Shell
123 1+18*2+3(10-6)*4/2

而所謂上下文無關,指的是在推導式的左邊,都是非終結符,並且可以無條件的被其右邊的式子替換。此處的無條件就是上下文無關。

實現一個四則運算計算器

我們這裡要使用jison,jison是一個npm包,所以安裝非常容易:

Shell
1 npm install-gjison

安裝之後,你本地就會有一個命令列工具jison,這個工具可以將你定義的jison檔案編譯成一個.js檔案,這個檔案就是直譯器的原始碼。我們先來定義一些符號(token),所謂token就是上述的終結符:

第一步:識別數字

建立一個新的文字檔案,假設就叫calc.jison,在其中定義一段這樣的符號表:

Shell
1234 \s+/*skip whitespace*/[0-9]+("."[0-9]+)?return'NUMBER'<<EOF>>return'EOF'.return'INVALID'

這裡我們定義了4個符號,所有的空格(\s+),我們都跳過;如果遇到數字,則返回NUMBER;如果遇到檔案結束,則返回EOF;其他的任意字元(.)都返回INVALID。

定義好符號之後,我們就可以編寫BNF了:

Shell
1234567 expressions:NUMBEREOF{console.log($1);return$1;};

這裡我們定義了一條規則,即expressions -> NUMBER EOF。在jison中,當匹配到規則之後,可以執行一個程式碼塊,比如此處的輸出語句console.log($1)。這個產生式的右側有幾個元素,就可以用$加序號來引用,如$1表示NUMBER實際對應的值,$2為EOF。

通過命令

Shell
1 jison calc.jison

可以在當前目錄下生成一個calc.js檔案,現在來建立一個檔案expr,檔案內容為一個數字,然後執行:

Shell
1 node calc.jsexpr

來測試我們的直譯器:

Shell
123 $echo"3.14">expr$node calc.jsexpr3.14

目前我們完整的程式碼僅僅20行:

Shell
1234567891011121314151617181920212223242526 /*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,新增一條新的規則:

Shell
123456789101112131415 expressions:statementEOF{console.log($1);return$1;};statement:NUMBERPLUSNUMBER{$$=$1+$3}|NUMBER{$$=$1};

即,expressions由statement組成,而statement可以有兩個規則規約得到,一個就是純數字,另一個是數字 加號 數字,這裡的PLUS是我們定義的一個新的符號:

Shell
1 "+"return"PLUS"

當輸入匹配到規則數字 加號 數字時,對應的塊{$$ = $1 + $3}會被執行,也就是說,兩個NUMBER對應的值會加在一起,然後賦值給整個表示式的值,這樣就完成了語義的翻譯。

我們在檔案expr中寫入算式:3.14+1,然後測試:

Shell
123 $jison calc.jison$node calc.jsexpr13.14

嗯,結果有點不對勁,兩個數字都被當成了字串而拼接在一起了,這是因為JavaScript中,+的二義性和弱型別的自動轉換導致的,我們需要做一點修改:

Shell
1234567 statement:NUMBERPLUSNUMBER{$$=parseFloat($1)+parseFloat($3)}|NUMBER{$$=$1};

我們使用JavaScript內建的parseFloat將字串轉換為數字型別,再做加法即可:

Shell
123 $jison calc.jison$node calc.jsexpr4.140000000000001

更多的規則

剩下的事情基本就是把BNF翻譯成jison的語法了:

Shell
1234 S->T+T|T-T|TT->F*F|F/F|FF->NUMBER|'('S')'NUMBER->0|1|2|3|4|5|6|7|8|9
Shell
1234567