使用 deadcode 查找 Go 程序中的从未使用的函数

Golang
195
0
0
2024-03-06

原文在这里

由 Alan Donovan 发布于2023年12月12日

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 进行了很多结构性的变更,这是 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 方法所需的,而 Goodbyer.Greet 方法则是实现 Greeter 接口所必需的,我们可以看到 main 中调用了 Greeter 接口的 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 命令加载解析类型检查指定的包,然后将它们转换为类似于典型编译器的中间表示形式

它使用称为 Rapid Type Analysis (RTA) 的算法来建立可达函数的集合,最初仅为每个主包的入口点:main 函数和包初始化器函数,该函数分配全局变量并调用名为 init 的函数。

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

直接的函数调用很容易理解:我们只需将被调用的函数添加到可达函数的集合中,如果这是我们第一次遇到被调用的函数,我们会以与 main 函数相同的方式检查其函数体。

通过接口方法的动态调用要复杂一些,因为我们不知道实现接口的类型集合。我们不希望假设程序中所有类型匹配的可能方法都是调用的潜在目标,因为其中一些类型可能只在死代码中实例化!这就是为什么我们收集转换为接口的类型集合的原因:转换使得这些类型中的每一个都从 main 函数变得可达,因此它们的方法现在是动态调用的潜在目标。

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

deadcode main.go

main 函数实例化了 Helloer,而 g.Greet 调用分派到已实例化的每种类型的 Greet 方法。

对(非方法)函数的动态调用类似于具有单个方法的接口。使用反射进行的调用被认为能够到达通过接口转换使用的任何类型的任何方法,或者通过 reflect 包从一个类型派生的任何类型。但在所有情况下,原则都是相同的。

测试

RTA 是一种整体程序分析。这意味着它总是从一个 main 函数开始并向前工作:你不能从一个库包(比如 encoding/json)开始。

但是,大多数库包都有测试,并且测试具有主要功能。我们看不到它们,因为它们是在 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

我们已经看到了它的用处,希望你也能。

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。