Go 博客
使用 deadcode 查找不可达函数
属于项目源代码但任何执行路径都永远无法到达的函数被称为“死代码”,它们会给代码库的维护工作带来负担。今天我们很高兴分享一个名为 deadcode
的工具来帮助您识别它们。
$ go install golang.org/x/tools/cmd/deadcode@latest
$ deadcode -help
The deadcode command reports unreachable functions in Go programs.
Usage: deadcode [flags] package...
示例
在过去一年左右的时间里,我们对 gopls(为 VS Code 和其他编辑器提供支持的 Go 语言服务器)的结构进行了大量更改。典型的更改可能涉及重写一些现有函数,并确保其新行为满足所有现有调用者的需求。有时,在付出了所有这些努力后,我们沮丧地发现某个调用者实际上在任何执行路径中都从未被达到过,因此可以安全地删除它。如果我们早知道这一点,重构任务就会更容易。
下面的简单 Go 程序说明了这个问题
module example.com/greet
go 1.21
package main
import "fmt"
func main() {
var g Greeter
g = Helloer{}
g.Greet()
}
type Greeter interface{ Greet() }
type Helloer struct{}
type Goodbyer struct{}
var _ Greeter = Helloer{} // Helloer implements Greeter
var _ Greeter = Goodbyer{} // Goodbyer implements Greeter
func (Helloer) Greet() { hello() }
func (Goodbyer) Greet() { goodbye() }
func hello() { fmt.Println("hello") }
func goodbye() { fmt.Println("goodbye") }
当我们执行它时,它会输出 hello
$ go run .
hello
从其输出可以清楚地看出,该程序执行了 hello
函数而不是 goodbye
函数。乍一看不太清楚的是,goodbye
函数永远不会被调用。然而,我们不能简单地删除 goodbye
,因为它被 Goodbyer.Greet
方法所需要,而该方法又需要实现 Greeter
接口,我们在 main
中可以看到该接口的 Greet
方法被调用。但是,如果我们从 main 开始向前追溯,我们可以看到从未创建过任何 Goodbyer
值,因此 main
中的 Greet
调用只能到达 Helloer.Greet
。这就是 deadcode
工具所使用算法背后的思想。
当我们在该程序上运行 deadcode 时,该工具告诉我们 goodbye
函数和 Goodbyer.Greet
方法都是不可达的
$ deadcode .
greet.go:23: unreachable func: goodbye
greet.go:20: unreachable func: Goodbyer.Greet
有了这些信息,我们可以安全地删除这两个函数,以及 Goodbyer
类型本身。
该工具还可以解释为什么 hello
函数是活跃的(可达的)。它会返回一个从 main 开始,到达 hello
的函数调用链
$ deadcode -whylive=example.com/greet.hello .
example.com/greet.main
dynamic@L0008 --> example.com/greet.Helloer.Greet
static@L0019 --> example.com/greet.hello
输出被设计成易于在终端上阅读,但您可以使用 -json
或 -f=template
标志指定更丰富的输出格式,以便供其他工具使用。
工作原理
deadcode
命令 加载、解析 和 类型检查 指定的包,然后将它们转换为类似于典型编译器的 中间表示。
然后它使用一种称为 快速类型分析 (RTA) 的算法来构建可达函数的集合,该集合最初仅包含每个 main
包的入口点:main
函数以及包初始化函数(该函数负责为全局变量赋值并调用名为 init
的函数)。
RTA 查看每个可达函数体内的语句,以收集三种信息:它直接调用的函数集合;它通过接口方法进行的动态调用集合;以及它转换为接口的类型集合。
直接函数调用很容易:我们只需将该被调用者添加到可达函数集合中,如果这是我们第一次遇到该被调用者,我们会像处理 main 函数一样检查其函数体。
通过接口方法的动态调用则比较棘手,因为我们不知道实现该接口的类型集合。我们不想假设程序中每个类型匹配的可能方法都是调用的潜在目标,因为其中一些类型可能仅从死代码中实例化!这就是我们收集转换为接口的类型集合的原因:这种转换使这些类型中的每一个都可以从 main
访问,因此其方法现在成为动态调用的潜在目标。
这导致了一个先有鸡还是先有蛋的情况。随着我们遇到每个新的可达函数,我们会发现更多的接口方法调用以及更多的具体类型到接口类型的转换。但随着这两个集合的笛卡尔积(接口方法调用 × 具体类型)不断增长,我们会发现新的可达函数。这类问题被称为“动态规划”,可以通过在一个大型二维表中(概念上)打勾来解决,随着分析的进行添加行和列,直到没有更多的勾可以添加。最终表格中的勾表示哪些是可达的;空白单元格则是死代码。
main
函数导致 Helloer
被实例化,并且 g.Greet
调用分派给到目前为止已实例化的每种类型的
Greet
方法。
对(非方法)函数的动态调用被视为类似于只有一个方法的接口。并且 使用反射 进行的调用被认为可以达到接口转换中使用的任何类型的任何方法,或者使用 reflect
包从其中可派生的任何类型。但在所有情况下,原理都是相同的。
测试
RTA 是一种全程序分析。这意味着它总是从一个 main 函数开始向前工作:您不能从像 encoding/json
这样的库包开始。
然而,大多数库包都有测试,而测试也有 main 函数。我们看不到它们是因为它们是在 go test
背后生成的,但我们可以使用 -test
标志将它们包含在分析中。
如果这报告库包中的某个函数是死的(不可达的),则表明您的测试覆盖率有待提高。例如,此命令列出了 encoding/json
中所有未被任何测试覆盖到的函数
$ deadcode -test -filter=encoding/json encoding/json
encoding/json/decode.go:150:31: unreachable func: UnmarshalFieldError.Error
encoding/json/encode.go:225:28: unreachable func: InvalidUTF8Error.Error
(-filter
标志将输出限制为与正则表达式匹配的包。默认情况下,该工具会报告初始模块中的所有包。)
可靠性
所有静态分析工具必然会产生目标程序可能的动态行为的不完美近似。工具的假设和推断可能是“可靠的”(sound),这意味着保守但可能过于谨慎;也可能是“不可靠的”(unsound),这意味着乐观但不总是正确。
deadcode 工具也不例外:它必须近似通过函数值和接口值或使用反射进行的动态调用的目标集合。在这方面,该工具是可靠的。换句话说,如果它报告某个函数是死代码,则意味着即使通过这些动态机制也无法调用该函数。然而,该工具可能无法报告一些实际上永远不会执行的函数。
deadcode 工具还必须近似那些由非 Go 代码函数进行的调用集合,这是它无法看到的。在这方面,该工具是不可靠的。其分析不了解完全由汇编代码调用的函数,也不了解由 go:linkname
指令引起的函数别名。幸运的是,这两个特性在 Go 运行时之外很少使用。
试用一下
我们定期在项目上运行 deadcode
,尤其是在重构工作之后,以帮助识别程序中不再需要的部分。
让死代码得以安息后,您可以专注于消除那些寿命已尽却顽固地存活下来、不断消耗您生命力的代码。我们将这种“不死”函数称为“吸血鬼代码”!
请试用一下
$ go install golang.org/x/tools/cmd/deadcode@latest
我们发现它很有用,希望您也觉得如此。
下一篇文章:分享您关于使用 Go 开发的反馈
上一篇文章:Go 开发者调查 2023 年下半年结果
博客索引