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...

昨年頃から、VS Codeなどのエディタを支えるGoの言語サーバーであるgoplsの構造に多くの変更を加えてきました。典型的な変更では、既存の関数を書き換え、その新しい動作が既存のすべての呼び出し元のニーズを満たすように注意深く確認します。しかし、すべての努力を払った後、呼び出し元の1つが実際にはどの実行でも到達できないことに気づき、安全に削除できることが判明することがありました。事前にこのことが分かっていれば、リファクタリング作業はもっと簡単だったでしょう。

以下の単純な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`メソッドは`main`から呼び出されることが分かっている`Greeter`インターフェースを実装するために必要とされているため、単純に`goodbye`を削除することはできません。しかし、`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`パッケージのエントリポイント、つまり`main`関数と、グローバル変数を割り当てて`init`という名前の関数を呼び出すパッケージ初期化関数だけです。

RTAは、各到達可能関数の本体にあるステートメントを調べて、3種類の情報を収集します。直接呼び出す関数のセット、インターフェースメソッドを介して行う動的呼び出しのセット、およびインターフェースに変換する型のセットです。

直接の関数呼び出しは簡単です。呼び出し先を到達可能な関数のセットに追加するだけで、呼び出し先に初めて遭遇した場合は、`main`の場合と同じようにその関数本体を検査します。

インターフェースメソッドを介した動的呼び出しは、インターフェースを実装する型のセットが分からないため、より複雑です。プログラム内の型が一致するすべてのメソッドが呼び出しの可能なターゲットであると想定したくありません。なぜなら、これらの型のいくつかはデッドコードからのみインスタンス化される可能性があるからです!そのため、インターフェースに変換される型のセットを収集します。変換により、これらの各型が`main`から到達可能になるため、そのメソッドは動的呼び出しの可能なターゲットになります。

これは、鶏が先か卵が先かという状況につながります。新しい到達可能関数に遭遇するたびに、より多くのインターフェースメソッド呼び出しと、具象型からインターフェース型へのより多くの変換が発見されます。しかし、これらの2つのセット(インターフェースメソッド呼び出し×具象型)のクロス積が大きくなるにつれて、新しい到達可能関数が発見されます。「動的計画法」と呼ばれるこのクラスの問題は、(概念的に)大きな2次元テーブルにチェックマークを付け、追加するチェックがなくなるまで行と列を追加することで解決できます。最終的なテーブルのチェックマークは、何が到達可能かを示しています。空白のセルはデッドコードです。

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年第2四半期結果
ブログインデックス