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 接口,而 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 年下半年结果
博客索引