Go ast.Preorder 怎样遍历语法树而不手写递归
我第一次把 Go AST 接到源码扫描器时,最先遇到的不是语法树本身,而是遍历代码很快变成一堆递归:每种节点都要判断子节点,想只读某一类节点还要维护顺序和位置。现在更直接的写法是先用 go/parser 得到 *ast.File,再把它交给 ast.Preorder,用 range 按深度优先前序读取节点。需要控制子树或读取父节点栈时,再切换到 ast.Inspect 或 ast.PreorderStack。
官方 API:https://pkg.go.dev/go/ast#Preorder
parser.ParseFile负责把源码变成 AST,token.FileSet负责把位置映射回文件和行列。ast.Preorder(root)返回iter.Seq[ast.Node],直接range就能按前序读取根节点及其后代。- 只筛选节点用 Preorder;需要决定是否进入子树用 Inspect 或 PreorderStack;需要父节点路径时优先考虑 PreorderStack。
先把源码、位置表和语法树分开
AST 遍历前要先完成两个准备动作:建立一个 token.FileSet,再调用 parser.ParseFile。前者承载位置表,后者返回以 *ast.File 为根的语法树。不要把 ast.Node 当成带完整文本的对象;节点主要提供类型和 Pos/End,真正显示文件名、行号和列号时仍要回到 FileSet。
package main
import (
"fmt"
"go/parser"
"go/token"
)
func parseSource(src string) (*token.FileSet, error) {
// FileSet 负责保存节点位置与文件名之间的映射。
fset := token.NewFileSet()
// SkipObjectResolution 只关心语法树,不额外建立旧式对象解析结果。
_, err := parser.ParseFile(fset, "sample.go", src, parser.SkipObjectResolution)
if err != nil {
return nil, fmt.Errorf("parse Go source: %w", err)
}
return fset, nil
}
上面的函数只演示解析入口。实际遍历还需要保留 ParseFile 返回的文件节点;如果源码不完整或语法有误,先处理解析错误,不要让遍历函数接收一个并不存在的根节点。

