//go:fix inline 与源代码级内联器

Go 1.26 重新实现了 go fix 子命令,帮助开发者让 Go 代码保持更新,采用现代写法。初次接触可以先阅读《使用 go fix 更新 Go 代码》。这里介绍其中的一项功能:源代码级内联器。

go fix 已有多种专门针对新语言特性和标准库功能的代码更新器。源代码级内联器则是 Go 团队建设“自助式”更新器和分析器的第一项成果。包作者可以借助它,以直接而安全的方式描述简单的 API 迁移和更新。下面先介绍内联器的用途和用法,再讨论问题本身以及背后的技术。

源代码级内联

2023 年,Go 团队构建了一个在 Go 源代码中内联函数调用的算法。对一次调用进行“内联”,就是用被调用函数的函数体副本替换调用,并以实参替换形参。它被称为“源代码级”内联,是因为修改会永久保留在源代码中。

包括 Go 编译器在内的典型编译器,也会实施类似的变换。不过,编译器修改的是临时存在的中间表示,目的是生成效率更高的代码。

如果你使用过 gopls 的交互式重构功能 Inline call,就已经用过源代码级内联器。在 VS Code 中,可以在“Source Action…”菜单找到这项代码操作。下面的官方截图展示了在 six 函数中内联 sum 调用前后的变化。

官方编辑器截图:在 six 函数中内联 sum 调用之前

官方编辑器截图:在 six 函数中内联 sum 调用之后

内联器是许多源代码变换工具的重要基础。例如,gopls 的“Change signature”和“Remove unused parameter”重构都会使用它。后文会看到,重构函数调用涉及许多细微的正确性问题,而内联器会处理这些问题。

同一套内联器也是新版 go fix 中的一个分析器。通过新的 //go:fix inline 指令注释,go fix 可以支持自助式 API 迁移和升级。以下示例展示了它的工作方式和用途。

示例:为 ioutil.ReadFile 更换名称

Go 1.16 引入了新的 os.ReadFile,并弃用了读取文件内容的 ioutil.ReadFile。从效果上说,这相当于给函数改名。不过,Go 的兼容性承诺意味着旧名称不能被移除。

package ioutil

import "os"

// ReadFile reads the file named by filename…
// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

理想情况下,希望所有 Go 程序停止调用 ioutil.ReadFile,转而使用 os.ReadFile。内联器可以帮助完成这项迁移。第一步是在旧函数上加上 //go:fix inline。该注释告诉工具,每次遇到这个函数的调用,都应将其内联。

package ioutil

import "os"

// ReadFile reads the file named by filename…
// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
//go:fix inline
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

对含有 ioutil.ReadFile 调用的文件运行 go fix 时,会产生下面的替换:

$ go fix -diff ./...
-import "io/ioutil"
+import "os"

-   data, err := ioutil.ReadFile("hello.txt")
+   data, err := os.ReadFile("hello.txt")

这次调用被内联了,实际效果是将一个函数调用替换为另一个函数调用。

内联器用被调用函数的函数体副本替换调用,而不是换成任意表达式。因此,原则上这项变换不应改变程序行为;当然,检查调用栈的代码是例外。这与 gofmt -r 等允许任意改写的工具不同:任意改写功能很强,但需要仔细审查。

多年来,Google 内部支持 Java、Kotlin 和 C++ 的团队一直在使用类似的源代码级内联工具。到原文撰写时,这些工具已经移除了 Google 代码库中数百万次对弃用函数的调用。使用者只需添加指令,然后等待。夜间,自动化程序会在包含数十亿行代码的单体仓库中准备、测试并提交成批的代码修改。顺利的话,第二天早上旧代码就不再被调用,也就可以安全删除了。

Go 内联器起步较晚,但截至原文发表时,已被用于为 Google 单体仓库准备超过 18,000 个变更集。

示例:修复 API 设计缺陷

稍加设计,许多迁移都可以表示为内联。考虑这个假想的 oldmath 包:

