前言
在程式碼編寫中,很多時候我們都會處理字串:發現字串中的某些規律,然後將想要的部分抽取出來。對於發雜一些的場景,我們會使用正規表示式來幫忙,正規表示式強大而靈活,主流的變成語言如Java,Ruby的標準庫中都對其由很好的支援。
但是有時候,當接收到的字串結構更加複雜(往往會這樣)的時候,正規表示式要麼會變的不夠用,要麼變得超出我們能理解的複雜度。這時候,我們可能借助一些更為強大的工具。
下面是一個實際的例子,這個程式碼片段是MapServer的配置檔案,它用來描述地圖中的一個層,其中包含了巢狀的CLASS,而CLASS自身又包含了一個巢狀的STYLE節。顯然,正規表示式在解釋這樣複雜的結構化資料方面,是無法滿足需求的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
LAYER NAME "counties" DATA "counties-in-shaanxi-3857" STATUS default TYPE POLYGON TRANSPARENCY 70 CLASS NAME "polygon" STYLE COLOR 255 255 255 OUTLINECOLOR 40 44 52 END END END |
在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),其中
- N是“非終結符”的集合
- Σ是“終結符”的集合,與N的交集為空(不想交)
- P表示規則集(即N中的一些元素以何種方式)
- S表示起始變數,是一個“非終結符”
其中,規則集P是重中之重,我們會在下一小節解釋。經過這個形式化的解釋,基本還是等於沒說,在繼續之前,我們先來看一下BNF,然後結合一個例子來幫助理解。
話說我上一次寫這種學院派的文章還是2009年,時光飛逝。
巴科斯正規化(Backus Normal Form)
維基上的解釋是:
巴科斯正規化(英語:Backus Normal Form,縮寫為 BNF),又稱為巴科斯-諾爾正規化(英語:Backus-Naur Form,也譯為巴科斯-瑙爾正規化、巴克斯-諾爾正規化),是一種用於表示上下文無關文法的語言,上下文無關文法描述了一類形式語言。它是由約翰·巴科斯(John Backus)和彼得·諾爾(Peter Naur)首先引入的用來描述計算機語言語法的符號集。
簡而言之,它是由推導公式的集合組成,比如下面這組公式:
1 2 3 4 |
S -> T + T | T - T | T T -> F * F | F / F | F F -> 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可以解釋一切出現在四則運算中的文法,比如:
1 2 3 |
1+1 8*2+3 (10-6)*4/2 |
而所謂上下文無關,指的是在推導式的左邊,都是非終結符,並且可以無條件的被其右邊的式子替換。此處的無條件就是上下文無關。
實現一個四則運算計算器
我們這裡要使用jison,jison是一個npm包,所以安裝非常容易:
1 |
npm install -g jison |
安裝之後,你本地就會有一個命令列工具jison,這個工具可以將你定義的jison檔案編譯成一個.js檔案,這個檔案就是直譯器的原始碼。我們先來定義一些符號(token),所謂token就是上述的終結符:
第一步:識別數字
建立一個新的文字檔案,假設就叫calc.jison,在其中定義一段這樣的符號表:
1 2 3 4 |
\s+ /* skip whitespace */ [0-9]+("."[0-9]+)? return 'NUMBER' <<EOF>> return 'EOF' . return 'INVALID' |
這裡我們定義了4個符號,所有的空格(\s+),我們都跳過;如果遇到數字,則返回NUMBER;如果遇到檔案結束,則返回EOF;其他的任意字元(.)都返回INVALID。
定義好符號之後,我們就可以編寫BNF了:
1 2 3 4 5 6 7 |
expressions : NUMBER EOF { console.log($1); return $1; } ; |
這裡我們定義了一條規則,即expressions -> NUMBER EOF。在jison中,當匹配到規則之後,可以執行一個程式碼塊,比如此處的輸出語句console.log($1)。這個產生式的右側有幾個元素,就可以用$加序號來引用,如$1表示NUMBER實際對應的值,$2為EOF。
通過命令
1 |
jison calc.jison |
可以在當前目錄下生成一個calc.js檔案,現在來建立一個檔案expr,檔案內容為一個數字,然後執行:
1 |
node calc.js expr |
來測試我們的直譯器:
1 2 3 |
$ echo "3.14" > expr $ node calc.js expr 3.14 |
目前我們完整的程式碼僅僅20行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
/* lexical grammar */ %lex %% \s+ /* skip whitespace */ [0-9]+("."[0-9]+)? return 'NUMBER' <<EOF>> return 'EOF' . return 'INVALID' /lex %start expressions %% /* language grammar */ expressions : NUMBER EOF { console.log($1); return $1; } ; |
加法
我們的解析器現在只能計算一個數字(輸入給定的數字,給出同樣的輸出),我們來為它新增一條新的規則:加法。首先我們來擴充套件目前的BNF,新增一條新的規則:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
expressions : statement EOF { console.log($1); return $1; } ; statement: NUMBER PLUS NUMBER {$ $ = $1 + $3} | NUMBER {$ $ = $1} ; |
即,expressions由statement組成,而statement可以有兩個規則規約得到,一個就是純數字,另一個是數字 加號 數字,這裡的PLUS是我們定義的一個新的符號:
1 |
"+" return "PLUS" |
當輸入匹配到規則數字 加號 數字時,對應的塊{$$ = $1 + $3}會被執行,也就是說,兩個NUMBER對應的值會加在一起,然後賦值給整個表示式的值,這樣就完成了語義的翻譯。
我們在檔案expr中寫入算式:3.14+1,然後測試:
1 2 3 |
$ jison calc.jison $ node calc.js expr 13.14 |
嗯,結果有點不對勁,兩個數字都被當成了字串而拼接在一起了,這是因為JavaScript中,+的二義性和弱型別的自動轉換導致的,我們需要做一點修改:
1 2 3 4 5 6 7 |
statement: NUMBER PLUS NUMBER {$ $ = parseFloat($1) + parseFloat($3)} | NUMBER {$ $ = $1} ; |
我們使用JavaScript內建的parseFloat將字串轉換為數字型別,再做加法即可:
1 2 3 |
$ jison calc.jison $ node calc.js expr 4.140000000000001 |
更多的規則
剩下的事情基本就是把BNF翻譯成jison的語法了:
1 2 3 4 |
S -> T + T | T - T | T T -> F * F | F / F | F F -> NUMBER | '(' S ')' NUMBER -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
expressions : statement EOF { console.log($1); return $1; } ; statement: term PLUS term {$ $ = $1 + $3} | term MINUS term {$ $ = $1 - $3} | term {$ $ = $1} ; term: factor MULTIPLE factor {$ $ = $1 * $3} | factor DIVIDE factor {$ $ = $1 / $3} | factor {$ $ = $1} ; factor: NUMBER {$ $ = parseFloat($1)} | LP statement RP {$ $ = $2} ; |
這樣,像複雜一些的四則運算:(10-2) * 3 + 2/4,我們的計算器也已經有能力來計算出結果了:
1 2 3 |
$ jison calc.jison $ node calc.js expr 24.5 |
總結
我們在本文中討論了BNF和上下文無關文法,以及這些理論如何與工程實踐聯絡起來。這裡的四則運算計算器當然是一個很簡單的例子,不過我們從中可以看到將BNF形式文法翻譯成實際可以工作的程式碼是多麼方便。我在後續的文章中會介紹jison更高階的用法,以及如何在實際專案中使用jison產生的直譯器。