用 ast.Preorder 直接读前序节点
ast.Preorder 从 Go 1.23 起提供,返回 iter.Seq[ast.Node]。它包含传入根节点本身,然后以深度优先前序访问后代:先看到父节点,再看到它的子节点。对于“找出所有标识符”“收集调用表达式”这类只需要顺序读取的任务,不必再写 Visitor 或手动递归。
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)
func printIdentifiers(src string) error {
// FileSet 与 AST 配套使用,后面把 Pos 转成文件名和行列。
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "sample.go", src, parser.SkipObjectResolution)
if err != nil {
return fmt.Errorf("parse Go source: %w", err)
}
// Preorder 返回节点序列,range 会按根节点到子节点的顺序消费它。
for node := range ast.Preorder(file) {
id, ok := node.(*ast.Ident)
if !ok {
continue
}
// 只在类型断言成功后读取 Ident.Name,避免误把其他节点当标识符。
pos := fset.Position(id.Pos())
fmt.Printf("%s: %s\\n", pos, id.Name)
}
return nil
}
例如输入包含 func add(x, y int) int,遍历会先经过文件根节点和函数声明,再落到参数、类型和函数体中的节点。业务代码只处理关心的具体类型,其他节点直接跳过,这比为每一种节点写空方法更容易维护。
节点类型和源文件位置要分别判断
源码扫描常见的两个误区是:只看节点类型却丢了位置,或者只打印位置却不知道节点语义。正确做法是先做类型断言,再从节点的 Pos() 交给 FileSet.Position 转换。位置是区间信息时还应同时处理 End(),不要假定一个节点只占一列。
func collectCalls(src string) ([]string, error) {
// 解析结果只作为只读 AST 使用,扫描期间不修改节点。
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "sample.go", src, parser.SkipObjectResolution)
if err != nil {
return nil, fmt.Errorf("parse Go source: %w", err)
}
var calls []string
for node := range ast.Preorder(file) {
call, ok := node.(*ast.CallExpr)
if !ok {
continue
}
// Fun 可以是标识符、选择器或更复杂表达式,只记录位置不强转它。
start := fset.Position(call.Pos())
end := fset.Position(call.End())
calls = append(calls, fmt.Sprintf("%s-%s", start, end))
}
return calls, nil
}
如果后续需要输出调用目标名称,可以再对 call.Fun 做分支处理;不要因为某一次输入恰好都是 *ast.Ident,就把所有函数调用都直接断言成标识符。
什么时候不用 Preorder
Preorder 的优势是序列简单,但它不会替你决定是否进入某棵子树。如果扫描器已经知道某个函数体不需要继续分析,应该换用提供控制回调的 API。
| 需求 | 更合适的 API | 原因 |
|---|---|---|
| 按顺序读取全部节点 | ast.Preorder | 返回 iter.Seq[ast.Node],消费方式简洁 |
| 进入节点前决定是否继续 | ast.Inspect | 回调返回 false 时不进入子节点,并用 nil 回调表示离开 |
| 需要父节点路径并能裁剪子树 | ast.PreorderStack | 回调得到当前节点和 enclosing stack,返回 false 可跳过该子树 |
func countTopLevelFunctions(src string) (int, error) {
// Preorder 适合完整读取;这里仍用 Inspect 演示如何在函数声明处停止深入。
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "sample.go", src, parser.SkipObjectResolution)
if err != nil {
return 0, fmt.Errorf("parse Go source: %w", err)
}
count := 0
ast.Inspect(file, func(node ast.Node) bool {
// nil 表示离开节点,这个示例不需要处理离开事件。
if node == nil {
return true
}
if _, ok := node.(*ast.FuncDecl); ok {
count++
// 不进入函数体,避免把内部声明混入顶层统计。
return false
}
return true
})
return count, nil
}
PreorderStack 适合“某个调用是否位于某个函数或循环内部”这类需要上下文的问题;它提供的是到当前节点之前的父节点栈。不要为了拿父节点而在 Preorder 循环外维护一个容易失配的手写栈。

