Go 博客

使用 deadcode 查找不可达函数

Alan Donovan
2023 年 12 月 12 日

属于项目源代码但任何执行路径都永远无法到达的函数被称为“死代码”,它们会给代码库的维护工作带来负担。今天我们很高兴分享一个名为 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 访问,因此其方法现在成为动态调用的潜在目标。

这导致了一个先有鸡还是先有蛋的情况。随着我们遇到每个新的可达函数,我们会发现更多的接口方法调用以及更多的具体类型到接口类型的转换。但随着这两个集合的笛卡尔积(接口方法调用 × 具体类型)不断增长,我们会发现新的可达函数。这类问题被称为“动态规划”,可以通过在一个大型二维表中(概念上)打勾来解决,随着分析的进行添加行和列,直到没有更多的勾可以添加。最终表格中的勾表示哪些是可达的;空白单元格则是死代码。

illustration of Rapid Type Analysis
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 年下半年结果
博客索引