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 中。两种情况都说明旧结果和新语法已经不再是同一个检查快照。

// 说明: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 始终属于同一个版本。

回滚路径:不要把 Checker.Files 当成编辑器重检接口
types.NewChecker 创建的是带状态的检查器,Checker.Files 的语义是“把提供的文件作为该包的一部分进行检查”。它适合把同一包的文件分批加入,而不是把已经检查过的文件修改后再次塞回去,期待旧状态自动回滚和重算。
因此我的回滚规则很简单:
- 编辑前的
Info只用于旧版本诊断、差异展示或缓存追踪。 - 编辑后的源码使用新的
FileSet和新的 AST;多文件包要在同一个新FileSet中重新解析。 - 新检查使用新的
Info和新的Config.Check,不要把旧 map 合并进来。 - 如果需要增量性能,缓存源码文本、解析结果或文件级依赖,但要明确缓存失效边界,不能把旧类型结论伪装成实时状态。
对我来说,这个取舍比“想办法修补旧 Info”更容易复盘:一次检查就是一个不可混用的快照,新的语义就产生新的结果集。
告警确认与复盘清单
遇到 AST 重写后结果异常,可以按下面几项快速收口:
- 查询使用的 AST 节点,是否就是本次 Check 传入的节点指针?
Info中是否初始化并填充了Types、Defs、Uses?- 源码发生变化后,是否仍在复用旧的
FileSet、Package或Checker? - 多文件包是否把新文件放进同一个新的检查上下文,而不是混用新旧文件?
- 旧结果是否明确标记为旧快照,避免进入当前版本的缓存和诊断输出?
相关问题:
- 为什么同一个位置的表达式类型没有更新? 位置相同不代表 AST 节点和检查快照相同,Info 仍可能保存旧节点的结果。
- 能不能只清空 Info 的 map? 不建议。Package、Checker、AST 和 FileSet 之间仍可能存在旧状态,成套重建更可靠。
- Checker.Files 什么时候适合用? 适合把同一个包尚未检查的文件加入检查,不等于对已检查文件执行编辑器式增量重检。
-
440 收藏
-
129 收藏
-
475 收藏
-
197 收藏
-
422 收藏
-
387 收藏
-
480 收藏
-
396 收藏
-
452 收藏
-
478 收藏
-
303 收藏
-
475 收藏
-
115 收藏
-
420 收藏
-
349 收藏
-
466 收藏
-
456 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习