はじめに
Goを書いていると、次のような現象に出会えます。
nilを直接代入 → コンパイルOK、でも実行するとpanic
package main
import "fmt"
type Animal interface {
Speak() string
}
// nilポインタをinterfaceに代入 → コンパイルOK
var a Animal = nil
func main() {
fmt.Println(a.Speak()) // panic
}
メソッドが未実装な構造体 → 即コンパイルエラー
package main
import "fmt"
type Animal interface {
Speak() string
}
type Cat struct{}
// func (c Cat) Speak() string { return "Meow." }
var a Animal = Cat{} // Compile Error
func main() {
fmt.Println(a.Speak())
}
「どうせ実行時にpanicするなら、nilも一緒にコンパイルエラーにしてくれればいいのに」と思いませんか?この記事ではその理由をGoの仕様から解説します。
nilとCat{}ではコンパイラが参照するルールが違う
Cat{}をinterfaceに代入するとき、コンパイラは 実装チェック を行います。Go仕様のInterface typesには次のように書かれています。
A type T implements an interface I if
- T is not an interface and is an element of the type set of I; or
- T is an interface and the type set of T is a subset of the type set of I.
CatはSpeak() stringを持たないのでAnimalの型集合に属せず、コンパイルエラーになります。
一方 nil の代入は、実装チェックとは別のルールで処理されます。Go仕様のAssignabilityには次のルールがあります。
x is the predeclared identifier nil and T is a pointer, function, slice, map, channel, or interface type, but not a type parameter.
つまり、Animalはinterface型なので、このルールにより nil は直接代入できます。実装チェックは発生しません。
また builtin パッケージでのnilの定義は次の通りです。
nil is a predeclared identifier representing the zero value for a pointer, channel, func, interface, map, or slice type.
nilはinterfaceのゼロ値そのものであり、具体的な型を持たない識別子です。実装チェックは「代入しようとしている値の型がメソッドを持つか」を見るものですが、nilには型がないため、そもそもチェックの対象になりません。
なぜnilのままメソッドを呼ぶとpanicするか
Go公式FAQ(Why is my nil error value not equal to nil?)には次のように書かれています。
Under the covers, interfaces are implemented as two elements, a type T and a value V. V is a concrete value such as an int, struct or pointer, never an interface itself, and has type T.
An interface value is nil only if the V and T are both unset, (T=nil, V is not set), In particular, a nil interface will always hold a nil type.
var a Animal = nil
// 内部: (T=nil, V=nil)
// Tがnilなのでメソッド呼び出しのディスパッチ先がない
a.Speak() // panic: nil pointer dereference
T=nilということは、どの型のメソッドを呼べばいいかわからない状態です。そのためメソッドを呼んだ瞬間にpanicします。
「nilなら静的にエラーにできないの?」という疑問
コンパイラが追跡するのは型だけで、値は追跡しません。
var a Animal = nil
a.Speak() // コンパイラはaが実行時にnilかどうかを知らない
たとえば次のケースを考えてください。
func getAnimal(fromDB bool) Animal {
if fromDB {
return &Dog{}
}
return nil // 正当な「値なし」の表現
}
a := getAnimal(false)
if a != nil {
a.Speak() // 安全
}
nilはinterfaceの正当なゼロ値であり、「値がセットされていない」状態を表します。nilチェックをした上でメソッドを呼ぶのがGoの想定する使い方です。
nilインターフェースに対してメソッドを呼ぶのは常にpanicします。nilチェックを忘れないようにしましょう。
まとめ:コンパイラが参照するルールの違い
| 代入する値 | コンパイラが参照するルール | 結果 |
|---|---|---|
nil |
Assignability: nilはinterface型に直接代入できる | OK |
Cat{}(Speak未実装) |
Interface types: CatがAnimalの型集合に属するか | エラー |
Cat{}(Speak実装済み) |
Interface types: CatがAnimalの型集合に属するか | OK |
nilの代入とconcrete型の代入は、コンパイラが見るルールが根本的に異なります。nilはGoのAssignabilityルールによって直接許可されており、実装チェックを通りません。一方、concrete型は実装チェックを通過する必要があります。
実行時には(T, V)という内部表現が重要です。T=nilのときはメソッドのディスパッチ先がなくpanicするため、nilチェックが不可欠です。
感想:このGoの仕様について思うところ
この設計の恩恵を受けるのは、主に次のパターンです。
func doSomething() (Result, error) {
return result, nil // errorインターフェースにnilを返す
}
Goの「エラーは戻り値で返す」イディオムにおいて、return nil が書けることは確かに便利です。
ただ、それ以外の場面で var a Animal = nil が通ることに嬉しさを感じる場面はほとんどありません。「コンパイルが通った」と思って実行したらpanicする、というのは型システムが守ってくれているとは言い難い状態です。
根本的な問題は、Goに Option<T>に相当する仕組みがない ことです。Rustでは「値がないかもしれない」という状態を Option<T> として型に明示し、コンパイラが None ケースの処理を強制します。nilをうっかり踏む実行時panicは構造的に発生しません。
Goがこの設計を選んだのはシンプルさの優先と設計当時の時代背景によるものですが、Rustのような後発言語がOption型を採用して nil 安全を実現しているのを見ると、「やっぱりあったほうがよかったのでは」と思わざるを得ません。