NEE's Blog

使用 go fix 实现 Go 代码现代化

February 17, 2026

本文翻译自 Using go fix to modernize Go code,原载于 Hacker News。


Go 1.26 于本月发布,其中包含了一个完全重写的 go fix 子命令。go fix 使用一套算法来识别代码改进的机会,通常是通过利用语言和库的更现代特性来实现。在这篇文章中,我们将首先展示如何使用 go fix 来现代化你的 Go 代码库。然后在第二部分,我们将深入探讨其背后的基础设施及其演进。最后,我们将介绍”自助式”分析工具的主题,帮助模块维护者和组织编码他们自己的指南和最佳实践。

运行 go fix

go fix 命令与 go buildgo vet 类似,接受一组表示包的模式。以下命令修复当前目录下的所有包:

$ go fix ./...

成功时,它会静默更新你的源文件。它会丢弃任何触及生成文件的修复,因为在这种情况下,适当的修复应该是针对生成器本身的逻辑。我们建议每次将构建更新到更新的 Go 工具链版本时,都在项目上运行 go fix。由于该命令可能会修复数百个文件,请从一个干净的 git 状态开始,这样更改只包含来自 go fix 的编辑——你的代码审查者会感谢你的。

要预览上述命令将要进行的更改,使用 -diff 标志:

$ go fix -diff ./...
--- dir/file.go (old)
+++ dir/file.go (new)
-                       eq := strings.IndexByte(pair, '=')
-                       result[pair[:eq]] = pair[1+eq:]
+                       before, after, _ := strings.Cut(pair, "=")
+                       result[before] = after
…

你可以通过运行以下命令列出可用的修复器:

$ go tool fix help
…
Registered analyzers:
    any          replace interface{} with any
    buildtag     check //go:build and // +build directives
    fmtappendf   replace []byte(fmt.Sprintf) with fmt.Appendf
    forvar       remove redundant re-declaration of loop variables
    hostport     check format of addresses passed to net.Dial
    inline       apply fixes based on 'go:fix inline' comment directives
    mapsloop     replace explicit loops over maps with calls to maps package
    minmax       replace if/else statements with calls to min or max
…

添加特定分析器的名称可以显示其完整文档:

$ go tool fix help forvar

forvar: remove redundant re-declaration of loop variables

The forvar analyzer removes unnecessary shadowing of loop variables.
Before Go 1.22, it was common to write `for _, x := range s { x := x ... }`
to create a fresh variable for each iteration. Go 1.22 changed the semantics
of `for` loops, making this pattern redundant. This analyzer removes the
unnecessary `x := x` statement.

This fix only applies to `range` loops.

默认情况下,go fix 命令运行所有分析器。在修复大型项目时,如果将最活跃的分析器的修复作为单独的代码更改应用,可能会减少代码审查的负担。要仅启用特定分析器,使用与其名称匹配的标志。例如,要仅运行 any 修复器,指定 -any 标志。相反,要运行所有分析器(除了选定的),则否定标志,例如 -any=false

go buildgo vet 一样,每次运行 go fix 命令只分析特定的构建配置。如果你的项目大量使用针对不同 CPU 或平台标记的文件,你可能希望使用不同的 GOARCHGOOS 值多次运行该命令以获得更好的覆盖率:

$ GOOS=linux   GOARCH=amd64 go fix ./...
$ GOOS=darwin  GOARCH=arm64 go fix ./...
$ GOOS=windows GOARCH=amd64 go fix ./...

多次运行该命令还提供了协同修复的机会,我们将在下面看到。

现代化器(Modernizers)

Go 1.18 中泛型的引入标志着一个时代的结束——语言规范极少变化的时期,以及一个更快速(尽管仍然谨慎)变化时期的开始,尤其是在库方面。Go 程序员经常编写的许多简单循环,例如将 map 的键收集到切片中,现在可以方便地表示为对泛型函数(如 maps.Keys)的调用。因此,这些新特性创造了许多简化现有代码的机会。

2024 年 12 月,在 LLM 编码助手被疯狂采用期间,我们意识到这些工具——不出所料地——倾向于以类似于训练期间使用的大量 Go 代码的风格生成 Go 代码,即使有更新、更好的方式来表达相同的想法。更不明显的是,即使被指导使用诸如”始终使用 Go 1.25 的最新惯用语”之类的通用术语来使用新方式,这些工具也经常拒绝这样做。在某些情况下,即使被明确告知使用某个特性,模型也会否认它的存在。(有关更多令人沮丧的细节,请参阅我 2025 年的 GopherCon 演讲。)为了确保未来的模型在最新的惯用语上训练,我们需要确保这些惯用语反映在训练数据中,也就是开源 Go 代码的全球语料库中。

