フューチャー技術ブログ

go vet に含まれないスタンドアロンな静的解析ツールたち

はじめに

TIG真野です。育休明けです。

Go言語の特徴の1つに、静的解析ツールがリッチということがあると思いますが、代表格である go vet と付随する(という表現が正しいか不明ですが)ツール群についてまとめます。知っておくと似たようなツールを作ってしまうことを防げるかなという点と、類似のチェックを行いたい場合に参考にしたいこと、ユースケースが合えばこれらを利用してハッピーになりたいという思いがあります。

go vetとは

go vet はGo言語に標準で組み込まれた静的解析ツールで、コンパイラによってキャッチされないエラーや懸念を検出し報告してくれます。Linter(リンター)の一種です。

チェック内容の一覧は go tool vet help コマンドで確認できます。デフォルトでは全て有効になっており(-asmdecl=false などで個別に無効化は可能)、Go1.21時点では30のチェックが存在します。

$ go version
go version go1.21.1 linux/amd64

$ go tool vet help
(中略)
Registered analyzers:

asmdecl report mismatches between assembly files and Go declarations
assign check for useless assignments
atomic check for common mistakes using the sync/atomic package
bools check for common mistakes involving boolean operators
buildtag check //go:build and // +build directives
cgocall detect some violations of the cgo pointer passing rules
composites check for unkeyed composite literals
copylocks check for locks erroneously passed by value
directive check Go toolchain directives such as //go:debug
errorsas report passing non-pointer or non-error values to errors.As
framepointer report assembly that clobbers the frame pointer before saving it
httpresponse check for mistakes using HTTP responses
ifaceassert detect impossible interface-to-interface type assertions
loopclosure check references to loop variables from within nested functions
lostcancel check cancel func returned by context.WithCancel is called
nilfunc check for useless comparisons between functions and nil
printf check consistency of Printf format strings and arguments
shift check for shifts that equal or exceed the width of the integer
sigchanyzer check for unbuffered channel of os.Signal
slog check for invalid structured logging calls
stdmethods check signature of methods of well-known interfaces
stringintconv check for string(int) conversions
structtag check that struct field tags conform to reflect.StructTag.Get
testinggoroutine report calls to (*testing.T).Fatal from goroutines started by a test.
tests check for common mistaken usages of tests and examples
timeformat check for calls of (time.Time).Format or time.Parse with 2006-02-01
unmarshal report passing non-pointer or non-interface values to unmarshal
unreachable check for unreachable code
unsafeptr check for invalid conversions of uintptr to unsafe.Pointer
unusedresult check for unused results of calls to some functions
(省略)

vetはGo1.19でerrorsasが、Go1.20でloopclosureとtimeformatが追加されるなど、高品質にメンテナンスされているため安心して利用しているチームがほとんどだと思います。さすが標準ツールです。

Go1.20のvetについてはGo 1.20 vetのアップデートの記事もあります。

ちなみに、go test を実行するとGo 1.10から内部的にgo vetも動作するようになっている そうです。注意として、go testでgo vetのすべてのチェックが動くのではなく、信頼性の高い以下9つののサブセットに限る点です。go testgo vet の30種類すべてを動かしたい場合は go test -vet=all とオプションつけましょう。

  1. atomic
  2. bools
  3. buildtags
  4. directive
  5. errorsas
  6. ifaceassert
  7. nilfunc
  8. printf
  9. stringintconv

go vetを構成する要素

go vet の実装のうち、 mainパッケージのコードから抜粋します。 unitchecker.Main() の引数が先程の go tool vet help 結果で出てきたチェック一覧と一致することがわかると思います。

cmd/vet/main.goから抜粋
func main() {
objabi.AddVersionFlag()

unitchecker.Main(
asmdecl.Analyzer,
assign.Analyzer,
atomic.Analyzer,
bools.Analyzer,
buildtag.Analyzer,
cgocall.Analyzer,
composite.Analyzer,
copylock.Analyzer,
directive.Analyzer,
errorsas.Analyzer,
framepointer.Analyzer,
httpresponse.Analyzer,
ifaceassert.Analyzer,
loopclosure.Analyzer,
lostcancel.Analyzer,
nilfunc.Analyzer,
printf.Analyzer,
shift.Analyzer,
sigchanyzer.Analyzer,
slog.Analyzer,
stdmethods.Analyzer,
stringintconv.Analyzer,
structtag.Analyzer,
tests.Analyzer,
testinggoroutine.Analyzer,
timeformat.Analyzer,
unmarshal.Analyzer,
unreachable.Analyzer,
unsafeptr.Analyzer,
unusedresult.Analyzer,
)
}

