Go言語の型付きnilにご用心
Go言語の型付きnil
Table of contents
author: oikawa-k
Go言語の仕様として、型付きnil (interfaceの変数が値はnilだが型情報はnilではない) というものがあります。 この仕様はGo言語のわかりにくい仕様で有名であるため注意点を中心に解説します。
型付きnilで注意するべき点
型付きnilの問題が最もよく現れるのが error interfaceを返す関数です。以下のコードを見てください。
type MyError struct{}
func (e *MyError) Error() string {
return "my error"
}
func doSomething() *MyError {
// 何らかの処理をして、エラーがなければnilを返す
return nil
}
func process() error {
err := doSomething()
return err // ここで*MyError型のnilがerror interfaceに変換される
}
func main() {
err := process()
if err != nil {
fmt.Println("エラーが発生しました") // ここが実行されてしまう
}
}
doSomething() は *MyError 型のnilを返しており、process() 内の err := doSomething() の時点では err はただの *MyError 型のポインタなので、err == nil は正しくtrueと判定されます。
問題は return err の部分です。process() の返り値の型は error interfaceなので、*MyError 型の err はここで暗黙的に error interfaceへ変換されます。このときGoは「型情報が *MyError で値がnil」というペアを持つinterfaceを作り出すため、process() が返す error interface自体はnilではなくなります。
結果として main() 側の err != nil はtrueになってしまいます。これは err の型情報 (*MyError) が非nilであり、値だけがnilという状態、すなわち型付きnilになっているためです。interfaceの比較は型情報と値の両方が揃って初めてnilと判定されるため、意図せずエラーとして扱われてしまいます。
同様の問題はerrorに限らず、interfaceを返り値として使うあらゆる場面で発生し得ます。特にラッパー関数やミドルウェアのように、具体的な型のポインタをそのままinterfaceとして返す実装では注意が必要です。
型付きnilというわかりにくい挙動はどのようにして発生するのか
この挙動を理解するには、Goのinterfaceが内部的にどう表現されているかを知る必要があります。interfaceの変数は「型情報 (type)」と「値へのポインタ (value)」のペアとして保持されています。
interface = (type, value)
var err error のようにinterfaceを何も代入せず宣言した場合は (nil, nil) となり、これは正真正銘のnilです。一方で var p *MyError = nil を error interfaceに代入すると (*MyError, nil) という組が作られます。typeフィールドには *MyError という型情報がしっかり入っているため、interface自体は (nil, nil) ではなくなり、nil との比較結果はfalseになります。
なぜこのような設計になっているかというと、interfaceの型アサーションや型switchを正しく機能させるためです。err.(*MyError) のような型アサーションは、値がnilかどうかに関わらず「このinterfaceが元々どの型だったか」を知る必要があります。値がnilになった瞬間に型情報まで消えてしまうと、型アサーションが成立しなくなり、Goの型システムの一貫性が崩れてしまいます。つまり型付きnilは、interfaceに型情報と値を独立して持たせるという設計から必然的に生まれる挙動であり、バグではなく仕様です。
型付きnilとの付き合い方
型付きnilを完全になくすことはできませんが、影響を小さくする方法はいくつかあります。
interfaceを返す関数では具体型の変数を経由しない
先程の例で問題が起きたのは、関数の返り値の型が error interfaceであるにもかかわらず、途中で *MyError という具体型の変数に一度代入していたことが原因でした。関数の返り値の型がinterfaceであれば、途中経過も含めてinterface型で扱うようにすると型付きnilを防げます。
func doSomething() error {
// *MyErrorではなくerrorを返り値の型にする
return nil
}
errorを返す関数の返り値の型は具体的なポインタ型にしない
同様に、自分で定義する関数やメソッドが将来的にerror interfaceとして扱われることが分かっている場合は、最初から返り値の型を error にしておくのが安全です。「具体的なエラー型を返したいから」という理由で *MyError を返り値の型にすると、呼び出し元でこの問題を踏みやすくなります。
nilチェックが必要な場合はreflectを使う
コンストラクタなど、interfaceを受け取って中身がnilかどうかをどうしても判定したい場合は reflect パッケージを使う方法があります。
func isNil(i interface{}) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
return v.IsNil()
}
return false
}
ただし reflect はパフォーマンスコストがあり可読性も下がるため、あくまで最終手段です。基本的には前述のとおり、interfaceの返り値を具体型の変数に経由させない設計を徹底することで型付きnilを回避するべきです。
静的解析ツールで検出する
一部の性的解析ツールでは型付きnilになりうるコードパターンを検出できます。CIに組み込んでおくことで、レビューで見落としがちなこの手の問題を機械的に防止できます。
まとめ
- Go言語のinterfaceは型情報と値のペアで構成されており、具体型のnilポインタをinterfaceに代入すると型情報が残ったまま値だけがnilになる「型付きnil」が発生する
- 型付きnilのinterfaceは
nilとの比較でfalseになるため、if err != nilのようなチェックが意図通りに働かなくなる - 型付きnilは型アサーションを正しく機能させるための設計上必然の挙動であり、Go言語の仕様である
- 対策としては、interfaceを返す関数の内部で具体型の変数を経由させないことが最も効果的
- どうしても判定が必要な場合は
reflectを使う方法もあるが、コストが伴うため設計で回避するのが望ましい - 型付きnilによるトラブルを避けたければ静的解析ツールを利用するのが有効な手段の1つである
※本記事は、ジーアイクラウド株式会社の見解を述べたものであり、必要な調査・検討は行っているものの必ずしもその正確性や真実性を保証するものではありません。
※リンクを利用する際には、必ず出典がGIC dryaki-blogであることを明記してください。
リンクの利用によりトラブルが発生した場合、リンクを設置した方ご自身の責任で対応してください。
ジーアイクラウド株式会社はユーザーによるリンクの利用につき、如何なる責任を負うものではありません。