在过去的一年中,我们构建了数十个分析器来识别现代化机会。以下是它们建议的修复的三个示例:

minmax 用 Go 1.21 的 minmax 函数替换 if 语句:

// 修复前
x := f()
if x < 0 {
    x = 0
}
if x > 100 {
    x = 100
}

// 修复后
x := min(max(f(), 0), 100)

rangeint 用 Go 1.22 的 range-over-int 循环替换三子句 for 循环:

// 修复前
for i := 0; i < n; i++ {
    f()
}

// 修复后
for range n {
    f()
}

stringscut(我们之前看到了它的 -diff 输出)用 Go 1.18 的 strings.Cut 替换 strings.Index 和切片操作:

// 修复前
i := strings.Index(s, ":")
if i >= 0 {
     return s[:i]
}

// 修复后
before, _, ok := strings.Cut(s, ":")
if ok {
    return before
}

这些现代化器包含在 gopls 中,在你输入时提供即时反馈,也包含在 go fix 中,这样你可以在单个命令中一次性现代化多个完整的包。除了使代码更清晰外,现代化器还可以帮助 Go 程序员了解更新的特性。作为批准每个语言和标准库新更改过程的一部分,提案审查组现在考虑是否应该伴随一个现代化器。我们期望在每个版本中添加更多现代化器。

示例:Go 1.26 的 new(expr) 现代化器

Go 1.26 包含对语言规范的一个小但广泛有用的更改。内置的 new 函数创建一个新变量并返回其地址。历史上,它的唯一参数必须是类型,如 new(string),新变量被初始化为其”零”值,如 ""。在 Go 1.26 中,new 函数可以用任何值调用,使其创建一个初始化为该值的变量,避免了对额外语句的需求。例如:

// 修复前
ptr := new(string)
*ptr = "go1.25"

// 修复后
ptr := new("go1.26")

这个特性填补了一个讨论了十多年的空白,并解决了最受欢迎的语言更改提案之一。对于使用指针类型 *T 表示类型 T 的可选值的代码来说,这尤其方便,这在处理序列化包(如 json.Marshal 或 protocol buffers)时很常见。这是如此常见的模式,以至于人们经常将其封装在辅助函数中,例如下面的 newInt 函数,使调用者无需脱离表达式上下文来引入额外的语句:

type RequestJSON struct {
    URL      string
    Attempts *int  // (optional)
}

data, err := json.Marshal(&RequestJSON{
    URL:      url,
    Attempts: newInt(10),
})

func newInt(x int) *int { return &x }

newInt 这样的辅助函数在 protocol buffers 中使用如此频繁,以至于 proto API 本身提供了 proto.Int64proto.String 等。但 Go 1.26 使所有这些辅助函数变得不必要:

data, err := json.Marshal(&RequestJSON{
    URL:      url,
    Attempts: new(10),
})

为了帮助你利用这个特性,go fix 命令现在包含一个修复器 newexpr,它识别”类似 new”的函数(如 newInt)并建议修复,将函数体替换为 return new(x),并将每个调用(无论是在同一包中还是导入包中)替换为直接使用 new(expr)

为了避免过早引入新特性的使用,现代化器只在需要至少适当最低 Go 版本(在此例中为 1.26)的文件中提供修复,这通过封闭的 go.mod 文件中的 go 1.26 指令或文件本身的 //go:build go1.26 构建约束来确定。

运行以下命令更新源代码树中所有这种形式的调用:

$ go fix -newexpr ./...

此时,如果幸运的话,你所有类似 newInt 的辅助函数将变得未使用,可以安全删除(假设它们不是稳定发布的 API 的一部分)。少数调用可能会保留,因为在这些地方建议修复是不安全的,例如当名称 new 被另一个声明局部遮蔽时。你也可以使用 deadcode 命令来帮助识别未使用的函数。

协同修复

应用一个现代化可能会创造应用另一个现代化的机会。例如,这段将 x 限制在 0-100 范围内的代码片段会导致 minmax 现代化器建议使用 max 的修复。一旦应用了该修复,它会建议第二个修复,这次是使用 min

// 原始代码
x := f()
if x < 0 {
    x = 0
}
if x > 100 {
    x = 100
}

// 最终结果
x := min(max(f(), 0), 100)

