使用 goyacc 工具構建語法分析程序
使用 goyacc 工具構建語法分析程序
前言
本文仅讨论 goyacc 工具的应用, 而不是编译原理的基础知识. 故想要流畅地阅读本文, 需要首先理解以下问题:
- 词法分析, 语法分析分别是什么?
- 正规文法, 上下文无关文法, 上下文有关文法有何区别?
- 终结符, 非终结符各指代什么?
想要更好地运用 goyacc, 可能还需要掌握以下技能:
- 编写词法分析模式.
- 编写文法推导规则.
- 消除文法左递归.
- 自顶向下和自底向上分析算法.
goyacc 規則文件格式
goyacc 与 yacc 规则文件格式是一致的, 如果你熟知 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 { ... }
| factor { ... }
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.output 和 y.go 文件, 其中 y.output 对我们来说是无用的, 可以通过 -v 参数指向到 /dev/null 或添加 gitignore 配置忽略之. 如果规则常常变更, 可以考虑增加 -l 参数以去除生成 y.go 文件中的行号注释, 这样在 git diff 中不会看到这些无用的变更行.
生成的 *.go 文件中, 注意以下事实:
type yySymType struct {
yys int
Bool bool
Int int64
String string
Expr *ast.Expr
Term *ast.Term
}
const _Not = 57346
const _And = 57347
const _Or = 57348
// ...
var yyDebug = 0
var yyErrorVerbose = false
type yyLexer interface {
Lex(lval *yySymType) int
Error(s string)
}
type yyParser interface {
Parse(yylex yyLexer) int
Lookahead() int
}
func yyParse(yylex yyLexer) int {
return yyNewParser().Parse(yylex)
}
- 在
%union声明的类型, 会定义为一个struct的字段, 对$$,$1等的访问和修改最终会作用到这个结构体对象的成员. - 所有命名终结符 (如
_And,_Ident等, 而不是'+','('等) 都定义为枚举常量, 这可以在词法分析中作为词法记号返回. - 两个包内可见的静态变量
yyDebug和yyErrorVerbose. 前者定义调试信息打印级别, 范围0-4, 置为 0 时不打印; 后者决定是否在分析出错时返回更详细的错误信息. 我们可以编写一个init()函数将之初始化为需要的值, 或编写函数 (如SetDebugLevel(lvl int)) 来将控制权导出到包外. - 两个接口
yyLexer和yyParser分别定义了词法分析器和语法分析器接口, 其中后者已经自动实现为yyParserImpl, 而词法分析器需要我们来实现. - 函数
yyParse(yyLexer) int封装了分析过程, 我们需要实现并传入一个词法分析器接口对象, 分析完成后返回 0 或 1 表明是否有分析错误.
阅读
func (yyrcvr *yyParserImpl) Parse(yylex yyLexer) int实现可知, 前文规则区中嵌入的代码, 都在此方法的一个switch块中找到, 故在嵌入块中也可访问函数声明中定义的接收器yyrcvr和参数yylex, 通常我们会将分析结果 (如整型运算结果, 或抽象语法树结构) 保存到yylex对象中. 由于yylex是个接口对象, 实际访问时需要断言:yylex.(*MyLexer).AST = &ast.AST{ Expr: $1 }.
詞法分析
詞法分析器實現
虽然 yyLexer 需要我们自己来编写实现, 实际上并不一定需要从零开始. go 标准库 text/scanner 已经提供了便捷的实现, 我们可以直接基于此编写自己的词法分析器. text/scanner 识别的是 go 风格的符号, 可以通过 go doc text/scanner (注意不是 go/scanner) 查看文档.
扫描文本时, 注意嵌入成员
Scanner.Position和方法Scanner.Pos()的区别. 每次调用Scan()方法后,Position被置为当前扫描符号的起始位置, 而Pos()返回的是紧随其后的字符位置. 但是Init()和Next()会导致Position无效, 其成员Position.Line被置为零, 而Pos()将报告Next()返回的下一个字符之后的位置. 使用Peek()查询下一个字符不改变位置.
由于 yyParse() 只接收一个 yyLexer 参数, 也仅返回成功与否, 故其他参数和分析结果等返回值都需要通过 yyLexer 来存储 (如待扫描的文本流, 分析结果和分析错误等), 一般定义为结构体成员即可.
首先定义我们的词法分析器结构:
type Error struct {
Msg string // 错误消息
Token string // 错误处识别的终结符
Pos scanner.Position // 错误位置
}
// Error implements error.
func (err Error) Error() string {
return fmt.Sprintf(
"[L%d:%d] near '%s': %s",
err.Pos.Line, err.Pos.Column, err.Token, err.Msg,
)
}
type Lexer struct {
scanner *scanner.Scanner // 扫描器
errors []error // 错误列表
result *ast.AST // 分析结果: 抽象语法树
}
// Error implements yyParser.
func (l *Lexer) Error(msg string) {
lexer.errors = append(lexer.errors, Error{
Msg: msg,
Token: lexer.scanner.TokenText(),
Pos: lexer.scanner.Position,
})
}
// Lex implements yyParser.
func (l *Lexer) Lex(lval *yySymType) (token int) {
// ...
}
初始化掃描設置
scanner.Scanner 需要一个 io.Reader 来初始化, 同时重置扫描参数. 一般在准备扫描文本前, 会先调用 Scanner.Init(reader) 初始化配置, 然后访问 Scanner.Mode 成员, 修改需要识别的符号. 例如现在希望识别输入流中的整数, 浮点数, 字符串和标识符, 可以为 Lexer 添加如下方法:
func (l *Lexer) Reset(reader io.Reader) {
if l.scanner == nil {
l.scanner = &scanner.Scanner{}
}
l.scanner.Init(reader)
l.scanner.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanFloats | scanner.ScanStrings
l.scanner.Error = func(_ *scanner.Scanner, msg string) {
l.Error(msg)
}
l.Errors = nil
l.Result = nil
}
手動符號識别
scanner.Scanner 扫描符号时不会识别组合符号 (如 "==", "!=" 等), 故若要识别此类符号, 需要在词法分析器实现中手动实现. 下面就以这两个符号为例, 编写代码如下:
func (l *Lexer) Lex(lval *yySymType) (token int) {
tokenRune := l.scanner.Scan()
if tokenRune == scanner.EOF {
// 返回零表明输入流结束
return
}
// 取 Scanner 扫描的符号文本, 可能是 "=", "123", "foo" 等
text := l.scanner.TokenText()
switch tokenRune {
case '(', ')':
// %token '(' ')'
token = int(tokenRune)
case '=':
if l.scanner.Peek() == '=' {
l.scanner.Next()
// %token _Equals
token = _Equals
} else {
// %token '='
token = int(tokenRune)
}
case '!':
if l.scanner.Peek() == '=' {
l.scanner.Next()
// %token _NotEquals
token = _NotEquals
} else {
// %token _Not
token = _Not
}
case scanner.Ident:
// %token <String> _Ident
token = _Ident
lval.String = text
default:
// unknown tokens
token = int(tokenRune)
}
return
}
这个词法分析器将返回 '(', ')', '=', "==", "!=", '!', _Ident (标识符) 和其他未能识别的字符.
實例: 編寫四則運算程序
先编写文法规则. 前文已经给出了一个 E,T,F 形式的定义, 这里给出另一个等价定义:
E -> E + E | E - E
E -> E * E | E / E
E -> ( E ) | i
到这里为止, 此文法与前文给出的文法还不是等价的, 我们还需要声明运算符 '*', '/' 的优先级高于 '+', '-'. 于是得到以下规则:
%{
// ...
%}
%union {
Int int64
}
%type <Int> expr
%token '+' '-' '*' '/' '(' ')'
%token <Int> _Int
%left '+' '-'
%left '*' '/'
%%
root:
expr {
yylex.(*Lexer).val = $1
}
expr:
_Int {
$$ = $1
}
| '(' expr ')' {
$$ = $2
}
| expr '+' expr {
$$ = $1 + $3
}
| expr '-' expr {
$$ = $1 - $3
}
| expr '*' expr {
$$ = $1 * $3
}
| expr '/' expr {
$$ = $1 / $3
}
%%
注意到这里定义了一个 root 非终结符作为起始符号, 这是为了得到一个非递归的起始符号, 从而在此符号的嵌入规则中将最终分析结果收集到 yylex 中.
规则需要识别 '+', '-', '*', '/', '(', ')', _Int 终结符, 故实现词法分析器:
type Lexer struct {
scanner *scanner.Scanner // import "text/scanner"
val int64
}
func (l *Lexer) Reset(reader io.Reader) {
if l.scanner == nil {
l.scanner = &scanner.Scanner{}
}
l.scanner.Init(reader)
l.scanner.Mode = scanner.ScanInts // 只识别整型
l.scanner.Error = func(_ *scanner.Scanner, msg string) {
l.Error(msg)
}
l.val = 0
}
// Error 处理错误. 这里直接输出到控制台
func (l *Lexer) Error(s string) {
log.Printf("error: %s", s)
}
// Lex 分析并返回下一个终结符
func (l *Lexer) Lex(lval *yySymType) (token int) {
tokenRune := l.scanner.Scan()
if tokenRune == scanner.EOF {
return
}
text := l.scanner.TokenText()
switch tokenRune {
case '(', ')', '+', '-', '*', '/':
token = int(tokenRune)
case scanner.Int:
// 识别到整型后, 记录值并返回终结符 _Int
lval.Int, _ = strconv.ParseInt(text, 10, 64)
token = _Int
default:
token = int(tokenRune)
}
return
}
编写主函数, 完成计算器程序:
func main() {
yyErrorVerbose = true
repl(os.Stdin)
}
// repl 交互式计算器, 逐行分析并输出计算结果
func repl(reader io.Reader) {
scan := bufio.NewScanner(reader)
lexer := Lexer{}
for scan.Scan() {
if len(scan.Bytes()) == 0 {
break
}
lexer.Reset(bytes.NewReader(scan.Bytes()))
ret := yyParse(&lexer)
if ret == 0 {
log.Printf("res: %d", lexer.val)
}
}
}
拓展练习:
- 本例只实现了正数的识别和计算, 可以修改推导规则以识别负数.
- 本例只实现了整数的识别, 可以定义在词法分析器中将
Int和Float都识别为_Num来实现浮点数计算. - 可以增加开方和取幂等运算, 丰富计算器功能.
- 识别标识符
res, 取上一个计算结果作为操作数.

浙公网安备 33010602011771号