第 17 章 詞法分析器與剖析器產生器 (ocamllex, ocamlyacc)

本章描述兩個程式產生器:ocamllex,它從一組具有相關語意動作的正規表示式產生詞法分析器;以及 ocamlyacc,它從具有相關語意動作的文法產生剖析器。

這些程式產生器與大多數 C 程式設計環境中常見的 lexyacc 命令非常相似。本章假設讀者已具備 lexyacc 的基本知識:雖然本章會描述 ocamllexocamlyacc 的輸入語法以及與 lexyacc 的主要差異,但不會說明在 lexyacc 中撰寫詞法分析器或剖析器描述的基本概念。不熟悉 lexyacc 的讀者可以參考 Aho、Lam、Sethi 和 Ullman 的「Compilers: principles, techniques, and tools」(Pearson,2006 年),或 Levine、Mason 和 Brown 的「Lex & Yacc」(O'Reilly,1992 年)。

1 ocamllex 概觀

ocamllex 命令會從一組具有附加語意動作的正規表示式產生詞法分析器,風格類似 lex。假設輸入檔案為 lexer.mll,執行

        ocamllex lexer.mll

會在檔案 lexer.ml 中產生詞法分析器的 OCaml 程式碼。此檔案會為詞法分析器定義中每個進入點定義一個詞法分析函式。這些函式與進入點具有相同的名稱。詞法分析函式會將詞法分析緩衝區作為引數,並傳回對應進入點的語意屬性。

詞法分析緩衝區是標準程式庫模組 Lexing 中實作的抽象資料類型。函式 Lexing.from_channelLexing.from_stringLexing.from_function 會分別建立從輸入通道、字元字串或任何讀取函式讀取的詞法分析緩衝區。(請參閱第 ‍29 章中的模組 Lexing 的描述。)

當與 ocamlyacc 產生的剖析器一起使用時,語意動作會計算屬於所產生剖析模組定義的類型 token 的值。(請參閱下方 ocamlyacc 的描述。)

1.1 選項

以下命令列選項可由 ocamllex 辨識。

-ml
輸出不使用 OCaml 內建自動機解譯器的程式碼。相反地,自動機由 OCaml 函式編碼。當使用原生編譯器時,此選項可提升效能,但當使用位元組碼編譯器時,則會降低效能。
-o 輸出檔案
指定 ocamllex 產生的輸出檔案名稱。預設值為輸入檔案名稱,其副檔名會被替換為 .ml
-q
靜音模式。ocamllex 通常會將資訊訊息輸出到標準輸出。如果使用選項 -q,則會抑制這些訊息。
-v-version
列印版本字串並結束。
-vnum
列印簡短版本號碼並結束。
-help--help
顯示簡短的使用摘要並結束。

2 詞法分析器定義的語法

詞法分析器定義的格式如下

{ header }
let ident = regexp …
[refill { refill-handler }]
rule entrypoint [arg1argn] =
  parse regexp { action }
      | …
      | regexp { action }
and entrypoint [arg1argn] =
  parse …
and …
{ trailer }

註解會以 (**) 分隔,如同在 OCaml 中一樣。parse 關鍵字可以由 shortest 關鍵字取代,其語意後果將在下方說明。

重新填入處理常式是 4.02 中引入的最新(可選)功能,將在下方子章節 ‍17.2.7 中說明。

2.1 標頭和尾部

headertrailer 區段是任意的 OCaml 文字,以大括號括住。可以省略其中之一或全部。如果存在,則標頭文字會按原樣複製到輸出檔案的開頭,而尾部文字會複製到結尾。通常,標頭區段包含動作所需的 open 指示詞,以及動作中可能使用的一些輔助函式。

2.2 命名正規表示式

在標頭和進入點之間,可以為頻繁出現的正規表示式命名。其寫法為 let ident = regexp。在遵循此宣告的正規表示式中,識別碼 ident 可以用作 regexp 的簡寫。

2.3 進入點

進入點的名稱必須是 OCaml 值的有效識別碼(以小寫字母開頭)。同樣地,引數 arg1argn 必須是 OCaml 的有效識別碼。每個進入點都會變成一個 OCaml 函式,該函式採用 n+1 個引數,額外的隱式最後一個引數的類型為 Lexing.lexbuf。字元會從 Lexing.lexbuf 引數讀取,並與規則中提供的正規表示式進行比對,直到輸入的前置詞符合其中一個規則。然後會評估對應的動作,並將其作為函式的結果傳回。

如果有數個正規表示式符合輸入的前置詞,則會套用「最長比對」規則:選取符合輸入最長前置詞的正規表示式。如果比對相同,則會選取規則中較早出現的正規表示式。

不過,如果詞法分析器規則是使用 shortest 關鍵字而不是 parse 關鍵字引入的,則會套用「最短比對」規則:選取輸入的最短前置詞。如果比對相同,則仍會選取規則中較早出現的正規表示式。此功能不適用於一般的詞法分析器,它可以方便地將 ocamllex 用作簡單的文字處理工具。

2.4 正規表示式

正規表示式的風格類似 lex,但具有更類似 OCaml 的語法。

regexp::=
' regular-charescape-sequence '
一個字元常數,與 OCaml 字元常數具有相同的語法。比對表示的字元。
_
(底線)比對任何字元。
eof
比對詞法分析器輸入的結尾。
注意:在某些系統上,使用互動式輸入時,檔案結尾後面可能會接續更多字元。不過,ocamllex 無法正確處理包含 eof 後面接續其他內容的正規表示式。
" { string-character } "
一個字串常數,與 OCaml 字串常數具有相同的語法。比對對應的字元序列。
[ character-set ]
比對屬於給定字元集的任何單一字元。有效的字元集包括:單一字元常數 ' c ';字元範圍 ' c1 ' - ' c2 '(包含 c1c2 之間的所有字元);以及兩個或多個字元集的聯集,以串連表示。
[ ^ character-set ]
比對不屬於給定字元集的任何單一字元。
regexp1 # regexp2
(字元集的差異)正規表示式 regexp1regexp2 必須是以 [] 定義的字元集(或單一字元運算式或底線 _)。比對兩個指定字元集的差異。
regexp *
(重複)比對零個或多個符合 regexp 的字串串連。
regexp +
(嚴格重複)比對一個或多個符合 regexp 的字串串連。
regexp ?
(選項)比對空字串,或符合 regexp 的字串。
regexp1 | regexp2
(選擇)匹配符合 regexp1regexp2 的任何字串。如果 regexp1regexp2 都是字元集合,則此建構產生另一個字元集合,該集合是透過取 regexp1regexp2 的聯集而得到的。
regexp1 regexp2
(串接)匹配兩個字串的串接,第一個字串符合 regexp1,第二個字串符合 regexp2
( regexp )
匹配與 regexp 相同的字串。
ident
參考先前透過 let ident = regexp 定義繫結到 ident 的正規表達式。
regexp as ident
regexp 所匹配的子字串繫結到識別符號 ident

關於運算子的優先順序,# 具有最高優先順序,其次是 *+?,然後是串接,然後是 |(選擇),最後是 as

2.5 動作

動作是任意的 OCaml 表達式。它們在一個上下文中求值,其中使用 as 建構所定義的識別符號會繫結到匹配字串的子部分。此外,lexbuf 會繫結到目前的詞法分析器緩衝區。下面列出了一些 lexbuf 的典型用法,以及 Lexing 標準程式庫模組提供的詞法分析器緩衝區操作。

Lexing.lexeme lexbuf
傳回匹配的字串。
Lexing.lexeme_char lexbuf n
傳回匹配字串中的第 n 字元。第一個字元對應於 n = 0。
Lexing.lexeme_start lexbuf
傳回輸入文字中匹配字串開頭的絕對位置(即匹配字串第一個字元的位移量)。從輸入文字讀取的第一個字元的位移量為 0。
Lexing.lexeme_end lexbuf
傳回輸入文字中匹配字串結尾的絕對位置(即匹配字串之後第一個字元的位移量)。從輸入文字讀取的第一個字元的位移量為 0。
entrypoint [exp1expn] lexbuf
(其中 entrypoint 是同一個詞法分析器定義中另一個進入點的名稱。)以遞迴方式在給定的進入點上呼叫詞法分析器。請注意,lexbuf 是最後一個引數。適用於詞法分析巢狀註解等情況。

2.6 正規表達式中的變數

as 建構類似於許多正規表達式套件提供的「群組」。這些變數的類型可以是 stringcharstring optionchar option

我們先考慮線性模式的情況,也就是說,所有 as 繫結的變數都不同的情況。在 regexp as ident 中,ident 的類型通常是 string (或 string option),除非 regexp 是字元常數、底線、長度為 1 的字串常數、字元集規格或這些的選擇。然後,ident 的類型是 char (或 char option)。當整體規則匹配不表示匹配繫結的子模式時,會引入選項類型。特別是 ( regexp as ident ) ?regexp1 | ( regexp2 as ident ) 的情況。

對於 as 繫結的變數,沒有線性限制。當一個變數繫結多次時,先前的規則應擴充如下:

例如,在 ('a' as x) | ( 'a' (_ as x) ) 中,變數 x 的類型為 char,而在 ("ab" as x) | ( 'a' (_ as x) ? ) 中,變數 x 的類型為 string option

在某些情況下,成功的匹配可能不會產生一組唯一的繫結。例如,正規表達式 (('a'|"ab") as x) (("ba"|'a') as y) 匹配 aba 可能會導致將 x 繫結到 "ab" 和將 y 繫結到 "a",或者將 x 繫結到 "a" 和將 y 繫結到 "ba"。在這種不明確的正規表達式上產生的自動機 ocamllex 將會選擇一組可能的結果繫結。所選的繫結組會刻意保持未指定。

2.7 重新填滿處理常式

預設情況下,當 ocamllex 到達詞法分析緩衝區的結尾時,它會靜默呼叫 lexbuf 結構的 refill_buff 函式並繼續詞法分析。有時能夠控制重新填滿動作會很有用;通常,如果您使用用於非同步計算的程式庫,您可能會想要將重新填滿動作包裝在延遲函式中,以避免阻擋同步操作。

自 OCaml 4.02 起,可以指定一個重新填滿處理常式,它是一個在重新填滿時會被呼叫的函式。它會傳遞詞法分析的續程,它對此具有完全的控制權。用作重新填滿動作的 OCaml 表達式應具有的類型是下列的執行個體:

   (Lexing.lexbuf -> 'a) -> Lexing.lexbuf -> 'a

其中第一個引數是續程,它捕捉 ocamllex 通常會執行的處理(重新填滿緩衝區,然後再次呼叫詞法分析函式),且會產生 ['a] 的結果類型應與所有詞法分析規則的結果類型統一。

例如,考慮以下詞法分析器,它針對任意單子進行參數化:

{
type token = EOL | INT of int | PLUS

module Make (M : sig
               type 'a t
               val return: 'a -> 'a t
               val bind: 'a t -> ('a -> 'b t) -> 'b t
               val fail : string -> 'a t

               (* Set up lexbuf *)
               val on_refill : Lexing.lexbuf -> unit t
             end)
= struct

let refill_handler k lexbuf =
    M.bind (M.on_refill lexbuf) (fun () -> k lexbuf)

}

refill {refill_handler}

rule token = parse
| [' ' '\t']
    { token lexbuf }
| '\n'
    { M.return EOL }
| ['0'-'9']+ as i
    { M.return (INT (int_of_string i)) }
| '+'
    { M.return PLUS }
| _
    { M.fail "unexpected character" }
{
end
}

2.8 保留的識別符號

所有以 __ocaml_lex 開頭的識別符號都保留給 ocamllex 使用;請勿在您的程式中使用任何這類識別符號。

3 ocamlyacc 概述

ocamlyacc 命令會從附加語義動作的上下文無關文法規範中產生剖析器,其樣式與 yacc 相似。假設輸入檔案是 grammar.mly,執行

        ocamlyacc options grammar.mly

會在檔案 grammar.ml 中產生剖析器的 OCaml 程式碼,並在檔案 grammar.mli 中產生其介面。

產生的模組會為文法中的每個進入點定義一個剖析函式。這些函式具有與進入點相同的名稱。剖析函式會將詞法分析器(從詞法分析器緩衝區到符記的函式)和詞法分析器緩衝區作為引數,並傳回對應進入點的語義屬性。詞法分析器函式通常是由 ocamllex 程式從詞法分析器規格產生。詞法分析器緩衝區是標準程式庫模組 Lexing 中實作的抽象資料類型。符記是來自具體類型 token 的值,該類型定義在 ocamlyacc 產生的介面檔案 grammar.mli 中。

4 文法定義的語法

文法定義具有下列格式:

%{
  header
%}
  declarations
%%
  rules
%%
  trailer

註解符號如同 OCaml,使用 (**) 來分隔。此外,在「宣告」和「規則」區段中,註解符號也可使用如同 C 語言的 /**/ 來分隔。C 語言風格的註解符號不支援巢狀結構,但 OCaml 風格的註解符號則支援。

4.1 標頭和尾部

標頭和尾部區段是 OCaml 程式碼,會原封不動地複製到檔案 grammar.ml 中。這兩個區段都是可選的。標頭會放在輸出檔案的開頭,通常包含 open 指令以及規則語義動作所需的輔助函式。尾部會放在輸出檔案的結尾。

4.2 宣告

宣告每行一個。它們都以 % 符號開頭。

%token constrconstr
將給定的符號 constrconstr 宣告為詞彙 (終端符號)。這些符號會以常數建構子 (constant constructor) 的形式加入到 token 具體型別中。
%token < typexpr > constrconstr
將給定的符號 constrconstr 宣告為具有附加屬性 (指定型別) 的詞彙。這些符號會以帶有指定型別參數的建構子形式加入到 token 具體型別中。typexpr 部分是任意的 OCaml 型別表達式,但除了標準內建型別外,所有型別建構子名稱都必須是完全限定的 (例如 Modname.typename),即使標頭區段中已給定正確的 open 指令 (例如 open Modname) 也是如此。這是因為標頭只會複製到 .ml 輸出檔案,而不會複製到 .mli 輸出檔案,但 %token 宣告的 typexpr 部分會複製到兩者。
%start symbolsymbol
將給定的符號宣告為文法的進入點。對於每個進入點,輸出模組中會定義一個同名的解析函式。未宣告為進入點的非終端符號則不會有這樣的解析函式。起始符號必須使用下面的 %type 指令給定型別。
%type < typexpr > symbolsymbol
指定給定符號的語義屬性型別。這僅對於起始符號是強制性的。其他非終端符號不需手動給定型別:這些型別會在通過 OCaml 編譯器執行輸出檔案時被推導出來 (除非 -s 選項生效)。typexpr 部分是任意的 OCaml 型別表達式,但如上文 %token 所述,所有型別建構子名稱都必須是完全限定的。
%left symbolsymbol
%right symbolsymbol
%nonassoc symbolsymbol

將優先順序和結合性與給定的符號關聯。同一行上的所有符號都具有相同的優先順序。它們的優先順序高於之前在 %left%right%nonassoc 行中宣告的符號。它們的優先順序低於之後在 %left%right%nonassoc 行中宣告的符號。這些符號會被宣告為向左結合 (%left)、向右結合 (%right) 或不結合 (%nonassoc)。這些符號通常是詞彙。它們也可以是虛擬非終端符號,用於規則內的 %prec 指令。

優先順序宣告會以下列方式用於解決歸約/歸約和移入/歸約衝突:

  • 詞彙和規則都具有優先順序。預設情況下,規則的優先順序是其最右邊終端符號的優先順序。您可以使用規則中的 %prec 指令來覆蓋此預設值。
  • 歸約/歸約衝突會優先選擇第一個規則 (按原始檔案中給定的順序),ocamlyacc 會輸出警告。
  • 移入/歸約衝突會通過比較要歸約的規則的優先順序與要移入的詞彙的優先順序來解決。如果規則的優先順序較高,則會歸約規則;如果詞彙的優先順序較高,則會移入詞彙。
  • 優先順序相同的規則和詞彙之間的移入/歸約衝突將使用結合性來解決:如果詞彙是向左結合的,則解析器會歸約;如果詞彙是向右結合的,則解析器會移入。如果詞彙是不結合的,則解析器會宣告語法錯誤。
  • 當無法使用上述方法解決移入/歸約衝突時,ocamlyacc 會輸出警告,並且解析器始終會移入。

4.3 規則

規則的語法與通常相同:

nonterminal :
    symbolsymbol { semantic-action }
  | …
  | symbolsymbol { semantic-action }
;

規則的右側部分也可以包含 %prec 符號 指令,以使用給定符號的優先順序和結合性覆蓋規則的預設優先順序和結合性。

語義動作是任意的 OCaml 表達式,它們會被求值以產生附加到已定義的非終端符號的語義屬性。語義動作可以使用 $ 符號表示法來訪問規則右側符號的語義屬性:$1 是第一個 (最左邊) 符號的屬性,$2 是第二個符號的屬性,依此類推。

規則可以包含特殊的符號 error 來指示重新同步點,如同 yacc

不支援在規則中間發生的動作。

非終端符號如同常規的 OCaml 符號,除了它們不能以 ' (單引號) 結尾。

4.4 錯誤處理

錯誤恢復支援如下:當解析器到達錯誤狀態 (沒有文法規則可以應用) 時,它會呼叫一個名為 parse_error 的函式,並以字串 "syntax error" 作為引數。預設的 parse_error 函式不做任何事情並返回,從而啟動錯誤恢復 (見下文)。使用者可以在文法檔案的標頭區段中定義自訂的 parse_error 函式。

如果其中一個文法動作引發 Parsing.Parse_error 例外,解析器也會進入錯誤恢復模式。

在錯誤恢復模式下,解析器會從堆疊中捨棄狀態,直到它到達可以移入錯誤詞彙的地方。然後,它會從輸入中捨棄詞彙,直到找到三個連續的可接受詞彙,並從第一個詞彙開始處理。如果無法找到可以移入錯誤詞彙的狀態,則解析器會引發 Parsing.Parse_error 例外而中止。

請參閱有關 yacc 的文件,以獲取有關如何使用錯誤恢復的更多詳細資訊和指南。

5 選項

ocamlyacc 命令識別以下選項:

-bprefix
將輸出檔案命名為 prefix.mlprefix.mliprefix.output,而不是預設的命名慣例。
-q
此選項無效。
-v
產生解析表的描述,以及有關文法中歧義導致的衝突報告。描述會放在檔案 grammar.output 中。
-version
列印版本字串並結束。
-vnum
列印簡短版本號碼並結束。
-
從標準輸入讀取文法規範。預設的輸出檔案名稱是 stdin.mlstdin.mli
-- file
file 作為文法規範處理,即使其名稱以破折號 (-) 字元開頭也是如此。此選項必須是命令列上的最後一個選項。

在執行時,可以通過在 OCAMLRUNPARAM 環境變數中設定 p 選項來對 ocamlyacc 產生的解析器進行偵錯 (請參閱第 15.2 節)。這會導致執行解析器的下推自動機列印其動作的追蹤 (移入的詞彙、歸約的規則等)。追蹤會提到規則編號和狀態編號,可以通過查看 ocamlyacc -v 產生的檔案 grammar.output 來解釋。

6 一個完整的範例

歷久彌新的經典:桌上型計算機。這個程式會讀取標準輸入中的算術表達式,每行一個,並印出它們的值。以下是語法定義

        /* File parser.mly */
        %token <int> INT
        %token PLUS MINUS TIMES DIV
        %token LPAREN RPAREN
        %token EOL
        %left PLUS MINUS        /* lowest precedence */
        %left TIMES DIV         /* medium precedence */
        %nonassoc UMINUS        /* highest precedence */
        %start main             /* the entry point */
        %type <int> main
        %%
        main:
            expr EOL                { $1 }
        ;
        expr:
            INT                     { $1 }
          | LPAREN expr RPAREN      { $2 }
          | expr PLUS expr          { $1 + $3 }
          | expr MINUS expr         { $1 - $3 }
          | expr TIMES expr         { $1 * $3 }
          | expr DIV expr           { $1 / $3 }
          | MINUS expr %prec UMINUS { - $2 }
        ;

以下是相應詞法分析器的定義

        (* File lexer.mll *)
        {
        open Parser        (* The type token is defined in parser.mli *)
        exception Eof
        }
        rule token = parse
            [' ' '\t']     { token lexbuf }     (* skip blanks *)
          | ['\n' ]        { EOL }
          | ['0'-'9']+ as lxm { INT(int_of_string lxm) }
          | '+'            { PLUS }
          | '-'            { MINUS }
          | '*'            { TIMES }
          | '/'            { DIV }
          | '('            { LPAREN }
          | ')'            { RPAREN }
          | eof            { raise Eof }

以下是主程式,它將剖析器與詞法分析器結合在一起

        (* File calc.ml *)
        let _ =
          try
            let lexbuf = Lexing.from_channel stdin in
            while true do
              let result = Parser.main Lexer.token lexbuf in
                print_int result; print_newline(); flush stdout
            done
          with Lexer.Eof ->
            exit 0

要編譯所有內容,請執行

        ocamllex lexer.mll       # generates lexer.ml
        ocamlyacc parser.mly     # generates parser.ml and parser.mli
        ocamlc -c parser.mli
        ocamlc -c lexer.ml
        ocamlc -c parser.ml
        ocamlc -c calc.ml
        ocamlc -o calc lexer.cmo parser.cmo calc.cmo

7 常見錯誤

ocamllex:轉換表溢出,自動機太大

ocamllex產生的確定性自動機最多限制為 32767 個轉換。上面的訊息表示您的詞法分析器定義太複雜,超出了這個限制。這通常是由於詞法分析器定義中,為語言的每個字母關鍵字都設定了單獨的規則,如下例所示。

rule token = parse
  "keyword1"   { KWD1 }
| "keyword2"   { KWD2 }
| ...
| "keyword100" { KWD100 }
| ['A'-'Z' 'a'-'z'] ['A'-'Z' 'a'-'z' '0'-'9' '_'] * as id
               { IDENT id}

為了保持產生的自動機體積小,請使用一個通用的「識別符號」規則重寫這些定義,然後使用雜湊表查找來區分關鍵字和識別符號

{ let keyword_table = Hashtbl.create 53
  let _ =
    List.iter (fun (kwd, tok) -> Hashtbl.add keyword_table kwd tok)
              [ "keyword1", KWD1;
                "keyword2", KWD2; ...
                "keyword100", KWD100 ]
}
rule token = parse
  ['A'-'Z' 'a'-'z'] ['A'-'Z' 'a'-'z' '0'-'9' '_'] * as id
               { try
                   Hashtbl.find keyword_table id
                 with Not_found ->
                   IDENT id }
ocamllex:位置記憶體溢出,綁定太多
ocamllex產生的確定性自動機會維護一個掃描詞法分析器緩衝區內位置的表格。此表格的大小限制為最多 255 個單元格。此錯誤在正常情況下不應出現。
ocamlyacc:並行安全

ocamlyacc產生的剖析器不是執行緒安全的。這些剖析器依賴於一個由所有ocamlyacc產生的剖析器共享的內部工作狀態。如果您需要執行緒安全的剖析器,menhir剖析器產生器是更好的選擇。