🦜

GoのTyped-nilの扱い

2022/10/17に公開
1

Go触り始めて誰もが躓くぎょっとする挙動のひとつ。

https://twitter.com/ryo_grid/status/1581829096476397573

初学者の期待と違う挙動の出るコード

playground

package main

import "log"

type S struct {
	value interface{}
}

func (s *S) Get() interface{} {
	return s.value
}

type Value interface {
	Get() interface{}
}

func Do() Value {
	var res *S
	if false {
		res = &S{123}
	}
	return res
}

func main() {
	v := Do()
	if v == nil {
		log.Fatal("v is empty!")
	}
	log.Println("v is not empty:", v)
}
2009/11/10 23:00:00 v is not empty: <nil> <-- ?!

空判定に引っかからず、有意な値を返されたという処理フローになっちゃうという問題。

Typed-nil問題について

これは「Typed-nil」問題といってGoの初期段階でもよく議論になりました。

  • ゼロ値を返しているのに空にならないのは直感に反する
  • コアメンバーの一人もこれは混乱するのでnil判定を可能にすべきという意見もあった
  • 最終的な回答は「理解して使ってくれ」という事です。

内部の仕組み

まずなんでこうなっちゃうのか。

interface型の内部構造は

https://github.com/golang/go/blob/a563954b799c6921fc3666b4723d38413f442145/src/runtime/runtime2.go#L143-L146

という感じになっており、tabは型情報を指し、dataは値を指しています。
つまり、「型」と「値」をセットで保持するというのがinterface型ということになります。

interface型にとっての「空」とは

内部構造に対するゼロ値つまり内部構造へのポインタがnilという値が格納されたinterface型だけが「空」であり、「型が有意で値が空のペア」はTyped-nilと呼ばれ、これが格納されたinterface型は「空」ではなく「有意な値」が格納されているということです。

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

package main

import "fmt"

type S struct{}

func (s *S) String() string {
	return "Hello!"
}

func main() {
	var s *S
	var typedNil interface{} = s
	fmt.Println(typedNil)
}

このようにTyped-nilもメソッドコール可能であり有意であると言えます。

go-staticcheckによる警告

Doの返す値に関して警告が出る仕掛けがあります(VSCode-Go)。

じゃあどうすれば?

前述のDo関数の書き方がGoには不適です。
Goらしく書くと以下のように書きます。

func Do() Value {
	if 条件 {
		return &S{123}
	}
	return nil
}

これにより最後のreturnではnilリテラルを返そうとしますが、Valueインターフェース型のnilを返すことになり、この場合インターフェース型内部構造そのものがnilになります。

悪手の例

Doが返すTyped-nilをなんとか元型におけるnilかどうかを判定しようとするのは悪手です。

元型に関する認識をここで持ち出すとせっかくインターフェース型に抽象化した意義が消滅します。元型がなんであるかは知らなくてもよく、インターフェースを満たすものがここで返されているという認識だけで扱えなければなりません。

reflect.ValueOf(返値).IsNil()などを使って判定しようとしましょう。

これは返値の元型がポインタであることを知っていなければ書けないリフレクション処理です。
これでは元型に値型を使った場合にpanicになってしまいます。

また、ptr, ok := 返値.(元型)としてやればptrがnilかどうか判定することは可能ですが、元型を知らなくても操作できるのがインターフェース型の良さなので台無しです。

もちろんこのあたりをジェネリクスや判定メソッドを生やしたりして何とかするのも悪手だと思います。

ここまでわかっていてもやっちゃうパターン

playground

func Convert(s ...*S) []interface{} {
  var res []interface{}
  for _, v := range s {
	  res = append(res, v)
  }
  return res
}

slice := []*S{&S{}, &S{}, &S{}, nil, &S{}}
converted := Convert(slice...)
...

ここでconvertedにはTyped-nilの値が含まれているので後続の処理で困ります。

こういう場合、Convert処理の中で空チェックを行い、空のものは「appendしない」または「interface型としてのnilにする」などの処理を入れましょう。

まとめ

  • Typed-nilはインターフェース型にとっては有意な値(not空)
  • コアメンバーはここが初心者が躓きがちなのは把握している
  • コアメンバーはそういうインターフェース型の振る舞いを理解して使ってくれというスタンス
  • VSCode-Goが警告してくれる
  • Typed-nilを保持するインターフェース値を作らない・返さないが基本
  • Typed-nil値をメモリに置いておく意義はないのでスライスにもマップにも入れないのが基本
  • インターフェース型を返すのは元型のことを知らなくてもよいという疎結合を実現するためのはずで返値の扱いに元型を知っている必要が生まれる形の実装は悪手
  • メソッドや関数がインターフェース型を返す時、内部処理はそれが空かどうか確認可能なはずでそれによりreturnを書き分けるのがGoらしい書き方
  • 上記さえ守ってあればif 返値==nil判定は期待通りに動く。

Discussion

NoboNoboNoboNobo

空かもしれないポインタ値を雑にインターフェース型に入れてはいけません(元型のnil値を入れてしまうとTyped-nilになっちゃう)。
つまり誰かが空チェックをしなきゃいけないものを後続処理に後回しにしちゃだめ。Goでは空かもしれない値は早めに空チェックしましょうということです。