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

Go 修改 AST 后为什么不能把旧 Info 当成新的检查结果

来源:17golang原创

时间:2026-11-10 15:47:40 254浏览 收藏

我在做 Go 源码重写工具时遇到过一个很容易误判的问题:第一次检查通过后,我直接修改了 AST,然后继续调用旧的 types.Info.TypeOf。旧节点还能查到类型,新换进去的节点却查不到,甚至同一个位置看起来还保留着修改前的结论。

结论先说清楚:types.Info 是一次类型检查生成的结果集合,不是会跟着 AST 自动刷新的实时索引。AST 一旦发生语义变化,就应该把它当成新的源码快照,重新解析或重新组织文件,并创建新的 token.FileSet、types.Info 和检查器。

官方地址:https://pkg.go.dev/go/types#Checker

旧 Info 只能回答“旧 AST 在那次检查中得到什么结果”,不能代替当前 AST 的重新检查。

触发信号:Info 里的旧 AST 还能查到,但它不是实时索引

go/types 的检查过程会把表达式、标识符和对象之间的关系写进 Info 的多个 map。官方示例也是先创建这些 map,再把同一个 Info 传给 Config.Check,最后通过 TypeOf、ObjectOf 或 Defs 查询结果。

问题在于,map 的键通常是具体的 AST 节点指针。你把 BasicLit.Value 从 "1" 改成 "true",旧 map 不会收到“缓存失效”通知;你把一个表达式替换成新的 *ast.CallExpr,新指针也不会自动出现在旧的 Types 中。两种情况都说明旧结果和新语法已经不再是同一个检查快照。

Go AST、types.Info 与类型检查快照关系的横向结构说明图
图1:检查快照说明图,展示 AST、FileSet、Checker 与 Info 结果的绑定关系;这是静态说明图,不是运行截图。
// 说明:Info 记录第一次检查的节点结果,不会监听后续 AST 字段修改。
info := &types.Info{
	Types: make(map[ast.Expr]types.TypeAndValue),
	Defs:  make(map[*ast.Ident]types.Object),
	Uses:  make(map[*ast.Ident]types.Object),
}

// 这里的 Check 会填充 info,但不会建立自动失效机制。
_, err := (&types.Config{}).Check("sample", fset, files, info)
if err != nil {
	// 诊断仍属于这一次 AST 快照。
	return err
}

快速判断:是节点被改写,还是查询对象已经换了

排查时我会先把变化分成三类。第一类是原节点仍在 map 里,只是内部字段被改写;这时旧键可能还能命中,但值仍代表修改前的语义。第二类是用新节点替换了旧节点;这时用新节点调用 Info.TypeOf,通常得到 nil,因为新键从未参与那次检查。第三类是重新解析了源码;此时连 *ast.File 和位置体系都可能已经属于新的快照,继续拿旧 Info 查询就更没有意义。

另一个容易忽略的点是 Info.TypeOf 有前置条件:对应的 Types、Uses 和 Defs map 必须被填充。即便节点没有变化,如果初始化时没有打开所需的 map,也不能把 nil 简单解释成“类型检查失败”。

我的快速判断表如下:

现象更可能的原因处理动作
新表达式 TypeOf 为 nil新节点没有进入旧检查用新 AST 重新 Check
旧节点仍有旧类型旧 map 仍保存历史结论不要原地复用旧 Info
位置和对象对不上FileSet 或 AST 已换代成套保存新快照

处理步骤:每次变更都创建新的检查上下文

最稳妥的做法是把“源码文本到检查结果”的过程封装成一个纯快照函数。源码发生变化时,再调用一次,而不是试图清空旧 map 或让旧 Checker 猜出哪些节点已经失效。

// checkSource 为每份源码快照创建成套的解析与类型检查状态。
func checkSource(src string) (*types.Package, *types.Info, error) {
	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, "sample.go", src, parser.ParseComments)
	if err != nil {
		// 解析失败时,不要把上一个快照的 Info 当成当前结果。
		return nil, nil, err
	}

	info := &types.Info{
		Types: make(map[ast.Expr]types.TypeAndValue),
		Defs:  make(map[*ast.Ident]types.Object),
		Uses:  make(map[*ast.Ident]types.Object),
	}
	conf := &types.Config{}
	pkg, err := conf.Check("sample", fset, []*ast.File{file}, info)
	if err != nil {
		// pkg 可能是不完整包;调用方应记录本次错误并丢弃本次结果。
		return pkg, info, err
	}
	return pkg, info, nil
}

如果工具还需要修改 AST,建议先完成修改,再把最终树交给检查器;如果修改来自用户输入,通常直接重新解析文本更容易维护。重点不是“清空 map”,而是让 AST、FileSet、Info、Package 和 Checker 始终属于同一个版本。

Go 修改源码后重新创建 FileSet、AST、Info 和 Checker 的关系结构图
图2:新检查上下文说明图,展示修改后成套重建 FileSet、AST、Info、Package 的关系;这是静态结构图,不是运行截图。

回滚路径:不要把 Checker.Files 当成编辑器重检接口

types.NewChecker 创建的是带状态的检查器,Checker.Files 的语义是“把提供的文件作为该包的一部分进行检查”。它适合把同一包的文件分批加入,而不是把已经检查过的文件修改后再次塞回去,期待旧状态自动回滚和重算。

因此我的回滚规则很简单:

  • 编辑前的 Info 只用于旧版本诊断、差异展示或缓存追踪。
  • 编辑后的源码使用新的 FileSet 和新的 AST;多文件包要在同一个新 FileSet 中重新解析。
  • 新检查使用新的 Info 和新的 Config.Check,不要把旧 map 合并进来。
  • 如果需要增量性能,缓存源码文本、解析结果或文件级依赖,但要明确缓存失效边界,不能把旧类型结论伪装成实时状态。

对我来说,这个取舍比“想办法修补旧 Info”更容易复盘:一次检查就是一个不可混用的快照,新的语义就产生新的结果集。

告警确认与复盘清单

遇到 AST 重写后结果异常,可以按下面几项快速收口:

  1. 查询使用的 AST 节点,是否就是本次 Check 传入的节点指针?
  2. Info 中是否初始化并填充了 Types、Defs、Uses?
  3. 源码发生变化后,是否仍在复用旧的 FileSet、Package 或 Checker?
  4. 多文件包是否把新文件放进同一个新的检查上下文,而不是混用新旧文件?
  5. 旧结果是否明确标记为旧快照,避免进入当前版本的缓存和诊断输出?

相关问题:

  • 为什么同一个位置的表达式类型没有更新? 位置相同不代表 AST 节点和检查快照相同,Info 仍可能保存旧节点的结果。
  • 能不能只清空 Info 的 map? 不建议。Package、Checker、AST 和 FileSet 之间仍可能存在旧状态,成套重建更可靠。
  • Checker.Files 什么时候适合用? 适合把同一个包尚未检查的文件加入检查,不等于对已检查文件执行编辑器式增量重检。
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>