では、引数の asmdecl.Analyzerunusedresult.Analyzer たちが何かというと、import元のパッケージを見ると、 golang.org/x/tools/go/analysis/passes であることがわかります。 passes パッケージは golang.org/x/tools/go/analysis パッケージの静的解析のAPIを利用して作られている集合で、各パッケージごとに静的解析のチェックが実装されているというわけです。わかりやすい構成ですね。

スタンドアローンドライバー

さて、golang.org/x/tools/go/analysis/passes のGoDocを見ると、 foo/cmd/foo といった形式で存在するドライバー(コマンド)が存在します。2023年9月5日公開 Version: v0.13.0だと、以下の5種類です。日本語訳はChatGPTさんにお願いしました。なぜかifaceassert、lostcancel、stringintconv、unmarshal、unusedresultはcmdパッケージがあるものの、go vetに含まれているため除外しています。

これらは先程紹介したgo tool vet help にも cmd/vet/main.go にも含まれないため、golang.org/x/tools/go/analysis/passes パッケージに存在すれど go vet で行われないチェックツールです。この記事の趣旨はこいつらってどのような存在か抑えておこうというものです。これらの使い方は後述します。

Name Description
defers package defersは、defer文の一般的な間違いをチェックするAnalyzerを定義します。
fieldalignment package fieldalignmentは、フィールドがソートされている場合に、より少ないメモリを使用するstructを検出するAnalyzerを定義します。
findcall package findcallは、Analysis APIの単純な例とテストとして機能するAnalyzerを定義します。
nilness package nilnessは、SSA関数の制御フローグラフを検査し、nilポインタの参照や退化したnilポインタの比較などのエラーを報告します。
shadow package shadowは、シャドウされた変数をチェックするAnalyzerを定義します。

使い方は5種類すべて同様に以下の流れで利用できます。

  1. 各 xxx/cmd/xxx をインストールする
  2. go vet-vettool オプションでインストールしたコマンドのパスを渡す
    • -vettool で渡す値は、絶対パスである必要があるので注意ください(後述で command -v やらを使っている理由がそれです)

まずは5つのツールをすべてインストールします。

go install golang.org/x/tools/go/analysis/passes/defers/cmd/defers@latest
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
go install golang.org/x/tools/go/analysis/passes/findcall/cmd/findcall@latest
go install golang.org/x/tools/go/analysis/passes/nilness/cmd/nilness@latest
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest

defers

defersはdefer構文でのよくある間違いを検知してくれるツールです。

検知するためのコードを準備します。このコードはdefersのGoDocに掲載されていた内容なので新規性は無いです。

defersで検知するコード
package example

import "time"

func Defers() {
start := time.Now()
defer recordLatency(time.Since(start)) // error: call to time.Since is not deferred

// any logic
}

func recordLatency(d time.Duration) {
// any logging
}
defersの実行
# (Windowsでは $()の部分を %USERPROFILE%\go\bin\defer などに書き換えてください)
$ go vet -vettool=$(command -v defers) ./...
# example
.\vetdefers.go:7:22: call to time.Since is not deferred

-vettool オプションでdefers を渡すことで実行できました。最後の行に出ているのが検知したメッセージです。

検知した内容としては vetders.go のL7行目の deferで呼ばれる recordLatency() の引数、 time.Since(start) がdeferが呼ばれるタイミングではなく、即時評価されちゃっているということです。

おそらく以下が正しいのでは? という検知です。うっかりやっちゃいそうですね。

おそらく正しいと思われる実装
defer func() { recordLatency(time.Since(start)) }()

これが標準で入っていない理由ですが、ハッキリとしたIssueやレビューのやり取りを見つけられずでした。推測ですが、検知するのが time.Since のみなのでピンポイントすぎる内容であること、影響度も処理時間計測で用いられるものが大半だと思われるので、クリティカルで無いとみなされたのかなと思います。

fieldalignment

fieldalignmentはフィールドを並び替えた場合にメモリ消費量が抑えられるstructを検出してくれるツールです。

例えば以下のようなコードがあったとします(※GoDocから引っ張ってきているのでコード例にも新規性は無いです)。

fieldalignment.go
package example

type Good struct {
y int32 //4byte
x byte // 1byte
z byte // 1byte
}

type Bad struct {
x byte // 1byte
y int32 // 4byte
z byte // 1byte
}
go vet -vettool=$(command -v fieldalignment) ./...
# example
.\fieldalignment.go:9:10: struct of size 12 could be 8

実行すると、9行目の Bad という structが検知されます。byte型は1byteです。メモリは4byteごとに確保され(64bit OSだと最大8byteまでは同じ領域に連続して格納できるため)、x,zを連続させると4byteで格納できます。int32(4byte) + byte型の2フィールド(4byte) = 8byteにできるわけです。もし、int32を間に入れると(Bad structの場合)、4byte(x分) + 4byte(y分) + 4byte(z分)で12byteとなってしまい、無駄が生じているよというわけです。