协同效应也可能发生在不同的分析器之间。例如,一个常见的错误是在循环内重复连接字符串,导致二次时间复杂度——这是一个错误,也是拒绝服务攻击的潜在载体。stringsbuilder 现代化器识别这个问题并建议使用 Go 1.10 的 strings.Builder

// 修复前
s := ""
for _, b := range bytes {
    s += fmt.Sprintf("%02x", b)
}
use(s)

// 修复后
var s strings.Builder
for _, b := range bytes {
    s.WriteString(fmt.Sprintf("%02x", b))
}
use(s.String())

一旦应用了这个修复,第二个分析器可能会识别出 WriteStringSprintf 操作可以组合为 fmt.Fprintf(&s, "%02x", b),这既更清晰又更高效,并提供第二个修复。(这第二个分析器是来自 Dominik Honnef 的 staticcheck 的 QF1012,它已经在 gopls 中启用,但尚未在 go fix 中启用,不过我们计划从 Go 1.27 开始将 staticcheck 分析器添加到 go 命令中。)

因此,可能值得多次运行 go fix,直到它达到一个固定点;通常两次就足够了。

合并修复和冲突

单次运行 go fix 可能在同一源文件中应用数十个修复。所有修复在概念上是独立的,类似于具有相同父级的一组 git 提交。go fix 命令使用简单的三路合并算法按顺序协调修复,类似于合并编辑同一文件的一组 git 提交的任务。如果修复与迄今为止累积的编辑列表冲突,它将被丢弃,并且工具会发出警告,指出某些修复被跳过,应该再次运行该工具。

这可靠地检测由重叠编辑引起的_语法_冲突,但另一类冲突是可能的:_语义_冲突发生在两个更改在文本上独立但其含义不兼容时。举一个例子,考虑两个各自删除局部变量倒数第二次使用的修复:每个修复本身都很好,但当两者一起应用时,局部变量变得未使用,在 Go 中这是编译错误。两个修复都不负责删除变量声明,但必须有人来做,那个人就是 go fix 的用户。

当一组修复导致导入变得未使用时,会出现类似的语义冲突。因为这种情况太常见,go fix 命令应用最后一遍来检测未使用的导入并自动删除它们。

语义冲突相对罕见。幸运的是,它们通常表现为编译错误,使其不可能被忽视。不幸的是,当它们发生时,确实需要在运行 go fix 后进行一些手动工作。

现在让我们深入研究这些工具背后的基础设施。

Go 分析框架

自 Go 早期以来,go 命令就有两个用于静态分析的子命令,go vetgo fix,每个都有自己的算法套件:”检查器”和”修复器”。检查器报告代码中可能的错误,例如将字符串而不是整数作为 fmt.Printf("%d") 转换的操作数传递。修复器安全地编辑你的代码以修复错误或以更好的方式表达相同的内容,可能更清晰、更简洁或更高效。有时同一算法同时出现在两个套件中,当它既可以报告错误又可以安全修复时。

2017 年,我们重新设计了当时整体的 go vet 程序,将检查器算法(现在称为”分析器”)与”驱动程序”(运行它们的程序)分离;结果就是 Go 分析框架(Go analysis framework)。这种分离使分析器可以编写一次,然后在各种不同环境的驱动程序中运行,例如:

  • unitchecker,将分析器套件转换为可以由 go 命令的可扩展增量构建系统运行的子命令,类似于 go build 中的编译器。这是 go fixgo vet 的基础。
  • nogo,用于替代构建系统(如 Bazel 和 Blaze)的类似驱动程序。
  • singlechecker,将分析器转换为独立命令,加载、解析和类型检查一组包(可能是整个程序),然后分析它们。我们经常使用它对模块镜像(proxy.golang.org)语料库进行临时实验和测量。
  • multichecker,对分析器套件做同样的事情,具有”瑞士军刀”风格的 CLI。
  • gopls,VS Code 和其他编辑器背后的语言服务器,在每次编辑器按键后提供来自分析器的实时诊断。
  • staticcheck 工具使用的高度可配置驱动程序。(Staticcheck 还提供了可以在其他驱动程序中运行的大量分析器套件。)
  • Tricorder,Google 单体仓库使用的批处理静态分析管道,与其代码审查系统集成。
  • gopls 的 MCP 服务器,使诊断可用于基于 LLM 的编码代理,提供更健壮的”护栏”。
  • analysistest,分析框架的测试工具。

框架的一个好处是它能够表达辅助分析器,这些分析器不报告诊断或建议自己的修复,而是计算一些可能对许多其他分析器有用的中间数据结构,分摊其构建成本。示例包括控制流图、函数体的 SSA 表示以及用于优化 AST 导航的数据结构。

