go-defensive-coding について
go-defensive-codingは、Go開発者がコードを書く際やレビューする際に使う実践的なリファレンススキルです。障害につながりやすい言語仕様やリソース管理を扱い、作業モードと確認項目を備えています。
主な機能
- 01型付きnilインターフェースの落とし穴を解説
- 02nil map、slice、channelの動作を説明
- 03sliceのエイリアスと防御的コピーを解説
- 04数値変換と比較の安全性を扱う
- 05リソースのライフサイクルとゼロ値設計を扱う
- 06Goツールとレビュー項目を提示
活用シーン
- 01変更実装中にGoコードを堅牢化
- 02パニックやデータ破損のリスクをレビュー
- 03数値変換と共有sliceを確認
- 04遅延クリーンアップとリソース処理を監査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-defensive-codingインストール後、Claude Code で/go-defensive-codingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/copies-and-lifecycle.mdnil-and-aliasing.mdnumeric-safety.md詳細
Go の防御的コーディング
Go には例外も、型システムによる null 安全性もありません。以下の落とし穴はどれも 問題なくコンパイルでき、レビューにも通り、本番環境で障害を引き起こします。
必要に応じて読み込む詳細なリファレンス資料:
references/nil-and-aliasing.md— 型付き nil のルール全般、スライスの エイリアシングのシナリオ、メモリ保持。references/numeric-safety.md— 変換の範囲チェック、オーバーフロー 検出、浮動小数点数と時刻の比較。references/copies-and-lifecycle.md—copylocks/noCopy、ループ内のdefer、遅延実行されるCloseのエラー確認、ゼロ値の使用可能性。
以下のセクションだけでは不十分な場合に限り、リファレンスファイルを読んでください。
作業モード
- Harden — コードを記述または変更します。作業しながらすべてのルールを適用してください。
- Review — 既存のコードを監査します。重大度(🔴 パニックまたはデータ破損、🟡 潜在的なバグ、🟢 スタイル)を添えて、ファイル名と行番号を示してください。
1. 型付き nil のインターフェースの落とし穴
nil ではないインターフェースに nil ポインターを格納できます。これは Go で「ありえない」 nil チェックが発生する最も一般的な原因です。
type NotFoundError struct{ ID string }
func (e *NotFoundError) Error() string { return "not found: " + e.ID }
// ❌ Bad — returns a non-nil error even on success
func find(id string) error {
var err *NotFoundError // typed nil
if id == "" {
err = &NotFoundError{ID: id}
}
return err // interface is (type=*NotFoundError, value=nil) — NOT nil
}
// ✅ Good — return the untyped nil literal
func find(id string) error {
if id == "" {
return &NotFoundError{ID: id}
}
return nil
}
ルール:
- 具象型のエラー変数やポインター変数を宣言し、インターフェースとして返してはいけません。成功時には明示的に
nilを返してください。 - nil かもしれない具象型のポインターを、
error、io.Reader、またはインターフェース型の構造体フィールドに格納してはいけません。 staticcheckは、この落とし穴によりnilとの比較が決してtrueにならない場合にSA4023を報告します。すべてのケースを検出できるツールはありません。
2. nil のマップ、スライス、チャネルの動作
この表を覚えてください。半分は安全ですが、残りの半分はパニックまたはハングします。
| 操作 | nil マップ | nil スライス | nil チャネル |
|---|---|---|---|
| 読み取り/受信 | ゼロ値 | インデックス指定でパニック | 永久にブロック |
| 書き込み/送信 | パニック | append は動作 | 永久にブロック |
len/cap | 0 | 0 | 0 |
range | 反復回数 0 | 反復回数 0 | 永久にブロック |
close | 該当なし | 該当なし | パニック |
// ✅ A nil slice is a valid empty slice — do not guard append
var out []string
out = append(out, "a")
// ❌ A nil map is read-only
var m map[string]int
m["k"] = 1 // panic: assignment to entry in nil map
// ✅ Initialise every map before writing
m := make(map[string]int)
[]T{} ではなく nil スライスを返してください。フィールドに omitempty が指定されている場合、encoding/json ではどちらも同じように JSON にマーシャルされ、割り当てコストもかかりません。呼び出し元がそのマップに書き込むことが文書で明記されている場合に限り、空の非 nil マップを返してください。
3. スライスのエイリアシング
スライスはビューです。容量に余裕がある場合、append はそのビューを通じて書き込み、呼び出し元が所有しているデータを変更します。
a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99) // ❌ overwrites a[2]; a is now [1 2 99 4]
// ✅ Full slice expression caps the view — append must reallocate
b := a[:2:2]
b = append(b, 99) // a is untouched
制御できないコードにサブスライスを渡す場合や、構造体フィールドがより大きなバッファーのサブスライスを保持する場合は、必ずこの方法を適用してください。
サブスライスは、背後にある配列全体を生存状態に保つことにもなります。大きなバッファーを解放するには、必要なデータをコピーしてください。例:head := slices.Clone(buf[:64])。
4. 境界での防御的コピー
スライスとマップは参照型です。コピーせずに保存したり返したりすると、内部データを変更できるハンドルを渡すことになります。
type Config struct{ hosts []string }
// ❌ Bad — caller can mutate our state, both ways
func NewConfig(hosts []string) *Config { return &Config{hosts: hosts} }
func (c *Config) Hosts() []string { return c.hosts }
// ✅ Good — copy in, copy out
func NewConfig(hosts []string) *Config {
return &Config{hosts: slices.Clone(hosts)}
}
func (c *Config) Hosts() []string { return slices.Clone(c.hosts) }
マップには maps.Clone を使用してください。どちらもシャローコピーです。[]*User をクローンしても、ポインターが指すユーザーは共有されたままです。
値を呼び出し後も保持する場合や、呼び出し元に公開する場合はコピーしてください。関数内で読み取るだけのスライスはコピーしないでください。無駄なメモリー割り当てになります。
意図しないコピーの防止
sync.Mutex、sync.WaitGroup、または atomic.Int64 を保持する構造体をコピーしてはいけません。値レシーバーや値渡しのパラメーターを使うと、コピー側に独立したロックができ、両方が同期済みだと思い込んでしまいます。go vet の copylocks でこれらを検出できます。ロックを持たないコピー禁止型には noCopy マーカーを埋め込み、vet でも検出できるようにしてください。例:
references/copies-and-lifecycle.md。
5. 数値変換と比較
Go の数値変換でパニックが発生することはありません。値が切り捨てられます。
// ❌ Silent corruption when the value does not fit
count := int32(int64Total)
// ✅ Range-check before narrowing
if int64Total > math.MaxInt32 || int64Total < math.MinInt32 {
return fmt.Errorf("total %d out of int32 range", int64Total)
}
count := int32(int64Total)
同じことは int → uint(負の値はラップして巨大な値になります)と、len() の結果をサイズ指定型に代入する場合にも当てはまります。gosec はこれらを G115 として報告します。
浮動小数点数を == で比較してはいけません。許容誤差と比較してください。time.Time を == で比較してはいけません。Equal は時点を比較しますが、== は単調時計の読み取り値とロケーションも比較します。例:
references/numeric-safety.md。
整数のゼロ除算はパニックになります。浮動小数点数のゼロ除算は ±Inf または NaN を返し、NaN != NaN です。入力値に由来する除数はチェックしてください。
6. リソースのライフサイクル
deferはループ本体の末尾ではなく、関数の戻り時に実行されます。ループ内のdefer f.Close()は、ループが終わるまですべてのファイルを開いたままにします。各反復を独自の関数スコープに入れてください。resp.Body.Close、rows.Close、mu.Unlock、tx.Rollbackも同様です。- 書き込みを行った対象の
Closeを遅延実行する場合は、そのエラーを確認してください。クローズ時のフラッシュ失敗は、データが黙って失われることにつながります。本文の処理が成功した場合は、名前付きのerr戻り値に代入してください。
例:references/copies-and-lifecycle.md。
7. ゼロ値と初期化の安全性
ゼロ値で動作するように型を設計すれば、コンストラクターの呼び出し忘れを防げます。
sync.Mutex のゼロ値はそのまま使用でき、nil マップは読み取り可能です。ロックの下で、最初の書き込み時にマップを遅延初期化してください。例:references/copies-and-lifecycle.md。
遅延初期化を必ず一度だけ行い、競合の可能性がある場合は sync.Once を使用してください。
init() は避けてください。main の前に実行され、適切にエラーを返すことができず、単独でテストできません。また、複数ファイル間での実行順序がファイル名に依存します。エラーを返す明示的な New... コンストラクターを使用してください。
ツールで徹底する
これらを実行してください。読むだけで済ませないでください。インストールされていないツールはスキップし、その旨を記録してください。
go vet ./... # copylocks, lostcancel, printf
golangci-lint run # errcheck, bodyclose, makezero, sqlclosecheck
staticcheck ./... # SA4023 typed-nil comparisons
gosec -include=G115,G104 ./... # integer overflow, unhandled errors
go test -race ./... # aliasing bugs often surface as races
関連する golangci-lint のリンター:errcheck、bodyclose、sqlclosecheck、rowserrcheck、makezero、nilerr、exhaustive、および nilness アナライザーを有効にした govet。go vet 自体は nilness を実行しません。
検証チェックリスト
- 成功パスで、具体的なポインター型をインターフェースとして返す関数がない
- すべてのマップが、最初の書き込みより前に初期化されている
- パッケージ境界を越えて渡す部分スライスには、完全スライス式
a[:n:n]を使用している - 構造体に格納される、または構造体から返されるスライスとマップが複製されている
- ミューテックスまたはアトミック値を保持する型を、値として渡したり受け取ったりしていない
- すべての数値の縮小変換で範囲を確認しているか、値の範囲が制限されていることを文書化している
- 浮動小数点数または
time.Timeに対して==を使用していない - 入力から導出した除数がゼロでないことを確認している
- 外側に関数スコープがないループ本体内で
deferを使用していない - 書き込みを行ったリソースに対する遅延
Closeのエラーを報告している go vet、golangci-lint、go test -raceで問題がない