Tech Learning Daily

2026-10-27 (Tue) — 第 102 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Lang 🌿 基礎 ⏱ 約 7 分

nil を返したはずなのに、なぜ Go の err != nil が true になるのか?

バリデーション関数は何も問題なく終わり、最後の return p の p は確かに nil だった。それなのに呼び出し側の if err != nil に入り、ログには error: <nil> と出ている。nil なのに nil ではない値は、Go のどこから生まれるのだろうか。

🎯 3 行まとめ
  • Go の interface 値は「動的な型」と「動的な値」の 2 つの欄を持ち、== nil は両方が空のときだけ true になる。
  • nil の *MyError を error として返すと、型の欄に *MyError が入るので「中身が nil なのに nil ではない error」になる。
  • 対策は「エラーが無いときは素の nil を書いて返す」「戻り値の型を具体型でなく error にする」で、staticcheck の SA4023 が典型パターンを検出する。

interface 値は「型」と「値」の 2 欄でできている

interface 型とは、メソッドの集合だけを定めて、それを満たす任意の具体型の値を入れられる型である。error も Error() string というメソッドを 1 つだけ持つ interface 型にすぎない。

Go の FAQ によれば、interface 値は内部的に型 T と値 V の 2 要素で実装されている。V は int や構造体やポインタなどの具体的な値で、これを動的な値と呼ぶ。T はその値の具体型で、これを動的な型と呼ぶ。「動的」と呼ぶのは、同じ interface 変数が実行中に別の型・別の値を入れ替えて保持できるからである。

メモリ上ではこれは 2 ワードの組になる。Russ Cox の解説によると、1 ワード目は型情報を指し、メソッドを持つ interface では itab(interface table: 型のメタデータと、その interface のメソッドの関数ポインタ表)を指す。2 ワード目はデータを指す。err.Error() の呼び出しは、itab から関数ポインタを取り出し、2 ワード目をレシーバとして渡す処理にコンパイルされる。型の欄が要るのは、どの具体型の Error を呼ぶかをこの欄で決めているからである。

var err error  // 何も入れていない
┌──────────┬──────────┐
│ 型       │ 値       │
│ (空)     │ (空)     │ → err == nil: true
└──────────┴──────────┘

var p *MyError // p は nil ポインタ
err = p
┌──────────┬──────────┐
│ 型       │ 値       │
│ *MyError │ nil      │ → err == nil: false
└──────────┴──────────┘

== nil は 2 欄とも空かを見ている

言語仕様の比較演算子の節は、2 つの interface 値が等しいのは「動的な型が同一で動的な値が等しいとき、または両方が nil のとき」と定めている。interface 型の変数の初期値は nil で、このとき型の欄も値の欄も空である。つまり err == nil は「中身のポインタが nil か」ではなく「型の欄も値の欄も空か」を問う式である。

var p *MyError = nil の p を error 型の戻り値として返すと、コンパイラは *MyError から error への暗黙の変換を行い、型の欄に *MyError、値の欄に nil ポインタを入れる。FAQ の言い方では (T=*MyError, V=nil) である。型の欄が埋まっているので、この interface 値は nil ではない。

ここで lead の謎、ログに <nil> と出る件が回収できる。fmt は %v で error を表示するとき Error() を呼ぶ。中で nil ポインタを参照して panic しても、fmt のドキュメントにある通り「nil レシーバが原因の panic なら装飾なしの文字列 <nil> を出力する」。ログは中身が nil だと正直に表示しており、それでも interface としては nil ではない。%v でなく %T(引数の型を表示する verb)で出せば *main.MyError と表示され、型の欄が埋まっていることが見える。

🍱 たとえるなら

宛名ラベル付きの宅配便の箱である。「箱が届いていない」状態(ラベルも中身も無い)と、「〇〇エラー様宛」とラベルが貼られた空箱が届いた状態は違う。玄関先の判定 err != nil は「箱が届いたか」しか見ないので、空箱でも受け取った扱いになる。そして開封係(Error())は、ラベルに書かれた宛先の手順で箱を開ける。ラベルが無ければ誰が開けるかも決められないので、ラベルの有無が判定の基準になっている。

型付き nil はどこで紛れ込むのか

具体型のポインタを nil で初期化し、「問題があれば値を入れる」スタイルで最後に返すのが FAQ の典型例である。成功時には nil ポインタがそのまま error に変換されて返る。

// NG: 成功時も non-nil の error が返る
func validate(u User) error {
    var e *ValidationError
    if u.Name == "" {
        e = &ValidationError{Field: "name"}
    }
    return e
}

// OK: エラーが無ければ素の nil を返す
func validate(u User) error {
    if u.Name == "" {
        return &ValidationError{Field: "name"}
    }
    return nil
}

