使用 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 { ... }
    | 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.outputy.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 等, 而不是 '+', '(' 等) 都定义为枚举常量, 这可以在词法分析中作为词法记号返回.
  • 两个包内可见的静态变量 yyDebugyyErrorVerbose. 前者定义调试信息打印级别, 范围 0-4, 置为 0 时不打印; 后者决定是否在分析出错时返回更详细的错误信息. 我们可以编写一个 init() 函数将之初始化为需要的值, 或编写函数 (如 SetDebugLevel(lvl int)) 来将控制权导出到包外.
  • 两个接口 yyLexeryyParser 分别定义了词法分析器和语法分析器接口, 其中后者已经自动实现为 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)
		}
	}
}

拓展练习:

  • 本例只实现了正数的识别和计算, 可以修改推导规则以识别负数.
  • 本例只实现了整数的识别, 可以定义在词法分析器中将 IntFloat 都识别为 _Num 来实现浮点数计算.
  • 可以增加开方和取幂等运算, 丰富计算器功能.
  • 识别标识符 res, 取上一个计算结果作为操作数.

實例: 構建抽象語法樹

附錄

posted @ 2024-07-17 21:22  王牌饼干  阅读(3)  评论(0编辑  收藏  举报