一応、Playgroundも用意しました。気になる方は確認ください。

https://go.dev/play/p/BMtHxH9B_cF

これも go vet 標準に含めな議論を見つけられなかったですが、推測するとStructのフィールドを並び替えることで可読性などを落としメモリ省力化を追求することで利点が多いユースケースがメジャーではないこと(組み込みなど厳しいマッチしたケースは当然あると思います)があるのかなと思います。有用ですがデフォルトで有効にするものではないよねということです。

findcall

findcallは特定の関数・メソッドが呼ばれているかチェックするツールです。 -name オプションで対象の関数・メソッドを指定します。

package example

func main() {
println("hi")
print("hi")
}

func println(s string) {}
go vet -vettool=$(command -v findcall) -name println ./...
# example
.\findcall.go:4:9: call of println(...)

これについてはユースケースがピンと来ず、CIで組み込むというよりは開発中に個別で気になった関数・メソッドの利用可否をチェックするといった使われ方でしょうか。検査対象の名称を引数で渡す必要があるため、現在の形式では go vet 標準で組み込まれることはない認識です。

静的解析の題材としてtennntenさんが取り上げているイメージがあるので、これを土台として有用なツールが作られており、その観点では重要な位置づけにあるツールに見えます。

https://engineering.mercari.com/blog/entry/2018-12-16-150000/

nilness

nilness は nil ポインターの逆参照などを検知するツールです。例えばif分でnil検査をしているにもかかわらず、その変数にアクセスするとpanicが生じます。そういった実装を検知してくれます。

package example

type X struct{ f, g int }

func fnilness(x, y *X) {
if x == nil {
print(x.f) // L7 ★検知
} else {
print(x.f)
}

if x == nil {
if nil != y {
print(1)
panic(0)
}
x.f = 1 // L17 ★検知
y.f = 1 // L18 ★検知
}
}
>go vet -vettool=$(command -v nilness) ./...
# example
.\nilness.go:7:11: nil dereference in field selection
.\nilness.go:17:5: nil dereference in field selection
.\nilness.go:18:5: nil dereference in field selection

便利そうな気もしますね。なぜこれが go vet 標準でないのかは良く分かりませんでしたが、プロポーサルに理由が書いてありました。

それによると、go vetgo test に最終的にはすべて含まれることを目指しており、 nilness はそのための(速度)基準をまだ満たしていないといったことが壁のようです。

shadow

shadowはシャドウイングを検知してくれるツールです。シャドウイングについてはmattnさんのGo 言語で変数のシャドウイングを避けたいなら shadow を使おう。記事を参考ください(まさにshadowの紹介もしていますね)。

package example

import (
"fmt"
"os"
"testing"
)

func BadRead(f *os.File, buf []byte) error {
var err error // L10L
for {
_, err := f.Read(buf) // L12
if err != nil {
break // L14
}
foo(buf)
}
return err
}

func foo(buf []byte) {}

これに対してshadowを実行します。

>go vet -vettool=$(command -v shadow) ./...
# example
.\shadow.go:12:6: declaration of "err" shadows declaration at line 10

12行目の , err := f.Read(buf) で、 := で代入しているため、L10行目で宣言した err とは同一名称で異なる変数が宣言されています。そのためL14でbreakしても戻り値のerrはnilのままです。いい感じに検知してくれていますね。

シャドウイング自体はGoの言語機能として本来問題ないはずなのでgo vet標準には組み込まれていないのだと思います。

-vettool に複数プログラムを指定できない

なんとなく、以下のように一括でチェックができるイメージがありましたが、以下のような指定はできません。

NGケース(複数の-vettoolオプションは渡せない)
go vet -vettool=$(command -v defers) \
-vettool=$(command -v fieldalignment) \
-vettool=$(command -v nilness) \
-vettool=$(command -v shadow) ./...

そのため、go vetを複数回起動することになると思います。go vet 標準と、スタンドアローンドライバー4種類を実行する場合は以下のようにコマンドを並べることになると思います。

OKケース
go vet ./...
go vet -vettool=$(command -v defers) ./...
go vet -vettool=$(command -v fieldalignment) ./...
go vet -vettool=$(command -v nilness) ./...
go vet -vettool=$(command -v shadow) ./...

また、 -vettool を指定すると、go vet組み込み+vettoolのチェックを行うと私は勘違いしてしまいましたが、あくまでvettoolのチェックのみ行われることに注意ください。

まとめ

go vet に近い静的解析ツールを5種類紹介しました。それらが解決する課題がチームにマッチする場合は、有効にして取り入れてると良いかなと思いました。また、類似の課題の場合はそれらのコードを参考に、自分たちで解析ツールを作るといったことにも役立つと思います。

アイキャッチはUnsplashEtactics Incが撮影した写真です。