使用 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
, 取上一个计算结果作为操作数.
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析