The Go Blog

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

この1年ほど、VS Code などのエディタを動かす Go の言語サーバーである gopls の構造に多くの変更を加えてきました。典型的な変更は、既存の関数を書き換えるもので、その新しい動作が既存のすべての呼び出し元のニーズを満たすように注意を払います。しかし、その努力を費やした後に、呼び出し元の一つが実際にはどの実行においても決して到達されていないことを知り、不満を感じることがありました。もしそれを事前に知っていれば、リファクタリング作業はもっと簡単だったでしょう。

以下の簡単な 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") }

実行すると、ハローと表示されます

$ go run .
hello

このプログラムの出力から、hello 関数は実行されるが、goodbye 関数は実行されないことが明らかです。一見しただけでは、goodbye 関数が決して呼び出されないことはあまり明らかではありません。しかし、goodbye を単純に削除することはできません。なぜなら、それは Goodbyer.Greet メソッドによって必要とされ、Goodbyer.Greet メソッドは、main から呼び出されている Greeter インターフェースの Greet メソッドを実装するために必要とされるからです。しかし、main から前向きに見ていくと、Goodbyer の値は決して作成されないため、mainGreet 呼び出しは 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 は、各到達可能な関数の本体内のステートメントを調べて、3種類の情報を収集します。直接呼び出す関数のセット、インターフェースメソッドを介して行う動的な呼び出しのセット、およびインターフェースに変換する型のセットです。

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

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

これは鶏と卵の関係につながります。新しい到達可能な関数に出会うたびに、より多くのインターフェースメソッド呼び出しと、具象型からインターフェース型へのより多くの変換を発見します。しかし、これら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 Developer Survey 2023 H2 結果
ブログインデックス