小試 boost spirit

twoon發表於2014-08-23

解釋文字檔案是日常程式設計中太平常的一件事情了,一般來說,土鱉點的做法可以直接手寫 parser 用迴圈暴力地去 map 文字上的關鍵字從而提取相關資訊,想省力一點則可以使用 tokenizer 或正規表示式之類的工具,無論怎樣,總的來說,手寫 parser 去解釋文字基本是件苦力活:寫出的程式碼比較難重用,可讀性可維護性也差,要是設計的差點,哪天文字格式一變,以前辛苦寫的程式碼馬上推倒重來,未嘗是新鮮事。

解救的方法是通過工具來生成 parser,這方面有好多比較出名的工具,比如 Lex, Yacc, ANTLR 等,它們的功能大同小異,都基於文法(用 EBNF 進行描述) 轉換成相應的 parser. 但這些工具使用起來通常比較麻煩:首先是生成的程式碼可讀性不大好,再者如果要把這些程式碼加入到工程中,通常都需要對生成的程式碼加以修改,因此文法一旦發生變動,parser 需要再次生成,這些修改基本又都得再次重來,因此維護困難。

那麼除了上述這些工具還有沒有別的解決方案呢?也許你可以嘗試一下 boost spirit。

以下內容基於 boost 1.37.0,spirit 的版本是 1806,比較老的一個版本,與最新版本相比,大概原理雖是差不多,但庫的目錄結構已有很大不同,知悉。

Spirit 是什麼

簡單來說,Spirit 是一個 parser generator,功能與 Yacc,ANTLR 類似,且也是基於 EBNF 來描述文法,再基於文法生成 parser,但與前面這些工具相比,它最大的不同點在於它使用了 C++ 程式碼來對文法進行描述,通過非常殘暴的模板程式設計技巧,在編譯階段就生成了相應的 parser。從使用者的角度來看,文法是用程式碼進行描述的,因此它天生就能直接加入到你當前的工程中與現成程式碼揉合在一起。

當然,Spirit 的文法在形式上 EBNF 還是有一點點的出入,比如說,用 ">>" 來連線不同表示式,表示重複的符號放在了表示式的前面等,這些都是受 c++ 語法的限制所做出的折衷,文法的語義其實未變。

初體驗

一個經典的整數四則運算如用 EBNF 來描述的話,可以寫成如下的形式:

    group       ::= '(' expression ')'
    factor      ::= integer | group
    term        ::= factor (('*' factor) | ('/' factor))*
    expression  ::= term (('+' term) | ('-' term))*

其中 group, factor, term, expression 分別稱為一個 rule,用於表示怎麼去匹配一條相應的文字,上述 EBNF 文法在 Spirit 中可以寫成如下形式:

    group       = '(' >> expression >> ')';
    factor      = integer | group;
    term        = factor >> *(('*' >> factor) | ('/' >> factor));
    expression  = term >> *(('+' >> term) | ('-' >> term));

看起來語法好像差不多,只是運算子有些不同,需要指明的是,在 Spirit 中一個 rule 就是一個 parser 物件,一個 parser 物件包含了相應的語法規則使得該 parser 只能 parse 符合這些規則的文字,比如說,我們現在想 parse 出一組用逗號隔開的整數(CSV),則我們可以定義如下一個 rule:

boost::spirit::rule<> csv_int = int_p >> *(',' >> int_p);

上述程式碼中,int_p 是 Spirit 內建的一個 rule 或者說 parser,該 parser 專門用於 parse 一個整型(除了 int_p,Spirit 還內建了一系列用於 parse 其它基本資料型別的 parser, 具體列表參考這裡)。parser 與 parser 通過 ">>" 運算子連線在一起後就組成了一個新的 parser,那麼怎麼來使用 csv_int 這個新生成的 parser 呢? Spirit 內建定義了一個函式,原型大概如下:

boost::spirit::parse_info<> parse(const char* text, parser, separator);

通過呼叫 parse() 函式傳入需要 parse 的文字與相應的 parser 就能對該文字進行相應的解釋,返回結果會指明 parse 的過程是否成功了,及如果出錯,在哪個位置出錯了。

Semantic actions