框架的另一个好处是它支持跨包进行推理。分析器可以将”事实”附加到函数或其他符号,以便在分析函数体时学到的信息可以在稍后分析对函数的调用时使用,即使调用出现在另一个包中或稍后的分析发生在不同的进程中。这使得定义可扩展的过程间分析变得容易。例如,printf 检查器可以判断像 log.Printf 这样的函数是否真的只是 fmt.Printf 的包装器,因此它知道应该以类似的方式检查对 log.Printf 的调用。这个过程通过归纳法工作,因此工具还将检查对 log.Printf 的进一步包装器的调用,依此类推。大量使用事实的分析器的一个例子是 Uber 的 nilaway,它报告导致空指针解引用的潜在错误。

go fix 中的”分离分析”过程类似于 go build 中的”分离编译”过程。正如编译器从依赖图的底部开始构建包并将类型信息向上传递到导入包一样,分析框架从依赖图的底部向上工作,将事实(和类型)向上传递到导入包。

2019 年,当我们开始开发 gopls(Go 的语言服务器)时,我们添加了分析器在报告诊断时建议修复的能力。例如,printf 分析器提议将 fmt.Printf(msg) 替换为 fmt.Printf("%s", msg),以避免动态 msg 值包含 % 符号时格式错误。这种机制已成为 gopls 许多快速修复和重构功能的基础。

虽然所有这些发展都在 go vet 上发生,但 go fix 仍然停留在 Go 兼容性承诺之前的状态,当时 Go 的早期采用者在语言和库快速且有时不兼容的演变期间使用它来维护他们的代码。

Go 1.26 版本将 Go 分析框架带到了 go fixgo vetgo fix 命令已经趋同,现在在实现上几乎相同。它们之间唯一的区别是它们使用的算法套件的标准,以及它们如何处理计算的诊断。Go vet 分析器必须检测低误报的可能错误;它们的诊断被报告给用户。Go fix 分析器必须生成可以在正确性、性能或风格上无回归地安全应用的修复;它们的诊断可能不会被报告,但修复直接应用。除了这种重点差异外,开发修复器的任务与开发检查器的任务没有什么不同。

改进分析基础设施

随着 go vetgo fix 中分析器数量的持续增长,我们一直在投资基础设施,以提高每个分析器的性能并使编写每个新分析器变得更容易。

例如,大多数分析器首先遍历包中每个文件的语法树,寻找特定类型的节点,如 range 语句或函数字面量。现有的 inspector 包通过预先计算完整遍历的紧凑索引使此扫描高效,以便以后的遍历可以快速跳过不包含任何感兴趣节点的子树。最近我们用 Cursor 数据类型扩展了它,允许在所有四个基本方向(上、下、左、右)之间灵活高效地导航节点,类似于导航 HTML DOM 的元素——使得表达诸如”找到每个是循环体第一个语句的 go 语句”这样的查询变得简单高效:

    var curFile inspector.Cursor = ...

    // Find each go statement that is the first statement of a loop body.
    for curGo := range curFile.Preorder((*ast.GoStmt)(nil)) {
        kind, index := curGo.ParentEdge()
        if kind == edge.BlockStmt_List && index == 0 {
            switch curGo.Parent().ParentEdgeKind() {
            case edge.ForStmt_Body, edge.RangeStmt_Body:
                ...
            }
        }
    }

许多分析器首先搜索对特定函数的调用,如 fmt.Printf。函数调用是 Go 代码中最多的表达式之一,因此与其搜索每个调用表达式并测试它是否是对 fmt.Printf 的调用,预先计算符号引用索引要高效得多,这由 typeindex 及其辅助分析器完成。然后可以直接枚举对 fmt.Printf 的调用,使成本与调用数量成正比,而不是与包的大小成正比。对于像 hostport 这样寻找不常用符号(net.Dial)的分析器,这可以轻松使其快 1000 倍。

过去一年的一些其他基础设施改进包括:

  • 标准库的依赖图,分析器可以查询以避免引入导入循环。例如,我们不能在被 strings 本身导入的包中引入对 strings.Cut 的调用。
  • 支持查询文件的有效 Go 版本,由封闭的 go.mod 文件和构建标签确定,以便分析器不会插入”太新”特性的使用。
  • 更丰富的重构原语库(例如”删除此语句”),正确处理相邻注释和其他棘手的边缘情况。