// Package oldmath is the bad old math package.
package oldmath

// Sub returns x - y.
func Sub(y, x int) int

// Inf returns positive infinity.
func Inf() float64

// Neg returns -x.
func Neg(x int) int

它有几项设计缺陷:Sub 的形参顺序不合理;Inf 隐式偏向两种无穷值中的一种;Neg 的功能又与 Sub 重复。假设已有一个避免这些问题的 newmath 包,希望用户迁移到新包。先用新包实现旧 API,并将旧函数标记为弃用,再加入内联指令:

// Package oldmath is the bad old math package.
package oldmath

import "newmath"

// Sub returns x - y.
// Deprecated: the parameter order is confusing.
//go:fix inline
func Sub(y, x int) int {
    return newmath.Sub(x, y)
}

// Inf returns positive infinity.
// Deprecated: there are two infinite values; be explicit.
//go:fix inline
func Inf() float64 {
    return newmath.Inf(+1)
}

// Neg returns -x.
// Deprecated: this function is unnecessary.
//go:fix inline
func Neg(x int) int {
    return newmath.Sub(0, x)
}

现在,oldmath 的使用者对自己的代码运行 go fix,工具就会用新函数替换旧函数的调用。gopls 的分析器集合中也早已包含 inline。如果编辑器使用 gopls,加入 //go:fix inline 后,各个调用位置就应该开始出现诊断,例如提示 oldmath.Sub 的调用应当内联,并提供完成这次内联的修复建议。

例如,这段旧代码:

import "oldmath"

var nine = oldmath.Sub(1, 10) // diagnostic: "call to oldmath.Sub should be inlined"

会转换为:

import "newmath"

var nine = newmath.Sub(10, 1)

修复后,Sub 的实参采用了符合逻辑的顺序。如果迁移足够顺利,内联器可能移除所有对 oldmath 函数的调用,使你能够删除对旧包的依赖。

inline 分析器也支持类型和常量。如果 oldmath 最初声明了表示有理数的数据类型以及表示 π 的常量,可以用以下转发声明,在保留现有代码行为的同时将它们迁移到 newmath:

package oldmath

//go:fix inline
type Rational = newmath.Rational

//go:fix inline
const Pi = newmath.Pi

每次 inline 分析器遇到 oldmath.Rational 或 oldmath.Pi 的引用,都会将其改为引用 newmath 中对应的声明。

内联器内部的工作

乍看之下,源代码内联似乎很直接:用被调用函数的函数体替换调用,为函数形参引入变量,再把调用实参绑定到这些变量。但要正确处理各种复杂情况和边界条件,同时生成可以接受的结果,是一项技术挑战。内联器包含约 7,000 行密集、类似编译器的逻辑。下面介绍六个难点。

1. 消除形参

内联器的一项重要任务,是尝试用调用中对应的实参替换被调用函数内每一次形参出现的位置。最简单的情况是实参为 0 或 "" 等简单字面量,此时替换很直接,可以消除形参。

//go:fix inline
func show(prefix, item string) {
    fmt.Println(prefix, item)
}

调用:

show("", "hello")

内联后:

fmt.Println("", "hello")

对于 404 或 "go.dev" 这类不那么简单的字面量,只要对应形参在函数体中最多出现一次,替换也很直接。但如果形参出现多次,把这些魔法值复制到代码各处会破坏代码风格,掩盖它们之间的联系。以后只修改其中一个,还可能造成不一致。

这时内联器需要更谨慎,产生较保守的结果。无论出于何种原因,只要一个或多个形参无法完全替换,内联器就会插入显式的“形参绑定”声明:

//go:fix inline
func printPair(before, x, y, after string) {
    fmt.Println(before, x, after)
    fmt.Println(before, y, after)
}

调用:

printPair("[", "one", "two", "]")

内联后:

// a “parameter binding” declaration
var before, after = "[", "]"
fmt.Println(before, "one", after)
fmt.Println(before, "two", after)

