Yacc使用優先順序

cuishengli發表於2021-02-04

Yacc使用優先順序

本示例是龍書4.9.2的示例,見圖4-59。

和前一章一樣,新建xUnit專案,用F#語言。起個名C4F59安裝NuGet包:

Install-Package FSharpCompiler.Yacc
Install-Package FSharpCompiler.Parsing
Install-Package FSharp.xUnit
Install-Package FSharp.Literals
Install-Package FSharp.Idioms

編寫語法輸入檔案C4F59.yacc:

lines : lines expr "\n"
      | lines "\n"
      | /* empty */
      ;
expr : expr "+" expr
     | expr "-" expr
     | expr "*" expr
     | expr "/" expr
     | "(" expr ")"
     | "-" expr %prec UMINUS
     | NUMBER
     ;

%%

%left "+" "-"
%left "*" "/"
%right UMINUS

當需要指定運算子號的優先順序時,文法輸入檔案的結構為:

rule list
%%
precedence list

多行註釋同C語言語法/* .*? */,不可以巢狀。將被忽略,不放入結果序列。

%prec UMINUS 用於為規則命名,這個名稱被優先順序定義引用。

優先順序規則同龍書中yacc的規則相同。這裡暫且不深入分解。

文法的寫作並非一蹴而就,需要一些手段技巧編寫。此處,暫且直接輸入書中現成的文法,如何從零開始寫文法檔案,將下一章詳細介紹。

輸入檔案完成,我們可以解析輸入檔案,得到結構化的資料。我們首先新建一個測試檔案。然後將下面程式碼放到一個測試中:

    let path = Path.Combine(__SOURCE_DIRECTORY__, @"C4F59.yacc")
    let text = File.ReadAllText(path)
    let yaccFile = YaccFile.parse text

我們得到YaccFile的結構化資料,就是yaccFile變數中。

        let y = {
            mainRules=[
                ["lines";"lines";"expr";"\n"];
                ["lines";"lines";"\n"];
                ["lines"];
                ["expr";"expr";"+";"expr"];
                ["expr";"expr";"-";"expr"];
                ["expr";"expr";"*";"expr"];
                ["expr";"expr";"/";"expr"];
                ["expr";"(";"expr";")"];
                ["expr";"-";"expr"];
                ["expr";"NUMBER"]
                ];
            precedences=[
                LeftAssoc,[TerminalKey "+";TerminalKey "-"];
                LeftAssoc,[TerminalKey "*";TerminalKey "/"];
                RightAssoc,[ProductionKey ["expr";"-";"expr"]]
                ]
            }

這個結構化資料,排除了註釋和多餘的空白,整理後,放入一個記錄中。注意輸入中的%prec已經被消除,整理成等價的形式。F#是值相等,所以,儘管引用不相等,值相等的產生式仍然被當作一個資料。

此時我們可以列印解析表資料。

    let yacc = ParseTable.create(yaccFile.mainRules, yaccFile.precedences)

    [<Fact>]
    member this.``generate parse table``() =
        let result =
            [
                "let rules = " + Render.stringify yacc.rules
                "let kernelSymbols = " + Render.stringify yacc.kernelSymbols
                "let parsingTable = " + Render.stringify yacc.parsingTable
            ] |> String.concat System.Environment.NewLine
        output.WriteLine(result)

建立一個新模組,將列印的三個值,複製到模組中。程式碼類似:

module C4F59ParseTable

let rules = set [["";"lines"];["expr";"(";"expr";")"];....]
let kernelSymbols = Map.ofList [1,"lines";2,"(";3,"expr";....]
let parsingTable = set [0,"",-8;0,"\n",-8;0,"(",-8;....]

F#的檔案是有依賴順序的,這個模組應該在測試類之前新增。為了保證生成資料的完整性,新增一個驗證Fact:

    [<Fact>]
    member this.``validate parse table``() =
        Should.equal yacc.rules         C4F59ParseTable.rules
        Should.equal yacc.kernelSymbols C4F59ParseTable.kernelSymbols
        Should.equal yacc.parsingTable  C4F59ParseTable.parsingTable

FSharpCompiler.Yacc的主要任務生成語法解析表,到這裡就完成了,下面介紹解析器的編寫。

定義文法的輸入型別:

首先,用如下方法,看看文法中用到了哪些個詞法符記:

    [<Fact>]
    member this.``terminals``() =
        let grammar = Grammar.from yaccFile.mainRules
        let terminals = grammar.symbols - grammar.nonterminals
        let result = Render.stringify terminals
        output.WriteLine(result)

輸出一個字串集合:

set ["\n";"(";")";"*";"+";"-";"/";"NUMBER"]