我们已经走了很远,但还有很多事情要做。修复器逻辑很难搞对。由于我们期望用户只需粗略审查就应用数百个建议的修复,因此修复器即使在晦涩的边缘情况下也必须正确。举一个例子(更多例子请参阅我的 GopherCon 演讲),我们构建了一个现代化器,将像 append([]string{}, slice...) 这样的调用替换为更清晰的 slices.Clone(slice),结果发现当 slice 为空时,Clone 的结果是 nil,这是一个微妙的行为变化,在罕见情况下可能导致错误;所以我们不得不将该现代化器从 go fix 套件中排除。

分析器作者的这些困难中的一些可以通过更好的文档(针对人类和 LLM)来缓解,特别是要考虑和测试的令人惊讶的边缘情况清单。类似于 staticcheck 和 Tree Sitter 中的语法树模式匹配引擎可以简化高效识别需要修复的位置的繁琐任务。更丰富的计算准确修复的操作符库将有助于避免常见错误。更好的测试工具将让我们检查修复不会破坏构建,并保留目标代码的动态属性。这些都在我们的路线图上。

“自助式”范式

更根本的是,我们在 2026 年将注意力转向”自助式”范式。

我们之前看到的 newexpr 分析器是一个典型的现代化器:为特定功能量身定制的定制算法。定制模型适用于语言和标准库的功能,但它并不能真正帮助更新第三方包的使用。虽然没有什么能阻止你为自己的公共 API 编写现代化器并在自己的项目上运行它,但没有自动的方法让你的 API 用户也运行它。你的现代化器可能不适合放在 gopls 或 go vet 套件中,除非你的 API 在 Go 生态系统中被特别广泛使用。即使在这种情况下,你也必须获得代码审查和批准,然后等待下一个版本。

在自助式范式下,Go 程序员将能够为自己的 API 定义现代化,他们的用户可以应用这些现代化,而无需当前中心化范式的所有瓶颈。这一点尤其重要,因为 Go 社区和全球 Go 语料库的增长速度远快于我们团队审查分析器贡献的能力。

Go 1.26 中的 go fix 命令包含了这种新范式的第一个成果的预览:注释驱动的源代码级内联器,我们将在下周即将发布的配套博客文章中描述它。在未来一年,我们计划在这个范式内探索另外两种方法。

首先,我们将探索从源代码树动态加载现代化器并在 gopls 或 go fix 中安全执行它们的可能性。在这种方法中,提供 API(例如 SQL 数据库)的包可以额外提供 API 误用的检查器,例如 SQL 注入漏洞或未能处理关键错误。同样的机制可以被项目维护者用来编码内部管理规则,例如避免调用某些有问题的函数或在代码的关键部分强制执行更强的编码规范。

其次,许多现有的检查器可以非正式地描述为”在 Y 之后别忘了 X!”,例如”打开文件后关闭它”、”创建上下文后取消它”、”锁定互斥量后解锁它”、”yield 返回 false 后跳出迭代器循环”等等。这些检查器的共同点是它们在所有执行路径上强制执行某些不变量。我们计划探索这些控制流检查器的泛化和统一,以便 Go 程序员可以轻松地将它们应用到新领域,无需复杂的分析逻辑,只需注释自己的代码。

我们希望这些新工具能在维护 Go 项目时节省你的精力,并帮助你更快地了解和受益于更新的特性。请在你的项目上尝试 go fix 并报告你发现的任何问题,也请分享你对新现代化器、修复器、检查器或静态分析的自助式方法的任何想法。


总结

这篇文章介绍了 Go 1.26 中全新的 go fix 命令,主要要点如下:

  1. 基本使用go fix ./... 可以自动更新代码以使用现代 Go 特性,建议每次升级 Go 版本后运行
  2. 现代化器(Modernizers):自动将旧代码模式转换为新的惯用写法,如用 min/max 替换 if 语句、用 strings.Cut 替换 strings.Index
  3. Go 1.26 的 new(expr)new 函数现在可以直接接受值参数,简化了创建指向特定值的指针的代码
  4. 协同修复:一次修复可能触发更多修复机会,建议多次运行直到达到固定点
  5. Go 分析框架go vetgo fix 现在共享同一套基础设施,分析器可以跨包传递”事实”
  6. 自助式范式:未来将支持用户自定义现代化器,无需等待官方工具链更新

对于中国开发者来说,这是一个好消息——随着 Go 语言的持续演进,我们可以更轻松地保持代码库的现代性,而不必手动追踪和应用每个新特性的惯用写法。

comments powered by Disqus