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 年下半年結果
部落格索引