语法分析与 AST:Parser 与 go/ast
一、从 Token 到 AST
词法分析把字符流切成了 Token 流,但 Token 流是"扁平"的------一串线性的 Token 序列。语法分析(Syntax Analysis)的任务是把这些 Token 组织成一棵抽象语法树(AST),赋予它们层次结构和语义关系。
go
Token 流(扁平):
func add ( a , b int ) int { return a + b }
AST(树形):
FuncDecl "add"
├── Recv: (nil)
├── Name: "add"
├── Type: FuncType
│ ├── Params: FieldList
│ │ ├── Field: a int
│ │ └── Field: b int
│ └── Results: int
└── Body: BlockStmt
└── ReturnStmt
└── BinaryExpr (op: +)
├── Ident "a"
└── Ident "b"
AST 是编译器理解你的代码的第一步"理解"------它知道 a + b 是一个加法表达式,return 是一个语句,func add 是一个函数声明。
二、AST 节点体系:go/ast 包
Go 的 AST 节点都实现了 ast.Node 接口:
go
type Node interface {
Pos() token.Pos // 节点起始位置
End() token.Pos // 节点结束位置
}
Node 之下分两大类:
arduino
ast.Node
├── ast.Expr // 表达式(有值)
│ ├── *ast.Ident // 标识符:x, fmt, Person
│ ├── *ast.BasicLit // 字面量:42, "hello", 'a'
│ ├── *ast.BinaryExpr // 二元运算:a + b
│ ├── *ast.UnaryExpr // 一元运算:-x, !flag
│ ├── *ast.CallExpr // 函数调用:f(x, y)
│ ├── *ast.SelectorExpr // 选择器:fmt.Println
│ ├── *ast.IndexExpr // 索引:arr[0](非泛型)
│ ├── *ast.SliceExpr // 切片:s[1:3]
│ ├── *ast.StarExpr // 指针解引用/取址:*p
│ ├── *ast.CompositeLit // 复合字面量:[]int{1,2,3}
│ ├── *ast.FuncLit // 匿名函数:func() {}
│ ├── *ast.ParenExpr // 括号:(x + y)
│ ├── *ast.TypeAssertExpr // 类型断言:x.(int)
│ └── *ast.KeyValueExpr // 键值对:k: v
│
├── ast.Stmt // 语句(有行为,无值)
│ ├── *ast.DeclStmt // 声明语句:var x int
│ ├── *ast.AssignStmt // 赋值:x = 1, y := 2
│ ├── *ast.ExprStmt // 表达式语句:fmt.Println(x)
│ ├── *ast.ReturnStmt // return
│ ├── *ast.IfStmt // if
│ ├── *ast.ForStmt // for
│ ├── *ast.RangeStmt // for ... range
│ ├── *ast.SwitchStmt // switch
│ ├── *ast.CaseClause // case 分支
│ ├── *ast.BlockStmt // {} 块
│ ├── *ast.IncDecStmt // x++ / x--
│ ├── *ast.BranchStmt // break / continue / goto
│ └── *ast.DeferStmt // defer
│
└── ast.Decl // 声明
├── *ast.GenDecl // 通用声明:import / var / const / type
│ └── *ast.ValueSpec // var/const 的单个条目
│ └── *ast.TypeSpec // type 的单个条目
└── *ast.FuncDecl // 函数声明:func add(...)
记住这个层次结构的关键:Expr 有值、Stmt 有行为、Decl 是声明。 三者覆盖了 Go 源码的全部语法元素。
三、go/parser:解析源码生成 AST
go/parser 包提供了从源码到 AST 的解析能力:
go
// 解析单个文件
func ParseFile(fset *token.FileSet, filename string, src interface{}, mode Mode) (*ast.File, error)
// 解析整个目录
func ParseDir(fset *token.FileSet, path string, filter func(fs.FileInfo) bool, mode Mode) (map[string]*ast.Package, error)
mode 参数控制解析的详细程度:
| Mode | 效果 |
|---|---|
0 |
最快,不解析注释 |
parser.ParseComments |
保留注释,关联到最近的声明 |
parser.AllErrors |
收集所有错误而非遇到第一个就停 |
parser.Trace |
打印解析过程(调试用) |
parser.SkipObjectResolution |
跳过标识符对象解析(加速) |
ast.File 的结构
go
type File struct {
Doc *CommentGroup // 文件级注释
Package token.Pos // package 关键字位置
Name *Ident // 包名
Decls []Decl // 所有声明(import/var/const/type/func)
Scope *Scope // 文件级作用域(标识符绑定)
Imports []*ImportSpec // 所有 import
Unresolved []*Ident // 未解析的标识符(待类型检查阶段处理)
Comments []*CommentGroup // 所有注释组
}
File.Decls 是最重要的字段------一个 Go 文件就是一组声明的列表。
四、AST 遍历:ast.Inspect 与 ast.Walk
ast.Inspect:快速遍历
ast.Inspect 是最常用的 AST 遍历方式。它对每个节点调用你的函数,返回 true 继续遍历子节点,返回 false 跳过子树:
go
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
// 找到一个函数调用
fmt.Println("调用:", exprString(call.Fun))
}
return true // 继续遍历子节点
})
ast.Walk:更精细的控制
ast.Walk 需要实现 ast.Visitor 接口,可以在访问节点前后做不同的事:
go
type myVisitor struct{}
func (v *myVisitor) Visit(n ast.Node) ast.Visitor {
if n == nil {
return nil // 子节点遍历完毕
}
// 处理节点...
return v // 继续遍历子节点
}
ast.Walk(&myVisitor{}, f)
ast.Print:调试利器
go
ast.Print(fset, f) // 以缩进格式打印完整 AST 树
五、实战:用 AST 做代码分析
AST 不只是编译器的内部数据结构------go vet、golangci-lint、gopls 全都建立在 go/ast 之上。下面看几个实际的分析场景。
场景 1:统计函数调用
提取源码中所有的函数调用,分析依赖关系:
go
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
// call.Fun 是被调用的函数/方法
// call.Args 是参数列表
switch fn := call.Fun.(type) {
case *ast.Ident:
fmt.Printf("调用普通函数: %s()\n", fn.Name)
case *ast.SelectorExpr:
// fmt.Println → X="fmt", Sel="Println"
fmt.Printf("调用方法: %s.%s()\n",
exprString(fn.X), fn.Sel.Name)
}
}
return true
})
场景 2:提取所有类型定义
go
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec := spec.(*ast.TypeSpec)
fmt.Printf("类型 %s = %s\n",
typeSpec.Name.Name,
exprString(typeSpec.Type))
}
}
场景 3:检测未处理的 error
Go 的 lint 规则之一:函数返回 error 但调用者没有检查。用 AST 实现:
go
ast.Inspect(f, func(n ast.Node) bool {
// 找到赋值语句
assign, ok := n.(*ast.AssignStmt)
if !ok {
return true
}
// 检查是否调用了返回 error 的函数
for _, expr := range assign.Rhs {
call, ok := expr.(*ast.CallExpr)
if !ok {
continue
}
// 这里需要配合 go/types 判断返回类型是否含 error
// 纯 AST 只能看结构,不能确定类型
}
return true
})
注意:纯 AST 分析能看到的只有"语法结构",看不到"类型信息"。完整的静态分析需要 go/types 配合------这就是下一章的内容。
六、AST 与语法错误处理
Go 的 Parser 是容错的------遇到语法错误不会立刻崩溃,而是尽量恢复并继续解析。这意味着即使源码有语法错误,你仍然能得到一个部分完整的 AST。
go
f, err := parser.ParseFile(fset, "broken.go", src, parser.AllErrors)
if err != nil {
// err 可能包含多个错误(scanner.ErrorList)
if errs, ok := err.(scanner.ErrorList); ok {
for _, e := range errs {
fmt.Println(e) // 逐条打印
}
}
// f 可能仍然非 nil------包含部分解析结果
}
这个设计让 IDE 能在有错误的代码上仍然提供补全、跳转等功能。
七、Parser 的实现原理
Go 的 Parser 是手写的递归下降解析器(Recursive Descent Parser),而不是用 yacc/bison 生成的 LALR 解析器。
核心思路:每个语法规则对应一个解析函数。
go
// 简化示意:解析 if 语句
func (p *parser) parseIfStmt() *ast.IfStmt {
p.expect(token.IF) // 消费 if 关键字
init, cond := p.parseInitCond() // 解析 if x; y != nil 中的 init 和 cond
body := p.parseBlock() // 解析 { ... } 块
var elseStmt ast.Stmt
if p.tok == token.ELSE { // 如果有 else
p.next()
elseStmt = p.parseElse()
}
return &ast.IfStmt{If: pos, Init: init, Cond: cond, Body: body, Else: elseStmt}
}
选择递归下降而非生成器的原因:
- 错误恢复更灵活------手写代码可以精确控制错误恢复策略
- 错误信息更友好------能给出"期望 xxx 但找到 yyy"这类精确提示
- 性能更好------没有状态机的额外开销
- Go 1.18 泛型 ------
[T any]语法需要前瞻判断(区分泛型参数和数组下标),手写更容易处理
八、本章要点
| 要点 | 说明 |
|---|---|
| AST = 语法树 | Token 流 → 树形结构,赋予层次关系 |
| 三大节点类型 | Expr(表达式有值)/ Stmt(语句有行为)/ Decl(声明) |
| go/parser | 手写递归下降解析器,容错设计,支持错误恢复 |
| ast.Inspect | 最常用的遍历方式,回调函数返回 bool 控制遍历 |
| 纯 AST 看不到类型 | 需配合 go/types 才能做类型感知的分析 |
| 容错解析 | 有语法错误也能得到部分 AST,IDE 功能不中断 |
一句话总结:go/parser + go/ast 是 Go 生态的语法分析基础设施,让你能用几十行代码实现自定义的代码分析工具------这就是 Go 工具链生态繁荣的根基。