もう 1 つの経路は、具体型を返す関数の戻り値を error 型の変数に代入する場面である。func check() *ValidationError が nil を返しても、var err error = check() と受けた時点で同じ変換が起きる。関数の中では正しく nil を返しているので、関数単体のテストでは見つからず、呼び出し側でしか顕在化しない。

FAQ は、エラーを返す関数のシグネチャには具体型でなく error を使うよう勧めており、例として os.Open を挙げている。os.Open の失敗時の値は常に *os.PathError だが、戻り値の型は error である。戻り値を error にしておけば、関数の中で return nil と書いた箇所はそのまま空の interface 値になり、変換が起きる場所自体が減る。

なぜ Go は「中身が nil なら nil」と扱わないのか

中身が nil ポインタなら == nil を true にしてくれればよいように思える。しかし Go では、nil ポインタはメソッドを呼べる正当な値である。ポインタレシーバのメソッドは、中でレシーバを参照しない限り nil に対して呼んでも panic しない。たとえば連結リストの func (l *List) Len() int が l == nil なら 0 を返す、という書き方は合法である。

そのため (T=*List, V=nil) は「振る舞いを持つ値」であり、「何も入っていない」(T=nil) とは意味が違う。言語が両者を同一視すると、nil レシーバで動くメソッドを interface 越しに呼べなくなる。Go は区別を保つ設計を選び、その代償として error の文脈でこの落とし穴が生まれた。

呼び出し側で reflect.ValueOf(err).IsNil() のように中身まで確かめる回避策もあるが、これは返す側の間違いを受け取る側で吸収しているだけである。直すべきは返す側で、静的解析で見つけるのが現実的である。staticcheck の SA4023(2020.2 から利用可能)は「interface 値と untyped nil の比較が成立し得ない」パターンを報告する。

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck -checks SA4023 ./...

-checks を省略すると既定のチェック群がまとめて走る。CI では既定のまま回し、SA4023 を含む全体をゲートにするのが扱いやすい。

💼 実務でどう出会うか

よくあるのは、独自エラー型 *AppError を返すヘルパーを作り、ハンドラで var err error = svc.Do() と受けたら成功時も 500 が返る、という障害である。ログには <nil> としか出ないので原因が分かりにくい。%T でログに型を出すか、エラーを返す関数の戻り値型が具体型になっていないかを最初に疑うとよい。同じことは io.Reader などの他の interface でも起こり、nil の *bytes.Buffer を io.Reader 引数に渡すと、受け取り側の nil チェックをすり抜ける。

⌨️ 手を動かす(5 分)

中身が nil の *MyError を error として返し、== nil の結果と %v・%T の表示を比べる。ローカルに Go が無ければ go run の行を docker run --rm -v "$PWD":/w -w /w golang go run main.go に置き換える。

mkdir -p /tmp/typednil && cd /tmp/typednil
cat > main.go <<'EOF'
package main

import "fmt"

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

func bad() error {
	var p *MyError
	return p
}

func good() error {
	return nil
}

func main() {
	e1, e2 := bad(), good()
	fmt.Println("bad  == nil:", e1 == nil)
	fmt.Printf("bad  %%v=%v %%T=%T\n", e1, e1)
	fmt.Println("good == nil:", e2 == nil)
	fmt.Printf("good %%v=%v %%T=%T\n", e2, e2)
}
EOF
go run main.go

bad は == nil が false なのに、%v は <nil>、%T は *main.MyError と表示されるはずである。good は true で、%T も <nil> になり、型の欄まで空であることが確かめられる。

🙅 よくある誤解
  • ログに <nil> と出ているならエラーは nil だ — fmt は nil レシーバで panic した Error() を <nil> と表示するだけで、interface 値が nil かどうかは表示しない。型を見るには %T を使う。
  • nil ポインタに対してメソッドを呼ぶと必ず panic する — panic するのはメソッドの中でレシーバを参照したときで、参照しないメソッドは nil レシーバでも正常に動く。この性質があるので、Go は型付き nil を空の interface と区別している。
  • 具体型のエラーを返したほうが型安全で親切だ — 呼び出し側が error 変数で受けた瞬間に型付き nil が生まれる。具体型が欲しい呼び出し側は errors.As で取り出せるので、シグネチャは error にしておくのが安全である。
📖 用語ミニ辞典
interface 型
メソッドの集合だけを定め、それを満たす任意の具体型の値を保持できる型。
動的な型 / 動的な値
interface 値が実行時に保持している具体型と具体値。この 2 つの組が interface 値の中身である。
itab
interface 値の 1 ワード目が指す表。具体型の情報と、interface のメソッドの関数ポインタを持つ。
型付き nil
動的な型は埋まっているが動的な値が nil の interface 値。== nil は false になる。
nil レシーバ
nil ポインタに対してメソッドを呼んだときのレシーバ。参照しなければ panic しない。
🔗 もっと深く