很好,現在我們一對一,定義文法的輸入型別,詞法符記:

type C4F59Token =
    | EOL
    | LPAREN
    | RPAREN
    | STAR
    | DIV
    | PLUS
    | MINUS
    | NUMBER of int

    member this.getTag() =
        match this with
        | EOL -> "\n"
        | LPAREN -> "("
        | RPAREN -> ")"
        | STAR -> "*"
        | DIV -> "/"
        | PLUS -> "+"
        | MINUS -> "-"
        | NUMBER _ -> "NUMBER"

可區分聯合的getTag成員,是文法輸入終結符號,字串型別,與語義資料的獲取橋樑。

我們先不單元測試,我們先繼續完成詞法分析器,下面是將輸入變成詞法符記的程式:

open FSharp.Literals.StringUtils
open System

type ....

    static member from (text:string) =
        let rec loop (inp:string) =
            seq {
                match inp with
                | "" -> ()

                | Prefix @"[\s-[\n]]+" (_,rest) // 空白
                    -> yield! loop rest

                | Prefix @"\n" (_,rest) -> //換行
                    yield EOL
                    yield! loop rest

                | PrefixChar '(' rest ->
                    yield LPAREN
                    yield! loop rest

                | PrefixChar ')' rest ->
                    yield RPAREN
                    yield! loop rest

                | PrefixChar '*' rest ->
                    yield STAR
                    yield! loop rest

                | PrefixChar '/' rest ->
                    yield DIV
                    yield! loop rest

                | PrefixChar '+' rest ->
                    yield PLUS
                    yield! loop rest

                | PrefixChar '-' rest ->
                    yield MINUS
                    yield! loop rest

                | Prefix @"\d+" (n,rest) ->
                    yield  NUMBER(Int32.Parse n)
                    yield! loop rest

                | never -> failwith never
            }
        loop text

詞法分析器利用兩個活動模式Prefix,和PrefixCharPrefix檢測輸入的頭部是否匹配給定的正規表示式,如果匹配,將字串分為兩部分,頭部的匹配的子字串,和剩餘部分的字串。如:

| Prefix @"\d+" (n,rest) ->

會成功匹配字串"123xyz..."。並返回元組為"123""xyz..."前者賦值給n,後者賦值給rest

PrefixChar檢測輸入的第一個字元是否是給定的字元,如果是,將返回除去頭部字元的剩餘部分的字串。如:

| PrefixChar '-' rest ->

會成功匹配字串"-123"。並返回給引數rest"123"

測試詞法分析器:

    [<Fact>]
    member this.``tokenize``() =
        let inp = "-1/2+3*(4-5)" + System.Environment.NewLine
        let tokens = C4F59Token.from inp
        let result = Render.stringify (List.ofSeq tokens)
        output.WriteLine(result)

得到結果:

[MINUS;NUMBER 1;DIV;NUMBER 2;PLUS;NUMBER 3;STAR;LPAREN;NUMBER 4;MINUS;NUMBER 5;RPAREN;EOL]

有了解析表資料,我們編寫解析器程式碼:

module C4F59.C4F59Parser

open FSharpCompiler.Parsing

let parser =
    SyntacticParser(
        C4F59ParseTable.rules,
        C4F59ParseTable.kernelSymbols,
        C4F59ParseTable.parsingTable
        )

let parseTokens tokens =
    parser.parse(tokens,fun (tok:C4F59Token) -> tok.getTag())

我們首先開啟名字空間FSharpCompiler.Parsing,同名NuGet包,利用SyntacticParser型別構造解析器,解析器是單例的,只需要初始化構造一次即可。解析方法的第一個引數是詞法符記的序列,第二個引數是一個函式,用來告訴解析方法如何獲得語義資料型別的標籤字串,作為文法的終結符號。

我們測試這個方法:

    [<Fact>]
    member this.``parse tokens``() =
        let tokens = [MINUS;NUMBER 1;DIV;NUMBER 2;PLUS;NUMBER 3;STAR;LPAREN;NUMBER 4;MINUS;NUMBER 5;RPAREN;EOL]
        let tree = C4F59Parser. parseTokens tokens
        let result = Render.stringify tree
        output.WriteLine(result)

輸出結果如下:

        let y = Interior("lines",[
            Interior("lines",[]);
            Interior("expr",[
                Interior("expr",[
                    Interior("expr",[Terminal MINUS;Interior("expr",[Terminal(NUMBER 1)])]);
                    Terminal DIV;
                    Interior("expr",[Terminal(NUMBER 2)])]);
                Terminal PLUS;
                Interior("expr",[
                    Interior("expr",[Terminal(NUMBER 3)]);
                    Terminal STAR;
                    Interior("expr",[
                        Terminal LPAREN;
                        Interior("expr",[
                            Interior("expr",[Terminal(NUMBER 4)]);
                            Terminal MINUS;
                            Interior("expr",[Terminal(NUMBER 5)])]);
                        Terminal RPAREN])])]);
            Terminal EOL])

