引言:Go 工具链背后的魔法
你是否好奇过,gofmt 是如何瞬间格式化你的代码的?IDE 是如何知道你的函数"未定义"的?或者 golangci-lint 是如何发现你潜藏的 Bug 的?
这一切的幕后黑手,不是魔法,而是 AST(抽象语法树)。
作为一名 Go 开发者,理解 AST 不仅能让你写出更酷的工具(比如自动生成代码),还能让你对编译器的脾气了如指掌。今天,我们就基于 go/ast 包,来一场代码的搭积木之旅。
一、零件分拣:词法分析 (Scanner)
本博文的 go 版本为:go version go1.21.13 darwin/arm64
go
const s = "foo"
在我们眼里,这是赋值。但在编译器眼里,这只是一盒刚倒在地上的、散乱的乐高零件。
编译器的第一道工序叫 scanner。它的工作很简单,就是把源码里的字符一个个拿起来看,然后给它们贴上标签,进行分类。
它不关心你拼的是汽车还是飞机(语法是否正确),它只关心手里拿的是什么零件。这些被分类好的零件,就叫 Token。
Go 的 Token 结构就像一个标签:<零件类型, 具体形状>。
上面的代码会被 Scanner 识别出 5 个零件:
CONST(类型:关键字,形状:"const")IDENT(类型:标识符,形状:"s")ASSIGN(类型:赋值符,形状:"=")STRING(类型:字符串,形状:"foo")EOF(文件结束标记)
下面就用一段代码来实验一把
go
func TestToken(t *testing.T) {
// 1. 准备要分析的代码片段
// 注意:为了演示方便,这里模拟成一个文件
src := []byte(`const s = "foo"`)
// 2. 初始化 FileSet (用于记录位置信息)
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(src))
// 3. 初始化 Scanner (词法扫描器)
var s scanner.Scanner
// Init 参数:文件句柄,源码字节流,错误处理(nil),模式
s.Init(file, src, nil, scanner.ScanComments)
fmt.Println("位置\tToken类型\t字面值(Literal)")
fmt.Println("---------------------------------------")
// 4. 循环扫描,直到文件结束 (EOF)
for {
pos, tok, lit := s.Scan()
// 打印结果
// fset.Position(pos) 把位置整数转为文件名:行:列
fmt.Printf("%s\t%-10s\t%q\n", fset.Position(pos), tok, lit)
if tok == token.EOF {
break
}
}
}
输出
位置 Token类型 字面值(Literal)
---------------------------------------
1:1 const "const"
1:7 IDENT "s"
1:9 = ""
1:11 STRING "\"foo\""
1:16 ; "\n"
1:16 EOF ""
这里需要注意下
- 当你打印 token.ASSIGN 时,Go 会把它显示为人类可读的符号 =。
1:16 ; "\n"我的代码里明明没有写分号;,为什么输出里多了一个分号?这就要说说Go 中自动分号插入规则 (Automatic Semicolon Insertion)- Go 语言虽然在规范里要求每行语句结束要有分号,但在写代码时允许省略。原因就是Scanner(词法分析器)在幕后默默地帮你加上了。
- Scanner 的规则是:如果一行代码以标识符(如 s)、数字、字符串(如 "foo")、return 等结尾, Scanner 就会自动在后面插入一个
;Token。 - 过程为:
- 代码
const s = "foo" - Scanner 读到 "foo" (STRING) 结束。
- Scanner 发现后面换行了(或者文件结束了)。
- 于是它根据规则,自动生成了一个 Token:类型是
;,字面值是\n(代表是因为换行符而触发的)。
- 代码
讲到了 token,就顺带着介绍 Go 中已经定义好的五种。
注:以下 token 介绍的代码位于 src/go/token/token.go
1.特殊类型 (Special Tokens)
go
// Token is the set of lexical tokens of the Go programming language.
type Token int
// The list of tokens.
const (
// Special tokens
ILLEGAL Token = iota
EOF
COMMENT
}
可以看出特殊类型的 Token 有错误、文件结束、注释三种。
这里的 Token 不直接对应代码逻辑,而是用于解析过程的状态标记。
2.标识符与基础字面量 (Identifiers & Literals)
go
const (
......
literal_beg
// Identifiers and basic type literals
// (these tokens stand for classes of literals)
IDENT // main
INT // 12345
FLOAT // 123.45
IMAG // 123.45i
CHAR // 'a'
STRING // "abc"
literal_end
......
)
对应源码中的 literal_beg 到 literal_end,有整数、浮点数、复数、字符串、字符,也就是代码中的"名词"和"数据"。
- 标识符 (IDENT):即代码中的名字。比如你定义的变量名 userName、函数名 main。它们就像句子里的名词,代表了一个个实体。
- 基础字面量 (Literals):即代码中的具体值。比如数字 123、字符串 "hello"。它们是实实在在的数据。
另外需要注意的是:Go 规范中布尔类型的 true 和 false 并不在基础面值类型中。但是为了词法解析方便,go/token 包将 true 和 false 等对应的标识符也作为面值 Token 一类。
- Token 层:true/false 是 IDENT
- AST 层:在表达式处理中,常被当作 BasicLit 使用
3.运算符与分隔符 (Operators & Delimiters)
go
const (
......
operator_beg
// Operators and delimiters
ADD // +
SUB // -
MUL // *
QUO // /
REM // %
AND // &
OR // |
XOR // ^
SHL // <<
SHR // >>
AND_NOT // &^
ADD_ASSIGN // +=
SUB_ASSIGN // -=
MUL_ASSIGN // *=
QUO_ASSIGN // /=
REM_ASSIGN // %=
AND_ASSIGN // &=
OR_ASSIGN // |=
XOR_ASSIGN // ^=
SHL_ASSIGN // <<=
SHR_ASSIGN // >>=
AND_NOT_ASSIGN // &^=
LAND // &&
LOR // ||
ARROW // <-
INC // ++
DEC // --
EQL // ==
LSS // <
GTR // >
ASSIGN // =
NOT // !
NEQ // !=
LEQ // <=
GEQ // >=
DEFINE // :=
ELLIPSIS // ...
LPAREN // (
LBRACK // [
LBRACE // {
COMMA // ,
PERIOD // .
RPAREN // )
RBRACK // ]
RBRACE // }
SEMICOLON // ;
COLON // :
operator_end
......
)
对应源码中的 operator_beg 到 operator_end。这是代码中的"动词"和"标点"。
- 算术与位运算 :
+,-,*,/,%,&,|,^,<<,>>,&^。 - 赋值运算 :
- 普通赋值:
=(ASSIGN)。 - 短变量声明:
:=(DEFINE)。 - 复合赋值:
+=,-=,&=等。
- 普通赋值:
- 逻辑与比较 :
&&,||,!,==,<,>,!=等。 - 结构操作 :
<-(通道),++,--,...(变长参数)。 - 分隔符 (标点) :
(,),[,],{,},,,.,:,;。
4.关键字 (Keywords)
go
const(
......
keyword_beg
// Keywords
BREAK
CASE
CHAN
CONST
CONTINUE
DEFAULT
DEFER
ELSE
FALLTHROUGH
FOR
FUNC
GO
GOTO
IF
IMPORT
INTERFACE
MAP
PACKAGE
RANGE
RETURN
SELECT
STRUCT
SWITCH
TYPE
VAR
keyword_end
......
)
对应源码中的 keyword_beg 到 keyword_end。
- Go 语言保留的 25 个 关键字 (
func,var,if,package,return等)。 - 注意:
true,false,iota,nil不是关键字,而是预定义的标识符 (IDENT)。
5.补充类型 (Additional Tokens)
go
const (
......
additional_beg
// additional tokens, handled in an ad-hoc manner
TILDE
additional_end
)
对应源码中的 additional_beg 之后。
TILDE: 波浪号~。这是 Go 1.18 引入泛型后新增的 Token,用于类型约束中的近似匹配(如~int)。
动手实验环节 :把 const s = "foo" 改成 const s = 1 + 2,观察 Token 的变化
二、依照图纸拼装:语法分析 (Parser)
既然都已经给积木打上标签了,接下来就要借助 Parser 把分类好的零件搭成模型(AST)。
-
核心包 :
go/ast。 -
核心接口
ast.Node:- 所有节点都必须实现
Pos()和End(),这说明任何 AST 节点都知道自己在源码中的位置。
- 所有节点都必须实现
-
三大支柱接口 :
ast.Decl(声明) :顶层结构。GenDecl(通用声明):import,const,type,var都在这里。FuncDecl(函数声明):也就是函数的定义。
ast.Stmt(语句) :动作结构,通常在函数体内部。AssignStmt(a := 1),IfStmt,ForStmt,ReturnStmt等。
ast.Expr(表达式) :求值结构,产生数据的。BinaryExpr(x + y),CallExpr(fmt.Println()),BasicLit("hello").
- 关系 :
Decl包含Stmt(在函数体里),Stmt包含Expr(比如 if 的条件判断)。
注:以下 token 介绍的代码位于 src/go/ast/ast.go
go
// All node types implement the Node interface.
// 所有的 AST 树的节点都需要实现 Node 接口
type Node interface {
Pos() token.Pos // position of first character belonging to the node
End() token.Pos // position of first character immediately after the node
}
// All expression nodes implement the Expr interface.
// 所有的表达式都需要实现 Expr 接口
type Expr interface {
Node
exprNode()
}
// All statement nodes implement the Stmt interface.
// 所有的语句都需要实现 Stmt 接口
type Stmt interface {
Node
stmtNode()
}
// All declaration nodes implement the Decl interface.
// 所有的声明都需要实现 Decl 接口
type Decl interface {
Node
declNode()
}
为什么接口里有私有方法?
你在源码中会看到 exprNode()、stmtNode() 这种没有参数、没有返回值的私有方法。这其实是 Go 开发者的类型安全哲学。
假设没有这些私有方法,任何实现过 Pos() 的结构体都能冒充表达式。而有了 exprNode(),编译器就能确保:只有 ast 包明确定义的结构体,才能被当作表达式使用。
这就像是在乐高零件上加了特殊的凸起,确保只有"动力组"的零件能插在"电池组"上,从根源上防止了你在构建 AST 时张冠李戴。
避坑指南:AST 节点的 Pos() 返回的是 token.Pos(一个整数),必须配合 fset.Position(pos) 才能转成咱们熟知的 line:column。
三、乐高套装详单:AST 节点类型详解
在 Go 的 ast 包中,所有的节点都实现了 ast.Node 接口。但为了区分功能,它们又被细分成了不同的"积木包"。
Node
Decl
*BadDecl
*FuncDecl
*GenDecl
Expr
*ArrayType
*BadExpr
*BasicLit
*BinaryExpr
*CallExpr
*ChanType
*CompositeLit
*Ellipsis
*FuncLit
*FuncType
*Ident
*IndexExpr
*InterfaceType
*KeyValueExpr
*MapType
*ParenExpr
*SelectorExpr
*SliceExpr
*StarExpr
*StructType
*TypeAssertExpr
*UnaryExpr
Spec
*ImportSpec
*TypeSpec
*ValueSpec
Stmt
*AssignStmt
*BadStmt
*BlockStmt
*BranchStmt
*CaseClause
*CommClause
*DeclStmt
*DeferStmt
*EmptyStmt
*ExprStmt
*ForStmt
*GoStmt
*IfStmt
*IncDecStmt
*LabeledStmt
*RangeStmt
*ReturnStmt
*SelectStmt
*SendStmt
*SwitchStmt
*TypeSwitchStmt
*Comment
*CommentGroup
*Field
*FieldList
*File
*Package
看着那几十种节点类型可能会头晕,但其实它们主要遵循一套清晰的继承/实现关系。我们可以把它看作一个家族树:
通用声明(import/var/type/const)
函数声明
错误的声明占位
导入规格
变量/常量规格
类型规格
{ 语句块 }
赋值语句
条件语句
返回语句
语句块里的声明(如局部变量)
包含表达式的语句
错误的语句占位
标识符(名字)
基础字面量(数字/字符串)
二元运算
函数调用
选择器(a.B)
错误的表达式占位
代表整个源文件
<<Interface>>
Node
+Pos() : token.Pos
+End() : token.Pos
<<Interface>>
Decl
<<Interface>>
Stmt
<<Interface>>
Expr
<<Interface>>
Spec
GenDecl
FuncDecl
BadDecl
ImportSpec
ValueSpec
TypeSpec
BlockStmt
AssignStmt
IfStmt
ReturnStmt
DeclStmt
ExprStmt
BadStmt
Ident
BasicLit
BinaryExpr
CallExpr
SelectorExpr
BadExpr
File
我们将列表中最常用的节点拎出来,看看它们分别对应代码里的什么东西:
(1) Decl (Declaration) ------ 声明类
这是代码的顶级骨架。
FuncDecl: 函数声明 。比如func main() { ... }。GenDecl: 通用声明 。这就有点意思了,为什么叫"通用"?因为 Go 把import、const、type、var这种长得像的声明都归到了这里,通过内部的Tok字段来区分具体是哪种。
(2) Spec (Specification) ------ 规格类
它是 GenDecl 的"具体内容"。因为 GenDecl 只是个壳,里面包着什么,由 Spec 决定。
ImportSpec: 描述引入的包(如"fmt")。ValueSpec: 描述变量或常量的值(如x = 1)。TypeSpec: 描述类型的定义(如type MyInt int)。
(3) Stmt (Statement) ------ 语句类
函数体内的执行逻辑,通常是动作。
- 流程控制 :
IfStmt(if),ForStmt(for),SwitchStmt(switch),SelectStmt(select)。 - 赋值与返回 :
AssignStmt(a := b),ReturnStmt(return)。 - Go 特有 :
GoStmt(go func()),DeferStmt(defer close())。
(4) Expr (Expression) ------ 表达式类
这是数量最多的一类,产生值或代表类型的微小单元。
- 字面量 :
BasicLit(数字/字符串),CompositeLit(结构体初始化{a:1}). - 标识符 :
Ident(变量名、包名)。 - 运算 :
BinaryExpr(二元运算+ -),UnaryExpr(一元运算! &). - 类型表达 : 是的,Go AST 里类型也是表达式。
ArrayType([]int),MapType(map[k]v),StructType(struct{...}). - 函数调用 :
CallExpr(len(x)).
(5) 其他辅助节点
File: 代表整个源文件,它是 AST 的根节点。Comment/CommentGroup: 代码里的注释。Field/FieldList: 用于函数参数列表或结构体字段列表。
记忆口诀:
- Decl 定框架,Spec 填内容。
- Stmt 做动作,Expr 算数据。
幕后功臣:BadStmt ------ 编译器给你的温情
这个小节的最后我要提一下 BadStmt。
- 什么是 BadStmt?
想象一下,你正在按照图纸拼装一个复杂的乐高城堡(编写代码)。突然,你拿错了一个形状奇特的零件,或者少拼了一块(语法错误)。
- 如果没有 BadStmt(传统做法):编译器会直接大喊:"报错!我不干了!",然后扔掉整本图纸。结果就是你的 IDE(如 GoLand)后面所有的代码都失去了颜色(全盘报红),因为它不再尝试理解剩下的部分。
- 有了 BadStmt(Go 的做法) :Parser(解析器)非常冷静。它发现这一行代码拼错了,无法识别为一个正常的语句(比如 IfStmt 或 AssignStmt)。于是,它会在这个位置放一块"黑色的废料积木",并给它贴上 BadStmt 的标签。
- 为什么说它是 IDE 的"救星"?
所有的 ast.Node 家族里都有个"倒霉孩子"叫 BadStmt(或者 BadDecl、BadExpr)。
很多人觉得"坏语句"有什么好研究的?但其实它是 IDE(如 GoLand、VSCode)能保持丝滑体验的功臣。
试想: 如果你少写一个括号,整篇代码就瞬间失去高亮、无法跳转、满屏报红,你会不会想砸键盘?
BadStmt 的存在就是为了告诉解析器:"这块地儿的代码写得垃圾,我先用个破麻袋把它装起来(占位),你绕过它,去解析后面的代码。"
正是因为 Parser 这种"局部崩溃、整体坚挺"的设计,才让我们在边写边错的过程中,依然能享受代码补全和静态分析。
四、一个函数的 AST 长什么样?
假设我们有如下源码:
go
package main
import "fmt"
func main() {
fmt.Println("Hello")
}
当我们调用 parser.ParseFile 后,得到的 AST 树结构大致如下(简化版):
- File (整个文件)
Name: main (Ident)- Decls (声明列表):
- GenDecl (import 声明)
Tok: importSpecs: ["fmt"]
- FuncDecl (函数声明)
Name: mainType: func()Body: BlockStmt (函数体代码块)List:- ExprStmt (表达式语句)
X: CallExpr (调用表达式)Fun: SelectorExpr (选择器 fmt.Println)X: fmtSel: Println
Args: ["Hello"] (BasicLit)
- ExprStmt (表达式语句)
- GenDecl (import 声明)
当然了,一开始记得时候可以看看下面的极简版。
File
└── Decls
├── GenDecl → Specs
└── FuncDecl
└── BlockStmt
└── Stmt → Expr
直观感受可以看看下图:
ast.File
FuncDecl: main
FuncType
BlockStmt: 函数体
List: 语句列表
ExprStmt: 表达式语句
CallExpr: 函数调用
Fun: 函数主体
Args: 参数列表
SelectorExpr: 选择器
X: fmt
Sel: Println
BasicLit: 'Hello'
接下来就用代码来分析上面的 Go 源文件吧。
go
func TestAst(t *testing.T) {
srcCode := `
package main
import "fmt"
func main() {
fmt.Println("Hello")
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "dummy.go", srcCode, 0)
if err != nil {
fmt.Printf("err = %s", err)
}
ast.Print(fset, f)
}
其中
go
fset := token.NewFileSet()
会新建一个 AST 文件集合
go
// A FileSet represents a set of source files.
// Methods of file sets are synchronized; multiple goroutines
// may invoke them concurrently.
//
type FileSet struct {
mutex sync.RWMutex // 加锁保护
base int // 基准偏移
files []*File // 按顺序被加入的文件集合
last *File // 最后一个文件缓存
}
然后,解析该集合
go
f, err := parser.ParseFile(fset, "dummy.go", srcCode, 0)
注:parser.ParseFile 的文件名参数(filename)其实可以是一个"虚名"。只要你在第三个参数(src)里提供了源码内容,Parser 就不会去读磁盘。这个特性在写测试用例或编写不需要保存文件的代码分析工具时非常有用!
ParseFile 会解析单个 Go 源文件的源代码并返回相应的 ast.File 节点。源代码可以通过传入源文件的文件名,或 src 参数提供。如果 src!= nil,则 ParseFile 将从 src 中解析源代码,文件名为仅在记录位置信息时使用。
type File struct {
Doc *CommentGroup // associated documentation; or nil
Package token.Pos // position of "package" keyword
Name *Ident // package name
Decls []Decl // top-level declarations; or nil
FileStart, FileEnd token.Pos // start and end of entire file
Scope *Scope // package scope (this file only)
Imports []*ImportSpec // imports in this file
Unresolved []*Ident // unresolved identifiers in this file
Comments []*CommentGroup // list of all comments in the source file
GoVersion string // minimum Go version required by //go:build or // +build directives
}
Golang 官方已经帮我们想好了如何去遍历这棵树
go
// Inspect traverses an AST in depth-first order: It starts by calling
// f(node); node must not be nil. If f returns true, Inspect invokes f
// recursively for each of the non-nil children of node, followed by a
// call of f(nil).
//
func Inspect(node Node, f func(Node) bool) {
Walk(inspector(f), node)
}
上述代码是 Go 官方提供的代码 go/ast,从注释中我们知道 Inspect 函数以深度遍历的方式遍历AST,通过调用 f(node) 开始,节点不能为零。如果 f 返回 true,Inspect 会为节点的每个非零子节点递归调用f,然后调用 f(nil)。
运行输出
0 *ast.File {
1 . Package: dummy.go:2:1
2 . Name: *ast.Ident {
3 . . NamePos: dummy.go:2:9
4 . . Name: "main"
5 . }
6 . Decls: []ast.Decl (len = 2) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: dummy.go:3:1
9 . . . Tok: import
10 . . . Lparen: -
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit {
14 . . . . . . ValuePos: dummy.go:3:8
15 . . . . . . Kind: STRING
16 . . . . . . Value: "\"fmt\""
17 . . . . . }
18 . . . . . EndPos: -
19 . . . . }
20 . . . }
21 . . . Rparen: -
22 . . }
23 . . 1: *ast.FuncDecl {
24 . . . Name: *ast.Ident {
25 . . . . NamePos: dummy.go:4:6
26 . . . . Name: "main"
27 . . . . Obj: *ast.Object {
28 . . . . . Kind: func
29 . . . . . Name: "main"
30 . . . . . Decl: *(obj @ 23)
31 . . . . }
32 . . . }
33 . . . Type: *ast.FuncType {
34 . . . . Func: dummy.go:4:1
35 . . . . Params: *ast.FieldList {
36 . . . . . Opening: dummy.go:4:10
37 . . . . . Closing: dummy.go:4:11
38 . . . . }
39 . . . }
40 . . . Body: *ast.BlockStmt {
41 . . . . Lbrace: dummy.go:4:13
42 . . . . List: []ast.Stmt (len = 1) {
43 . . . . . 0: *ast.ExprStmt {
44 . . . . . . X: *ast.CallExpr {
45 . . . . . . . Fun: *ast.SelectorExpr {
46 . . . . . . . . X: *ast.Ident {
47 . . . . . . . . . NamePos: dummy.go:5:5
48 . . . . . . . . . Name: "fmt"
49 . . . . . . . . }
50 . . . . . . . . Sel: *ast.Ident {
51 . . . . . . . . . NamePos: dummy.go:5:9
52 . . . . . . . . . Name: "Println"
53 . . . . . . . . }
54 . . . . . . . }
55 . . . . . . . Lparen: dummy.go:5:16
56 . . . . . . . Args: []ast.Expr (len = 1) {
57 . . . . . . . . 0: *ast.BasicLit {
58 . . . . . . . . . ValuePos: dummy.go:5:17
59 . . . . . . . . . Kind: STRING
60 . . . . . . . . . Value: "\"Hello\""
61 . . . . . . . . }
62 . . . . . . . }
63 . . . . . . . Ellipsis: -
64 . . . . . . . Rparen: dummy.go:5:24
65 . . . . . . }
66 . . . . . }
67 . . . . }
68 . . . . Rbrace: dummy.go:6:1
69 . . . }
70 . . }
71 . }
72 . FileStart: dummy.go:1:1
73 . FileEnd: dummy.go:6:3
74 . Scope: *ast.Scope {
75 . . Objects: map[string]*ast.Object (len = 1) {
76 . . . "main": *(obj @ 27)
77 . . }
78 . }
79 . Imports: []*ast.ImportSpec (len = 1) {
80 . . 0: *(obj @ 12)
81 . }
82 . Unresolved: []*ast.Ident (len = 1) {
83 . . 0: *(obj @ 46)
84 . }
85 . GoVersion: ""
86 }
我们利用 ast.Print(fset, f) 打印出了 dummy.go 的内部骨架。这串长长的输出并不是乱码,而是一棵层次分明的树。
让我们像剥洋葱一样,由外向内,把这棵树拆解来看。
1. 树根:源文件 (File)
text
0 *ast.File {
1 . Package: dummy.go:2:1
2 . Name: *ast.Ident { "main" }
6 . Decls: []ast.Decl (len = 2) { ... }
...
79 . Imports: []*ast.ImportSpec (len = 1) { ... }
82 . Unresolved: []*ast.Ident (len = 1) { ... }
86 }
*ast.File:这是 AST 的根节点,代表整个源文件。Name:包名节点(这里是main)。Decls:这是最关键 的字段。它是"顶级声明"的列表。Go 程序的顶层通常只有两类东西:- 通用声明(
import,const,type,var)。 - 函数声明(
func)。
在这个文件里,len=2告诉我们顶层有两个东西:一个是import "fmt",一个是func main。
- 通用声明(
2. 第一根树枝:导入声明 (GenDecl)
对应代码:import "fmt"
text
7 . . 0: *ast.GenDecl {
9 . . . Tok: import
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit { Value: "\"fmt\"" }
19 . . . . }
20 . . . }
22 . . }
*ast.GenDecl:为什么叫通用声明?看Tok: import字段,它表明这个通用声明的具体类型是"导入"。如果是var x = 1,这里的 Tok 就会变成var。Specs:全称 Specifications(规格)。因为import可以同时导入好几个包(用圆括号包裹),所以这里是一个列表。*ast.ImportSpec:这是列表中唯一的元素,描述了具体的导入内容。Path:导入的路径"fmt",被包装成了一个基础字面量BasicLit。
这里再说说为什么 import、const、type、var 四种完全不同的东西要挤在 GenDecl 里?
看看 GenDecl 的定义你就懂了:
go
type GenDecl struct {
Tok token.Token // 标记是哪种类型:IMPORT, CONST, TYPE, VAR
Specs []Spec // 具体的规格列表
}
这种设计是为了支持 Go 的批量声明语法。比如 import (...) 或者 var (...)。
- 一个 GenDecl 代表一个声明块。
- 内部的 Specs 列表则代表块里的每一行。
- 如果是单行声明(如 var a = 1),Specs 列表长度就是 1。
3. 第二根树枝:函数声明 (FuncDecl)
对应代码:func main() { ... }
text
23 . . 1: *ast.FuncDecl {
24 . . . Name: *ast.Ident { "main" ... }
33 . . . Type: *ast.FuncType { ... }
40 . . . Body: *ast.BlockStmt { ... }
70 . . }
*ast.FuncDecl:函数声明专用节点。Name:函数名main。Type:函数签名(FuncType)。里面包含了Params(参数)和Results(返回值)。因为main函数既没参数也没返回值,所以这里是空的。Body:函数体 。这是函数逻辑的核心,它是一个BlockStmt(块语句)。
4. 深入函数体:语句与表达式
对应代码:fmt.Println("Hello")
这行简单的代码,在 AST 中经历了层层包裹:
BlockStmt (花括号) -> ExprStmt (表达式语句) -> CallExpr (函数调用)。
text
43 . . . . . 0: *ast.ExprStmt {
44 . . . . . . X: *ast.CallExpr {
45 . . . . . . . Fun: *ast.SelectorExpr { ... }
56 . . . . . . . Args: []ast.Expr (len = 1) { ... }
65 . . . . . . }
66 . . . . . }
*ast.CallExpr:表示一次函数调用。它核心只有两部分:Fun:调用的谁?
这里是一个*ast.SelectorExpr(选择器表达式)。
因为我们调用的是fmt.Println,在 AST 看来,就是从X(fmt) 中选择了Sel(Println)。Args:传了什么参?
这里是一个列表,包含一个*ast.BasicLit,即字符串字面量"Hello"。
5. 幕后功臣:Scope 与 Unresolved
在输出的最后,你可能会注意到这两个字段:
text
74 . Scope: *ast.Scope {
75 . . Objects: map[string]*ast.Object (len = 1) {
76 . . . "main": *(obj @ 27)
77 . . }
78 . }
82 . Unresolved: []*ast.Ident (len = 1) {
83 . . 0: *(obj @ 46) <-- 对应 "fmt"
84 . }
Scope(作用域) :这是文件级别的作用域。编译器在这里记录了当前文件定义了哪些对象。可以看到,它记录了main函数的存在。Unresolved(未解析标识符) :这很有趣。为什么fmt在这里?
因为fmt是一个包名,它不是 在这个dummy.go文件里定义的(它是引入的)。Parser 在解析当前文件时,发现用了一个叫fmt的名字,但找不到它的定义,于是暂时把它扔进"未解析列表",留给后续的编译器环节(类型检查器)去处理。
这里体现了 Go 工具链一个非常重要的设计原则:
语法阶段(parser)只做"结构正确性",绝不做跨文件、跨包语义推断。
这样做的好处是:
- parser 极快
- AST 可被工具反复复用
- 不同工具(lint / types / SSA)各司其职
五、动手把玩:如何遍历这棵树。
go
func TestAst(t *testing.T) {
srcCode := `
package main
import "fmt"
func main() {
fmt.Println("Hello")
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "dummy.go", srcCode, 0)
if err != nil {
fmt.Printf("err = %s", err)
}
//ast.Print(fset, f)
// 演示:如何查找代码中所有的函数调用
fmt.Println("\n--- 开始遍历 AST ---")
ast.Inspect(f, func(n ast.Node) bool {
// 尝试将节点断言为函数调用 (CallExpr)
if call, ok := n.(*ast.CallExpr); ok {
// 进一步:看看调用的是什么函数
if fun, ok := call.Fun.(*ast.SelectorExpr); ok {
fmt.Printf("发现函数调用: %s.%s\n", fun.X, fun.Sel)
}
}
return true // 返回 true 继续遍历子节点
})
}
运行后输出如下
=== RUN TestAst
--- 开始遍历 AST ---
发现函数调用: fmt.Println
--- PASS: TestAst (0.00s)
PASS
下一步挑战: 既然你已经学会了如何"看"这棵树,那么如何"改"这棵树呢?
go
if call, ok := n.(*ast.CallExpr); ok {
if fun, ok := call.Fun.(*ast.SelectorExpr); ok {
if fun.Sel.Name == "Println" {
fun.Sel.Name = "Printf"
}
}
}
下一篇我们将探讨如何通过 go/format 将修改后的 AST 树写回磁盘,实现属于你自己的 gofmt。