Go 使用 deadcode 查找无法访问的函数

这篇文章是对 Go 官方博客 Finding unreachable functions with deadcode 的翻译

项目源代码中存在但在任何执行过程中都无法访问的函数被称为“死代码”,会对代码库维护工作造成负担。今天,我们很高兴与您分享一个名为 deadcode 的工具,帮助您识别这些死代码。

1
2
3
4
5
$ go install golang.org/x/tools/cmd/deadcode@latest
$ deadcode -help
The deadcode command reports unreachable functions in Go programs.

Usage: deadcode [flags] package...

案例

在过去一年左右的时间里,我们对 VS Code 和其他编辑器使用的 Go 语言服务器 gopls 的结构进行了大量更改。一个典型的更改可能是重写一些现有的函数,并确保其新行为满足所有现有调用者的需求。有时,在付出所有努力之后,我们会沮丧地发现其中一个调用者实际上从未在任何执行中被调用,因此可以安全地将其删除。如果我们事先知道这一点,我们的重构任务就会更容易。

下面的简单 Go 程序说明了这个问题:

1
2
module example.com/greet
go 1.21
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
$ go run .
hello

从程序的输出可以清楚地看出,它执行了 hello 函数,但没有执行 goodbye 函数。不太明显的是,goodbye 函数永远不会被调用。然而,我们不能简单地删除 goodbye,因为它是 Goodbyer.Greet 方法所必需的,而 Goodbyer.Greet 方法又是实现 Greeter 接口所必需的,我们可以看到 Greeter 接口的 Greet 方法是从 main 调用的。但是如果我们从 main 开始往前分析,可以看到没有 Goodbyer 值被创建过,所以 main 中的 Greet 调用只能到达 Helloer.Greet。这就是 deadcode 工具所使用的算法背后的想法。

当我们使用 deadcode 来检查这个程序时,该工具会告诉我们 goodbye 函数和 Goodbyer.Greet 方法都是不可到达的

1
2
3
$ deadcode .
greet.go:23: unreachable func: goodbye
greet.go:20: unreachable func: Goodbyer.Greet

有了这个信息之后,我们就可以安全的删除这两个函数,以及 Groodbyer 接口。

该工具还可以解释为什么 hello 函数是活跃的。它会从 main 开始,以函数调用链的形式显示到达 hello 函数的路径

1
2
3
4
$ 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 标志来指定更加丰富的输出以供其他工具使用。

deadecode 是如何工作的

deadcode 工具对指定的包进行 加载, 解析, 以及类型检查 ,然后将它们转换为类似于典型编译器的中间表示

然后,它使用一种称为快速类型分析(Rapid Type Analysis,RTA)的算法来构建可访问函数的集合。该集合最初仅包含每个 main 包的入口点:main 函数和包初始化函数,用于分配全局变量并调用名为 init 的函数。

RTA 会查看每个可达函数体内的语句,以收集三种信息:它直接调用的函数集合;它通过接口方法进行的动态调用集合;以及它转换为接口的类型集合。

直接函数调用很简单:我们只需将被调用者添加到可达函数的集合中,如果是第一次遇到被调用者,我们会像对待 main 函数一样检查它的函数体。

通过接口方法进行的动态调用则比较棘手,因为我们不知道实现该接口的类型集合。我们不想假设程序中所有类型匹配的方法都是调用的可能目标,因为其中一些类型可能只会从死代码中实例化!这就是我们收集转换为接口的类型集合的原因:这种转换使得这些类型中的每一个都可以从 main 访问,因此它们的方法现在可以成为动态调用的目标。

这导致了一种鸡生蛋蛋生鸡的情况。当我们遇到每个新的可达函数时,我们会发现更多的接口方法调用和更多的具体类型到接口类型的转换。但是,随着这两个集合(接口方法调用 × 具体类型)的叉积越来越大,我们会发现新的可达函数。这类问题被称为“动态规划”,可以通过(概念上)在一个大的二维表格中打勾来解决,随着我们前进,添加行和列,直到没有更多的勾要添加。最终表格中的勾告诉我们什么是可达的;空白单元格是死代码。

deadcode-rta

对(非方法)函数的动态调用类似于单个方法的接口。使用反射进行的调用被认为可以访问接口转换中使用的任何类型的任何方法,或者使用 reflect 包从一个类型派生的任何类型。但原则在所有情况下都是一样的。

测试

RTA 是一种全程序分析方法。这意味着它始终从 main 函数开始并向前工作:你不能从像 encoding/json 这样的库包开始。

然而,大多数库包都有测试,而测试有 main 函数。我们看不到这些函数,因为它们是在 go test 的幕后生成的,但我们可以使用 -test 标志将它们包含在分析中。 如果这报告库包中的函数是死的,这是一个迹象,表明你的测试覆盖率可以改进。例如,这个命令列出了 encoding/json 中所有没有被任何测试覆盖的函数:

1
2
3
$ 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,尤其是在重构工作之后,以帮助识别程序中不再需要的部分。

在清理死代码后,您可以专注于消除那些已经寿终正寝但却顽固地存在的代码,这些代码会继续消耗您的生命力。我们称这种不死的函数为“吸血鬼代码”! 请试一试:

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

我们发现这是很有用的, 希望对你也是。


Go 使用 deadcode 查找无法访问的函数
https://blog.zhangliangliang.cc/post/finding-unreachable-functions-with-deadcode.html
作者
Bobby Zhang
发布于
2023年12月20日
许可协议