使用 goyacc 工具構建語法分析程序

使用 goyacc 工具構建語法分析程序

前言

本文仅讨论 goyacc 工具的应用, 而不是编译原理的基础知识. 故想要流畅地阅读本文, 需要首先理解以下问题:

  • 词法分析, 语法分析分别是什么?
  • 正规文法, 上下文无关文法, 上下文有关文法有何区别?
  • 终结符, 非终结符各指代什么?

想要更好地运用 goyacc, 可能还需要掌握以下技能:

  • 编写词法分析模式.
  • 编写文法推导规则.
  • 消除文法左递归.
  • 自顶向下和自底向上分析算法.

goyacc 規則文件格式

goyaccyacc 规则文件格式是一致的, 如果你熟知 yacc, 想必对此也不会陌生. 规则文件一般以 *.y 作为扩展名, 由三部分内容构成, 其间以 %% 进行分隔.

定義區

代碼块

定义区主要功能是添加类型定义, 标识符定义, 包声明和引用, 这些代码将会被复制到生成文件的开头. 编写 goyacc 规则时, 首先添加一个代码块:

%{
package myparser

import (
    "errors"
    "fmt"
    "strings"
)
%}

引用块分别以 %{%} 起始和结束, 用来在最终生成的 *.go 文件开头处插入代码. 这里声明包名为 myparser, 并导入了三个标准库, 用于在之后的规则区嵌入代码中使用.

类型定义

程序在处理文法规则时, 通常一些特定的符号需要携带一些信息, 这时就需要为这些符号指定附加数据的类型, 我们可以使用 %union 来实现这一目标:

%union {
    Bool   bool     // 将 Bool 映射为 bool 类型
    Int    int64
    String string

    Expr *ast.Expr  // 用 ast 包定义的 Expr 类型来存放抽象语法树信息
}

类型定义将在规则区的嵌入代码中使用, 后文会介绍这些类型如何发挥作用.

符号定义

文法推导规则是由终结符和非终结符构成的, 这些符号在 *.y 文件中通过 %type%token 来声明:

%type       expr        // 定义非终结符
%type <Int> value       // 定义非终结符, 带有 Int 型信息
%token          '+' '-' '*' '/' // 定义终结符
%token <String> IDENT           // 定义标识符, 带有 String 型信息

一般习惯用小写单词 (如 expr)命名非终结符, 大写单词 (如 IDENT) 命名终结符. 这些符号最终会作为枚举常量添加到生成的 *.go 文件中, 如果不希望导出符号, 也可使用 _Ident 等形式来命名终结符. 此外, '+' 这类单个字符也可直接定义为终结符, 不需要命名.

这里的 <Tag> 用于指定 %union 块中已定义的类型, 在文法推导规则中, 相应的符号将携带指定类型的信息.

默认情况下, 规则区定义的首个非终结符将作为文法的起始符号, 但也可以通过 %start 显式指定:

%start expr // 显式指定 expr 为起始符号

运算符结合性

运算符本身也属于终结符, 与一般性的终结符不同的是它考虑结合性和优先级. 分别使用 %left%right 定义左结合和右结合运算符, 或使用 %nonassoc 来声明此运算符不与前后的其他终结符结合. 同一行定义的运算符具有相同的优先级, 不同行中, 后定义的运算符具有更高的优先级. 例:

%left '+' '-' // 定义左结合运算符
%left '*' '/' // 定义左结合运算符, 比 '+' '-' 优先级更高
%right NOT    // 定义右结合运算符, 比 '*' '/' 优先级更高

規則區

前文提到, *.y 文件由两个 %% 符号分为三部分, 在前面定义区的内容结束后, 加一行 %% 来接着编写文法规则:

// 定义区

%{
    ...
%}
%union { ... }
%type ...
%token ...

%%

// 规则区

%%

// 函数区

yacc 规则与常见的文法推导式十分相似, 我们很容易将已有的文法编写为 yacc 规则, 以常见的四则运算文法为例:

E -> E + T | E - T
T -> T * F | T / F
F -> ( E ) | i

根据以上文法, 我们可以写出以下规则:

expr:
    expr '+' term {
        $$ = $1 + $3
    }
    | expr '-' term { ... }
    | term { ... }
term:
    term '*' factor { ... }
    | term '/' factor { ... }
    | term { ... }
factor:
    '(' expr ')' { ... }
    | _Int { ... }

上述文法规则中, expr, term, factor 为非终结符, '+', '-', '*', '/', '(', ')', _Int 为终结符. 每一条推导规则由非终结符起始, 接冒号后编写各个推导分支, 分支之间以 | 分隔, 每个分支后用大括号包裹一个嵌入代码块, 用来执行此分支命中时的动作.

在分支的代码块中, 直接嵌入 go 代码, 使用 $$ 来指代此条规则的起始非终结符的附加信息, 即 %type 定义的数据类型, 如第一条规则的第一条分支中, $$ 指代 expr, 其类型可以定义为 %type <Int> expr. 根据当前分支的推导式, 式中的每个元素可用 $1, $2, $3 等符号来引用, 于是此分支实际生成的 go 代码形如:

switch state {
case x:
    // expr: expr '+' term { $$ = $1 + $3 }
    // 其中 $$, $1, $3 均定义为 <Int>, 而 '+' 没有定义附加信息类型, 故不可通过 $2 引用其值
    value.Int = dollar[1].Int + dollar[3].Int
default:
    // 其他状态分支略去
}

作为对照, 下面的例子用来构建抽象语法树:

%union {
    Expr *ast.Expr
    Term *ast.Term
    // ...
}
%type <Expr> expr
%type <Term> term
// ...
%%
// type Expr struct { Terms []*Term }
// type Term struct { ... }
// func NewExpr(*ast.Term) *ast.Expr
// func (*ast.Expr) Add(*ast.Term) *ast.Expr
expr:
    term {
        $$ = ast.NewExpr($1)
    }
    | expr '+' term {
        $$ = $1.Add($3)
    }
// ...
%%

函數區

由于 go 包内函数都是相互可调用的, 也没有先后定义的要求, 故不必在 *.y 文件的函数区编写代码, 而是直接在同目录下创建其他 *.go 文件来添加常量, 类型和函数定义等.

生成 go 代码

goyacc 命名读取 *.y 文件定义的规则, 检查并生成 y.go 文件, 可以通过安装官方 go-tools 包获得:

Usage of goyacc:
  -l    disable line directives
  -o string
        parser output (default "y.go")
  -p string
        name prefix to use in generated code (default "yy")
  -v string
        create parsing tables (default "y.output")

一般来说直接在 abc.y 同目录下执行 goyacc abc.y 即可生成 y.outputy.go 文件, 其中 y.output 对我们来说是无用的, 可以通过 -v 参数指向到 /dev/null 或添加 gitignore 配置忽略之. 如果规则常常变更, 可以考虑增加 -l 参数以去除生成 y.go 文件中的行号注释, 这样在 git diff 中不会看到这些无用的变更行.

詞法分析

實例: 編寫四則運算程序

實例: 構建抽象語法樹

热门相关:医道至尊   聊斋大圣人   剑道邪尊Ⅱ   天帝龙魂   无敌大佬要出世