登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

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

来源:17golang原创

时间:2026-10-15 08:04:16 206浏览 收藏

我第一次把 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>