像搭积木一样理解 Golang AST

引言: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 个零件:

  1. CONST (类型:关键字,形状:"const")
  2. IDENT (类型:标识符,形状:"s")
  3. ASSIGN (类型:赋值符,形状:"=")
  4. STRING (类型:字符串,形状:"foo")
  5. 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 节点都知道自己在源码中的位置
  • 三大支柱接口

    1. ast.Decl (声明) :顶层结构。
      • GenDecl (通用声明):import, const, type, var 都在这里。
      • FuncDecl (函数声明):也就是函数的定义。
    2. ast.Stmt (语句) :动作结构,通常在函数体内部。
      • AssignStmt (a := 1), IfStmt, ForStmt, ReturnStmt 等。
    3. 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 把 importconsttypevar 这种长得像的声明都归到了这里,通过内部的 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。

  1. 什么是 BadStmt?

想象一下,你正在按照图纸拼装一个复杂的乐高城堡(编写代码)。突然,你拿错了一个形状奇特的零件,或者少拼了一块(语法错误)。

  • 如果没有 BadStmt(传统做法):编译器会直接大喊:"报错!我不干了!",然后扔掉整本图纸。结果就是你的 IDE(如 GoLand)后面所有的代码都失去了颜色(全盘报红),因为它不再尝试理解剩下的部分。
  • 有了 BadStmt(Go 的做法) :Parser(解析器)非常冷静。它发现这一行代码拼错了,无法识别为一个正常的语句(比如 IfStmt 或 AssignStmt)。于是,它会在这个位置放一块"黑色的废料积木",并给它贴上 BadStmt 的标签。
  1. 为什么说它是 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 (声明列表):
      1. GenDecl (import 声明)
        • Tok: import
        • Specs: ["fmt"]
      2. FuncDecl (函数声明)
        • Name: main
        • Type: func()
        • Body: BlockStmt (函数体代码块)
          • List:
            • ExprStmt (表达式语句)
              • X: CallExpr (调用表达式)
                • Fun: SelectorExpr (选择器 fmt.Println)
                  • X: fmt
                  • Sel: Println
                • Args: ["Hello"] (BasicLit)

当然了,一开始记得时候可以看看下面的极简版。

复制代码
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 程序的顶层通常只有两类东西:
    1. 通用声明(import, const, type, var)。
    2. 函数声明(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 :表示一次函数调用。它核心只有两部分:
    1. Fun调用的谁?
      这里是一个 *ast.SelectorExpr(选择器表达式)。
      因为我们调用的是 fmt.Println,在 AST 看来,就是从 X (fmt) 中选择了 Sel (Println)。
    2. 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。

相关推荐
踏浪无痕2 小时前
JobFlow 的延时调度:如何可靠地处理“30分钟后取消订单”
后端·面试·开源
SystickInt2 小时前
C语言 UTC时间转化为北京时间
c语言·开发语言
黎雁·泠崖2 小时前
C 语言动态内存管理进阶:常见错误排查 + 经典笔试题深度解析
c语言·开发语言
成为大佬先秃头2 小时前
渐进式JavaScript框架:Vue 过渡 & 动画 & 可复用性 & 组合
开发语言·javascript·vue.js
嘻嘻嘻开心2 小时前
Java IO流
java·开发语言
侧耳倾听1112 小时前
RESTful API介绍
后端·restful
JIngJaneIL2 小时前
基于java+ vue家庭理财管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
hakesashou2 小时前
python 随机函数可以生成字符串吗
开发语言·python
vipbic2 小时前
基于 Nuxt 4 + Strapi 5 构建高性能 AI 导航站
前端·后端