当前位置:首页 > 文章列表 > Golang > Go教程 > Go ast.Preorder 怎样遍历语法树而不手写递归

Go ast.Preorder 怎样遍历语法树而不手写递归

来源:17golang原创 2026-10-15 08:04:16 0浏览 收藏

我第一次把 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 返回的文件节点;如果源码不完整或语法有误,先处理解析错误,不要让遍历函数接收一个并不存在的根节点。

Go 源码经过 FileSet 和 ParseFile 形成 ast.File 及节点位置映射的结构说明图
图1:从 Go 源码到 *ast.File 及其位置映射的静态结构说明图,不是截图或运行证据。

用 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 循环外维护一个容易失配的手写栈。

Go ast.Preorder、Inspect 和 PreorderStack 的遍历能力边界结构说明图
图2:Preorder、Inspect 与 PreorderStack 的能力边界静态说明图,不是截图或运行证据。

把解析错误和筛选错误分成两层

源码扫描器最好把职责拆成“解析”和“遍历”。解析函数只负责返回 *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。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go embed 管理默认模板的构建组织方式Go embed 管理默认模板的构建组织方式
上一篇
Go embed 管理默认模板的构建组织方式
Go tar 解包的路径过滤与文件类型处理方案
下一篇
Go tar 解包的路径过滤与文件类型处理方案
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    478次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    535次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    555次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    506次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    328次使用