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 的结构进行了许多更改,gopls 是 Go 的语言服务器,为 VS Code 和其他编辑器提供支持。典型的更改可能是重写一些现有函数,并确保其新行为满足所有现有调用者的需求。有时,在我们付出了所有努力之后,我们会沮丧地发现其中一个调用者在任何执行中实际上从未被访问过,因此可以安全地将其删除。如果我们事先知道这一点,我们的重构任务就会变得更容易。

下面的简单 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` 接口,我们看到该接口的 `Greet` 方法从 `main` 中调用。但是,如果我们从 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` 标志将输出限制为与正则表达式匹配的包。默认情况下,该工具会报告初始模块中的所有包。)

健壮性

所有静态分析工具 必然 会产生目标程序可能动态行为的不完美近似。工具的假设和推断可能是“健壮的”,这意味着保守但可能过于谨慎,或者“不健壮的”,这意味着乐观但并非总是正确。

deadcode 工具也不例外:它必须近似通过函数和接口值或使用反射进行的动态调用的目标集。在这方面,该工具是健壮的。换句话说,如果它报告一个函数是死代码,则意味着该函数即使通过这些动态机制也无法被调用。但是,该工具可能无法报告实际上永远无法执行的某些函数。

deadcode 工具还必须近似从非 Go 编写的函数发出的调用集,这些函数是它无法看到的。在这方面,该工具并不健壮。它的分析没有意识到仅从汇编代码调用的函数,或者从 `go:linkname` 指令 产生的函数别名。幸运的是,这两项功能在 Go 运行时之外很少使用。

试用

我们定期在我们的项目上运行 `deadcode`,尤其是在重构工作之后,以帮助识别不再需要的程序部分。

随着死代码的消亡,您可以专注于消除那些已经寿终正寝但顽固地存活下来的代码,继续消耗您的生命力。我们把这种不死函数称为“吸血鬼代码”!

请试用

$ go install golang.org/x/tools/cmd/deadcode@latest

我们发现它很有用,希望您也是。

下一篇文章:分享您使用 Go 开发的反馈
上一篇文章:Go 开发者调查 2023 H2 结果
博客索引