2. 副作用

在 Go 和其他命令式语言中,函数调用可能更新变量,从而影响其他函数的行为。考虑下面对 add 的调用:

func add(x, y int) int { return y + x }

z = add(f(), g())

简单地把 x 换成 f()、把 y 换成 g(),会得到:

z = g() + f()

这个结果并不正确,因为现在 g() 会在 f() 之前求值。如果两个函数有副作用,这些副作用就会按不同的顺序发生,可能改变表达式的结果。编写依赖调用实参副作用顺序的代码虽然不是好习惯,但仍然有人这样做,工具必须正确处理。

因此,内联器必须尝试证明 f() 和 g() 的副作用不会相互影响。如果证明成功,就可以安全采用上面的结果;否则必须退回到显式形参绑定:

var x = f()
z = g() + x

考虑副作用时,不仅要关注实参表达式,还要关注形参求值与被调用函数中其他代码的相对顺序。看下面对 add2 的调用:

//go:fix inline
func add2(x, y int) int {
    return x + other() + y
}

add2(f(), g())

这次 x 和 y 的使用顺序与声明顺序相同。替换成 f() + other() + g() 不会改变 f() 与 g() 的副作用顺序,却会改变 other() 与 g() 的副作用顺序。此外,函数体如果在循环内使用形参,替换还可能改变副作用发生的次数。

内联器使用一种新的风险分析,对被调用函数内的副作用顺序建模。不过,它建立必要安全性证明的能力仍然有限。例如,假如 f() 和 g() 都只是简单的访问器,以任意顺序调用它们可能完全安全。优化编译器甚至可能根据它们的内部实现,安全地重新安排两次调用。

但编译器生成的目标代码反映的是某个时刻的源代码;内联器的目的是永久修改源代码,因此不能依赖这些暂时成立的实现细节。极端一点,考虑下面的 start 函数:

func start() { /* TODO: implement */ }

优化编译器可以删除每次 start() 调用,因为它目前没有效果;内联器却不能这样做,因为这个函数以后可能变得重要。

所以,在熟悉项目的维护者看来,内联器的输出有时显然过于保守。遇到这些情况,手动清理修复后的代码会改善其风格。

3. 可能失败的常量表达式

你可能认为,永远可以安全地用同类型的常量实参替换形参变量;作者起初也是这样想的。但事实并非如此:原本在运行时执行的某些检查,会改为在编译时执行,并且失败。考虑对 index 的调用:

//go:fix inline
func index(s string, i int) byte {
    return s[i]
}

index("", 0)

一个简单的内联器可能把 s 换成 ""、把 i 换成 0,得到 ""[0]。但这个表达式在 Go 中并不合法,因为对这个特定字符串而言,该下标越界了。""[0] 由常量构成,因此在编译时求值,包含它的程序甚至无法构建。原程序则只有执行到这次 index 调用时才失败;一个正常工作的程序,想必不会走到这里。

所以,内联器必须跟踪那些可能在形参替换期间变成常量的表达式及其操作数,因为它们可能触发额外的编译期检查。内联器会建立一个约束系统,并尝试求解。对每项未满足的约束,都通过给受约束的形参加入显式绑定来解决。

4. 名称遮蔽

典型的实参表达式包含一个或多个标识符,引用调用者文件中的变量、函数等符号。内联器必须确保,形参替换后,实参表达式中的每个名称仍然引用同一个符号;也就是说,调用者的名称不能被被调用函数中的名称遮蔽。如果不能保证这一点,就必须再次插入形参绑定。例如:

//go:fix inline
func f(val string) {
    x := 123
    fmt.Println(val, x)
}

调用者:

x := "hello"
f(x)

内联后:

x := "hello"
{
    // another “parameter binding” declaration
    // to read the caller's x before shadowing it
    var val string = x
    x := 123
    fmt.Println(val, x)
}

