Try Golang! Sliceってポインタなの?それともポインタじゃないの?

Is the Golang slice a pointer? Or not a pointer?

ある程度Goを書いているプログラマであれば、下記のようなコードに出くわしたことがあるのではないでしょうか。

そうそう、Mapはポインタだから、関数内で変更した内容は呼び出し元に反映されるけど、Sliceはポインタじゃないから反映されないんだよね。呼び出し元に反映させるためには、Sliceをポインタで渡してあげないといけないね。

と、今までは理解していたのですが、この理解は半分正解で半分間違いでした。今日は、この挙動の原因と、その調査過程で見つけたnilなSliceのおもしろい(?)特徴の2点について、整理したいと思います。

原因は「Sliceはポインタじゃないから」ではなかった

実は、追加ではなく、単純な値の変更であれば反映されます。

Sliceの実装を見てみると、Sliceの実体(array)はポインタ型となっています。つまり、Slice自体は構造体ですが、実際の配列はポインタで参照している、ということです。配列への参照が変わらなければ、関数内で値を変更すると、呼び出し元の値も変わります。

一方でappend関数は、新しい配列を用意し、その配列を参照するSliceを返します。しかし、呼び出し元のSliceが参照する配列は元の配列であるため、反映されないのです。ちなみにmakeを使ってCapacityを最初から確保したとしても、呼び出し元のSliceのLengthが変わらないので、結果は同じとなります。

単純に「Sliceはポインタじゃない」と言ってしまうと、追加ではなく変更の場合の挙動にパニックしかねないので、上記のような構造であることをちゃんと理解しておくべきかなと思います。(ちなみにFAQでは「Sliceは参照型だ」と記載されています)

結局のところ、要素の追加時も呼び出し元に反映させたい場合は、下記のように「Sliceをポインタで渡してあげる」ことになります。

Sliceにもnilがある!?

上述の通り、Slice自体は構造体なのですが、それでもnilになりえます。A Tour of GoのNil Slicesにもある通り、変数宣言のみで値が設定されていないSliceはnilのSliceになります(明示的な代入も可能)。nilのSliceはLengthもCapacityも0となります。[]int{}といった形で、明示的に作成する要素ゼロのSliceと同じですね。

他の言語でnullといえば、ポインタの指し示す値が存在しないことを表すことが多いと思いますが、Goの場合はあくまでも「ゼロ値」。ポインタだけでなく、SliceもMapもInterfaceも(FunctionもChannelも!)、それぞれゼロ値はnilとなります。(ただし、その意味は同じではありません!)

nilのSliceと要素ゼロのSliceの違い

nilのSliceも要素ゼロのSliceも、その挙動は基本的に同じです。ただ、下記のように、一部で異なる挙動となることがあるので注意しましょう。

まず1つ目は、nilの判定。要素ゼロのSliceはnilではないと判定されます。

2つ目は、フォーマット出力。Goの構文表現で出力する%#vを使用した場合の出力結果が異なります。なお、 デフォルトのフォーマットで出力する%vの場合は出力結果は同じです。

最後は、JSONのエンコードです。nilのSliceはnullとエンコードされるのに対し、要素ゼロのSliceは空の配列[]とエンコードされます。

なお、GoのWikiでは、空のSliceを宣言する場合、明確な理由がなければ、nilのSliceで宣言することが推奨されています。また、nilのSliceと要素ゼロのSliceで挙動が変わるような実装は、バグの元になるので極力避けるべきだとも記載されています。


上述のWikiにもリンクが記載されていますが、GopherCon2016のセッションで、Francesc Campoy氏がGoのnilについて包括的に説明してくれています。英語だけど、図がたくさんあるのでわかりやすい!今回はSliceに注目しましたが、Goのnilでしばしば混乱を招くのはInterfaceです。これも含め、Slice以外の型のnilについて詳しく知りたい場合は、ぜひ一度ご覧になることをおすすめします。