前面定義的 csv_int 這個 parser 雖然定義了文法,但它基本沒做什麼事情,只能用來檢查一下某段文字是不是一組逗號隔開的整型,功能顯然太弱了,因為通常來說,我們是需要從文字中提取出資料來的,因此 parse 的過程需要支援某些動作,我們需要 parser 在 parse 到某些內容時,能夠執行使用者指定的行為動作,在 Spirit 中,這個些動作就叫作 semantic action.

Semantic action 是屬於一個 parser 的,它的意義在於指明當該 parser 執行成功了之後,要執行哪些操作,而這些操作是由使用者指定的。我們可以通過如下方式將一個 semantic action 與一個 parser 聯絡起來:

parser[func];

至於 func 的原型,當然是有要求的,而且這個要看具體的 parser, 比如說 int_p 這樣的 parser,它就只能接受 void func(const int val); 這樣的函式,很簡潔的語法!現在我們來在將前面用於解釋一組整型的程式碼中加入一個新功能,在 parse 完每一個整型後,我們將得到的資料儲存下來。

#include <vector>
#include <boost/spirit.hpp>
#include <boost/spirit/include/qi.hpp>
#include <boost/spirit/include/phoenix_core.hpp>
#include <boost/spirit/include/phoenix_object.hpp>

std::vector<int> g_output;

int on_parse_int(const int val)
{
   g_output.push_back(val);
}

int main()
{
   boost::spirit::rule<> int_csv_rule = int_p[on_parse_int] >> *(',' >> int_p[on_parse_int]);
   boost::spirit::parse("2,3,4", int_csv_rule);
   return 0;
}

semantic action 的實現依賴於 boost 裡另一個名聲顯赫的函式式模板庫:phoenix, 上面的例子只是一個簡單示範,未及冰山一角,spirit 其實還支援用 lambda 來寫回撥函式,以及用閉包來在不同的 rule 之間傳遞使用者定義的上下文資訊等,功能很強大,有興趣的讀者可以參考下這裡相對完整點的一個例子,它實現了一個簡單的四則運算及基本的函式呼叫。

生成的 Parser 的型別

Parser 是整個 Spirit 庫的核心功能所在,那麼 Spirit 生成的 parser 是怎麼進行工作呢? 結論是,spirit 所生成的 parser 就是所謂的 LL recursive decent parser,因此 parse 的時候是從左往右掃描輸入,而對 parser 中的文法,採取先左後右的順序進行匹配的,因此左遞迴之類的問題需要使用者自己消除,對如下一個例子:

rule<> rule1 = (int_p >> ',' >> int_p) | real_p;

rule1 在 parse 文字時,會優先匹配 (int_p >> ',' >> int_p),如若失敗,則再去匹配 real_p。

優缺點

因為使用該庫的時間還不是很長,初步上手的感覺,優點上個人覺得有如下幾點:

  1. 使用非常方便,尤其當要 parse 一些不太複雜的文字時,寫程式碼的效率很高。
  2. 生成的 parser 執行效率也很好。

結論就是:很好很強大,但與此同時,缺點也明顯:

  1. 錯誤提示不夠友好。當一段文字格式上有錯誤時,spirit 直接從出錯的地方返回但卻不提供相應的錯誤資訊,上層的程式碼只知道在哪裡出了錯,卻不知道是因什麼出了錯,因此很難生成一個有意義的錯誤提示。
  2. 該庫的實現大量使用了模板及符號過載,程式碼寫起來很酷炫,但是一旦出錯,錯誤提示基本沒有包含太多有意義的資訊,因此除錯起來很痛苦,尤其是在使用不夠熟練的情況下,因此個人建議在使用 Spirit 時,最好把任務進行適當分解,每完成一個小的任務就先測試確認它功能正常穩定再進行下一步,不要一下子寫一大堆程式碼,先不說功能如不正常難以除錯,甚至編譯錯誤時,找到出錯的地方都困難。
  3. 該庫在實現上嚴重依賴模板超程式設計,使用了諸多如 expression template 這樣的 coding idiom,parser 的生成實際上是在編譯階段完成,因此當你寫的 rule 很複雜時,編譯時間會很長,真的很長。

【參考】

http://boost-spirit.com/distrib/spirit_1_8_3/libs/spirit/doc/quick_start.html
http://en.highscore.de/cpp/boost/

相關文章