反过来,内联器也要检查被调用函数体中的每个名称,在拼接到调用位置后是否仍然指向同一个对象。换言之,调用者不能遮蔽被调用函数所使用的名称,也不能缺少这些名称。对于缺少的名称,内联器可能需要增加导入。

5. 未使用的变量

如果实参表达式没有副作用,对应形参也从未使用,那么可以消除这个表达式。但如果其中包含调用者中某个局部变量的最后一次引用,消除它就可能使变量变成未使用状态,导致编译错误。

//go:fix inline
func f(_ int) { print("hello") }

调用者:

x := 42
f(x)

简单内联会得到:

x := 42 // error: unused variable: x
print("hello")

因此,内联器必须计算局部变量的引用,避免移除最后一次引用。当然,仍有可能发生这样的情况:两个不同的内联修复,各自都只移除了某个变量的倒数第二次引用;两个修复单独应用都有效,合在一起却无效。上一篇文章讨论了这类语义冲突。在这种情况下,手动清理无法避免。

6. defer

某些情况下,根本无法消除调用。例如,被调用函数使用了 defer:如果直接移除调用,延迟函数将等到调用者返回时才执行,这已经太晚了。面对含有 defer 的被调用函数,唯一安全的处理办法,是将函数体放入函数字面量,并立即调用它。这个 func() { … }() 函数字面量限定了 defer 语句的生存期,如下所示:

//go:fix inline
func callee() {
    defer f()
    …
}

调用:

callee()

内联后:

func() {
    defer f()
    …
}()

在 gopls 中调用内联器,会产生上面的变化,并引入函数字面量。这种结果在交互式场景中可能合适,因为开发者通常会马上继续修改代码,或按需要撤销修复。但批处理工具很少适合这样做,所以 go fix 的分析器从策略上拒绝内联这种需要“函数字面量化”的调用。

以“整洁”为目标的优化编译器

上面六组示例展示了内联器如何正确处理复杂的语义边界情况。

作者特别感谢 Rob Findley、Jonathan Amsterdam、Olena Synenka 和 Lasse Folger 在思路、讨论、评审、功能及修复方面的贡献。把这些复杂逻辑集中在内联器中,目的是让使用者只需在 IDE 中执行“Inline call”重构,或在自己的函数上加上 //go:fix inline,就能相信生成的源代码变换只需简要审查即可应用。

尽管朝这个目标已经取得许多进展,Go 团队还没有完全达到它,而且很可能永远无法完全达到。以编译器为例:健全的编译器应对任何输入都生成正确输出,不会错误编译代码。这是每个使用者理应抱有的基本期待。优化编译器则在不牺牲安全性的前提下,仔细选择运行速度更快的代码。

类似地,内联器也有点像优化编译器,不过优化目标是整洁,而非速度:内联调用绝不能改变程序行为,理想情况下还要产生尽可能整洁的代码。遗憾的是,优化编译器不可能彻底完成优化任务。证明两个不同程序等价是不可判定问题;总会存在专家知道安全、但编译器无法证明安全的改进。

内联器也是如此:总会有输出过于繁琐,或在风格上逊于人工处理的情形;也总会有新的“整洁度优化”可以加入。

试一试

希望这次介绍能帮助你理解内联器面临的挑战,以及 Go 团队在提供健全、自助式代码变换工具方面的优先事项和发展方向。可以在 IDE 中交互式试用内联器,也可以通过 //go:fix inline 指令与 go fix 命令使用它,并向 Go 团队反馈使用体验、进一步改进的想法或新工具建议。


原文:《//go:fix inline and the source-level inliner》,Alan Donovan,2026 年 3 月 10 日。本文为中文翻译,正文已汉化;代码及代码内注释保留原样。原文与官方截图按 CC BY 4.0 使用,许可依据为 Go 网站版权页。历史数量均对应原文发表时点,不代表当前实测结果。

代码许可:BSD 3-Clause。以下保留原许可证:

Copyright 2009 The Go Authors.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  • Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  • Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
  • Neither the name of Google LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容