資料已經整理成為樹形,但是這個型別過於通用,我們可以遍歷樹,根據樹上面的資料轉換為更專用的資料。我們定義一個表示式資料型別。

type C4F59Expr =
    | Add      of C4F59Expr * C4F59Expr
    | Sub      of C4F59Expr * C4F59Expr
    | Mul      of C4F59Expr * C4F59Expr
    | Div      of C4F59Expr * C4F59Expr
    | Negative of C4F59Expr
    | Number   of int

下面是轉換模組:

module C4F59.C4F59Translation

open FSharpCompiler.Parsing

/// 
let rec translateExpr = function
    | Interior("expr",[e1;Terminal PLUS;e2;]) ->
        C4F59Expr.Add(translateExpr e1, translateExpr e2)
    | Interior("expr",[e1;Terminal MINUS;e2;]) ->
        C4F59Expr.Sub(translateExpr e1, translateExpr e2)
    | Interior("expr",[e1;Terminal STAR;e2;]) ->
        C4F59Expr.Mul(translateExpr e1, translateExpr e2)
    | Interior("expr",[e1;Terminal DIV;e2;]) ->
        C4F59Expr.Div(translateExpr e1, translateExpr e2)
    | Interior("expr",[Terminal LPAREN;e;Terminal RPAREN;]) ->
        translateExpr e
    | Interior("expr",[Terminal MINUS;e;]) ->
        C4F59Expr.Negative(translateExpr e)
    | Interior("expr",[Terminal (NUMBER n);]) ->
        C4F59Expr.Number n
    | never -> failwithf "%A" never.firstLevel

/// 
let rec translateLines tree = 
    [
        match tree with
        | Interior("lines",[lines;expr;Terminal EOL]) ->
            yield! translateLines lines
            yield translateExpr expr
        | Interior("lines",[lines;Terminal EOL]) ->
            yield! (translateLines lines)
        | Interior("lines",[]) ->
            ()
        | _ -> failwithf "%A" tree.firstLevel
    ]

這裡函式的輸入引數型別是ParseTree型別,此型別位於FSharpCompiler.Parsing中,所以先開啟名字空間。這個轉譯函式對應yacc輸入檔案的文法,每個函式對應一組產生式,依賴最少的非終結符號先定義。對於每個函式的每個匹配項對應一個產生式,匹配項一定是形如:

| Interior("left side",[symbol1;symbol2;....]) ->

文法的產生式一定對應節點Interior,節點的標籤一定是產生式左側的那個非終結符號,節點的子節點依次對應產生式右側的元素。個數是相等的,空產生式對應樹節點子節點列表也是空列表。如果子節點為終結符號則,如果位元組的為非終結符號,定義一個值,以遞迴呼叫翻譯函式。如果是終結符號,則顯式列出,以匹配同一文法符號的不同產生式的特徵。

        | Interior("lines",[lines;expr;Terminal EOL]) ->
            yield! (translateLines lines)
            yield expr

對應產生式:

lines : lines expr "\n"

產生式對應Interior"lines"對應左側的文法符號;子節點列表,[lines;expr;Terminal EOL]對應產生式: lines expr "\n";其中lines對應linesexpr對應exprTerminal EOL對應"\n"

非終結符號對應的子樹將會被遞迴翻譯。如果確定無用,也可能被直接丟棄。終結符號對應的子樹將會被提取有用的資料被轉化成新的資料形式,或無用的資料被丟棄。

每組產生式的最後有一個預設的後備匹配專案,正確的程式永遠用不到,如果用到只會用到輸入樹第一層的子樹資料,即可以確定錯誤:

| _ -> failwithf "%A" tree.firstLevel

測試:

    [<Fact>]
    member this.``translate to expr``() =
        let tokens = [MINUS;NUMBER 1;DIV;NUMBER 2;PLUS;NUMBER 3;STAR;LPAREN;NUMBER 4;MINUS;NUMBER 5;RPAREN;EOL]
        let tree = C4F59Parser. parseTokens tokens
        let exprs = C4F59Translation.translateLines tree

        let result = Render.stringify exprs
        output.WriteLine(result)

輸出結果:

[Add(Div(Negative(Number 1),Number 2),Mul(Number 3,Sub(Number 4,Number 5)))]

是不是清爽多了。

本章介紹瞭如何編譯帶優先順序的文法,這個優先順序已經是別人除錯正確的文法。下一章將介紹如何從零寫優先順序。

相關文章