把解析错误和筛选错误分成两层
源码扫描器最好把职责拆成“解析”和“遍历”。解析函数只负责返回 *ast.File 与 FileSet,遍历函数接收它们并提取结果;这样语法错误可以带上文件名,筛选规则变化时也不用重复处理输入。
type ParsedFile struct {
Set *token.FileSet
File *ast.File
}
func parseFile(src string) (ParsedFile, error) {
// 解析失败立即返回,调用方不接收半成品 AST。
set := token.NewFileSet()
file, err := parser.ParseFile(set, "sample.go", src, parser.SkipObjectResolution)
if err != nil {
return ParsedFile{}, fmt.Errorf("parse sample.go: %w", err)
}
return ParsedFile{Set: set, File: file}, nil
}
func findExportedNames(parsed ParsedFile) []string {
// 遍历函数只做筛选,不承担输入解析和错误包装。
var names []string
for node := range ast.Preorder(parsed.File) {
id, ok := node.(*ast.Ident)
if ok && ast.IsExported(id.Name) {
names = append(names, id.Name)
}
}
return names
}
这里的 findExportedNames 仍然只是一个最小示例。若要分析声明归属、类型信息或包级依赖,还需要引入对应的类型检查和导入器;Preorder 解决的是节点遍历,不会自动提供语义类型。
我会这样选择遍历方式
如果任务是把 AST 当成一个只读序列消费,例如收集标识符、字面量或调用节点,我会先选 ast.Preorder。如果任务需要在父节点处拦住某棵子树,选 ast.Inspect;如果还要知道当前节点外层套着哪些节点,选 ast.PreorderStack。这三个 API 的差别不是“新旧写法”,而是遍历控制权不同。
实际排查时可以按这张清单判断:
- 解析失败:检查源码、文件名和 ParseFile 模式,不要进入遍历阶段。
- 节点顺序不符合预期:先确认是否需要前序,而不是把位置排序当成树遍历顺序。
- 统计结果多了函数体内部节点:用 Inspect 或 PreorderStack 裁剪子树。
- 输出没有文件和行列:确认所有位置都经由同一个 FileSet 转换。
- 想知道类型语义:在 AST 遍历之外增加 go/types,不要把节点名称当作类型结论。
相关问题
ast.Preorder 会不会返回根节点?
会。官方定义是包含指定根节点在内,再按深度优先前序访问其后代,因此筛选逻辑要准备处理根节点类型。
Preorder 和 Inspect 的主要区别是什么?
Preorder 给出可以直接 range 的节点序列;Inspect 通过回调让你决定是否进入子树,并额外用 nil 回调表示离开节点。需要控制访问范围时 Inspect 更灵活。
为什么节点位置不能直接打印 Pos?
token.Pos 是和 FileSet 相关的内部位置值,应该调用 fset.Position(node.Pos()) 才能得到文件名、行号和列号。
Preorder 能代替类型检查吗?
不能。Preorder 只负责遍历语法树;变量类型、方法集和包依赖等语义信息,需要另行使用 go/types 等工具分析。
把 Go AST 遍历写稳的关键,不是记住更多节点类型,而是先明确需要多少控制权:顺序读取用 Preorder,子树控制用 Inspect,父节点上下文用 PreorderStack,位置显示始终交给 FileSet。
Go embed 管理默认模板的构建组织方式
- 上一篇
- Go embed 管理默认模板的构建组织方式
- 下一篇
- Go tar 解包的路径过滤与文件类型处理方案
-
- Golang · Go教程 | 6分钟前 | Go crypto/tls 证书到期时间 x509.Certificate NotAfter
- Go crypto/tls 读取证书到期时间的代码写法
- 183浏览 收藏
-
- Golang · Go教程 | 9分钟前 |
- Go net/netip 解析地址并区分 IPv4 映射
- 377浏览 收藏
-
- Golang · Go教程 | 12分钟前 |
- Go container/heap 实现定时任务优先队列
- 395浏览 收藏
-
- Golang · Go教程 | 15分钟前 | Go 切片排序 稳定排序 sort.SliceStable
- Go sort.SliceStable 保持同键顺序的排序实现
- 300浏览 收藏
-
- Golang · Go教程 | 16分钟前 | 配置 · 错误处理 · Go教程 · Go 超时配置 time.Duration time.ParseDuration 配置时长
- Go time.ParseDuration 读取配置时长的错误处理
- 119浏览 收藏
-
- Golang · Go教程 | 18分钟前 | goroutine · go · Context · Go 定时任务 reset goroutine泄漏 time.Ticker Stop
- Go time.Ticker 长时间服务的停止与泄漏规避
- 121浏览 收藏
-
- Golang · Go教程 | 19分钟前 |
- Go time.Location 处理业务时区的转换方法
- 239浏览 收藏
-
- Golang · Go教程 | 27分钟前 |
- Go 构建标签拆分平台实现的组织方式
- 476浏览 收藏
-
- Golang · Go教程 | 28分钟前 |
- Go 私有模块下载的 GOPRIVATE 配置边界
- 284浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 478次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 535次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 555次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 506次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 328次使用
-
- Go map 并发写 panic 怎么办:从共享 map 到可控写入路径
- 2026-06-30 123浏览
-
- go语言中的defer关键字
- 2023-02-17 150浏览
-
- Golang中Interface接口的三个特性
- 2023-01-07 394浏览
-
- go语言中函数与方法介绍
- 2023-01-07 297浏览
-
- go语言数据类型之字符串string
- 2022-12-30 321浏览
