Edited at

Go の Slice の落とし穴

Go の Slice には落とし穴があります。

これは、 Slice 特有の、参照データ型のようでありながら完全な参照とも言い切れない、少し変わった挙動に由来します。


Slice を関数に渡し、その関数内で変更した場合

Arrayと対比することで挙動がわかりやすくなるので、まず、Arrayについて見てみます。

次のようなコードを実行してみます。

package main

import "fmt"

func main() {
var a [5]int // array
for i := 0; i < 5; i++ {
a[i] = i
}

fmt.Printf("1. a: %v\n", a)

modifyArray(a)

fmt.Printf("3. a: %v\n", a)
}

func modifyArray(a [5]int) {
a[2] = 9
fmt.Printf("2. a: %v\n", a)
}

すると、次のように表示されます。

1. a: [0 1 2 3 4]

2. a: [0 1 9 3 4]
3. a: [0 1 2 3 4]

関数呼び出しの中で 9 に変更したArrayの a[2] のデータは、呼び出し元には反映されません。

一方、Sliceではどうなるでしょうか?

package main

import "fmt"

func main() {
var a []int // slice
for i := 0; i < 5; i++ {
a = append(a, i)
}

fmt.Printf("1. a: %v\n", a)

modifySlice(a)

fmt.Printf("3. a: %v\n", a)
}

func modifySlice(a []int) {
a[2] = 9
fmt.Printf("2. a: %v\n", a)
}

正解はこちら

1. a: [0 1 2 3 4]

2. a: [0 1 9 3 4]
3. a: [0 1 9 3 4]

Slice では、呼び出した先の関数が行った a[2] のデータへの変更が、呼び出し元のSliceに反映されています。

これらの挙動は、「Arrayは値型のデータ型で、Sliceは参照型のデータ型だから」と解説される場合があります。

Arrayのほうは modifyArray() にわたすときに全体を値として渡します。つまり、Array全体をコピーしているので、関数内で中身を変更しても、コピーを変更しているにすぎず、呼び出し元の Array には影響がありません。

一方、 modifySlice() には呼び出し元のSliceを参照として渡しているので、関数内で引数として受け取った Slice の要素を変更すれば、呼び出しもとの Slice も変更される、というわけです。

本当でしょうか?次の例を見てみましょう。


Slice を関数に渡し、その関数内で append() した場合

次のようなコードを実行すると、何が出力されるでしょうか?

appendSliceUnsafe() は少しとっつきにくいかもしれませんが、 appendSlice() と同様、 Slice に要素を追加しています。

package main

import (
"fmt"
"reflect"
"unsafe"
)

func main() {
var a []int
for i := 0; i < 5; i++ {
a = append(a, i)
}

fmt.Printf("1. a: %v\n", a)

appendSlice(a)
appendSliceUnsafe(a)

fmt.Printf("4. a: %v\n", a)
}

func appendSlice(a []int) {
a = append(a, 10)
fmt.Printf("2. a: %v\n", a)
}

func appendSliceUnsafe(a []int) {
h := (*reflect.SliceHeader)(unsafe.Pointer(&a))
h.Len++
a[len(a)-1] = 20
fmt.Printf("3. a: %v\n", a)
}

正解はこちら。

1. a: [0 1 2 3 4]

2. a: [0 1 2 3 4 10]
3. a: [0 1 2 3 4 20]
4. a: [0 1 2 3 4]

あれ?「Sliceは参照型のデータ型」だから、呼び出した関数内で変更すれば、呼び出し元も変更されるはずだったのでは?

この挙動は、 Slice というデータ型がどういう構造であるかを理解すれば、納得できます。


Slice の構造

Slice は、 Array を次のようなsturctでラップしているデータ型です。

type SliceHeader struct {

Data uintptr
Len int
Cap int
}
// (go doc reflect.SliceHeader より引用)


  • Data は Arrayの領域 へのポインタです

  • Len は現在の長さ(Array領域のうち、Sliceとして使用中の長さ)です。

  • Cap はキャパシティ(容量)で、未使用部分を含めたArray領域の容量(Dataの指し先からArray領域末尾までの長さ)です。

Slice を関数に渡す場合、参照を渡しているのではなく、このデータ構造を コピーして渡している のです。

データを格納してあるArray領域へのポインタは渡しているので、その要素を変更すると呼び出し元に反映される場合もありますが、Len や Cap はコピーを渡しているに過ぎないので、append()などでそれらの値を関数の呼び出し先で変更しても、呼び出し元には反映されません。

さらに、一度も append()しなければ参照を渡してるかのように扱えますが、1度でもappend()したら、元のサイズよりも後ろの位置にある要素に書き込まれた内容は呼び出し元から見えませんし、もしappend()時にCapが足りなくなって別の場所に確保&コピーされたりすれば、元のサイズ内の要素への書き込みであっても呼び出し元からは見えません。例えば次のようなコードを実行すると、

package main

import (
"fmt"
)

func main() {
var a []int
for i := 0; i < 5; i++ {
a = append(a, i)
}

fmt.Printf("1. a: %v\n", a)

append5(a)

fmt.Printf("3. a: %v\n", a)
}

func append5(a []int) {
for i := 0; i < 5; i++ {
a = append(a, i)
}
a[2] = 9
fmt.Printf("2. a: %v\n", a)
}

呼び出し元でも見えていた領域である a[2] に書き込んだはずの 9 が呼び出し元ではなかったことになっています。

1. a: [0 1 2 3 4]

2. a: [0 1 9 3 4 0 1 2 3 4]
3. a: [0 1 2 3 4]

これは、append5内で複数 append() していく過程で、もともとの Cap よりも多くの要素が必要になった結果、データ格納領域が別の場所への確保&コピーされているためです。


Slice を関数に渡す場合の注意

Slice を関数にわたす場合、結局何が良くて何が良くないのでしょうか?

次の場合はほぼ問題ありません。


  • リードオンリーの利用

  • Slice に書き込む場合、必要な長さをすべて確保した上で渡す

もし渡した先の関数で Slice の長さ(Len)や容量(Cap)を変える場合、ポインタで渡すべきかを考えましょう

package main

import "fmt"

func main() {
var a []int // slice
for i := 0; i < 5; i++ {
a = append(a, i)
}

fmt.Printf("1. a: %v\n", a)

appendSlicePointer(&a)

fmt.Printf("3. a: %v\n", a)
}

func appendSlicePointer(a *[]int) {
for i := 0; i < 5; i++ {
*a = append(*a, i)
}
fmt.Printf("2. a: %v\n", *a)
}

1. a: [0 1 2 3 4]

2. a: [0 1 2 3 4 0 1 2 3 4]
3. a: [0 1 2 3 4 0 1 2 3 4]


さらに微妙なケース

一度 Slice の構造について理解し、 関数呼び出しや append() が何をしているかを理解してしまえば、ハマることは少ないと思います。しかし、次のような微妙なケースについても注意が必要です。

package main

import (
"fmt"
)

func main() {
a := []int{0, 1, 2, 3, 4}

b := a
b = append(b, 5)

c := a
c = append(c, 6)

fmt.Printf("a: %v\n", a)
fmt.Printf("b: %v\n", b)
fmt.Printf("c: %v\n", c)
}

結果は次のように、特に不思議なところはありません。

a: [0 1 2 3 4]

b: [0 1 2 3 4 5]
c: [0 1 2 3 4 6]

では、次の場合はどうでしょうか。

package main

import (
"fmt"
)

func main() {
var a []int
for i := 0; i < 5; i++ {
a = append(a, i)
}

b := a
b = append(b, 5)
c := a
c = append(c, 6)

fmt.Printf("a: %v\n", a)
fmt.Printf("b: %v\n", b)
fmt.Printf("c: %v\n", c)
}

結果は次のようになります。

a: [0 1 2 3 4]

b: [0 1 2 3 4 6]
c: [0 1 2 3 4 6]

5 を追加したはずの b の末尾の要素が 6 になってしまっています。

前者のコードでは、もともと a と同じ場所を指していた b,c に append() するとき、その Cap をオーバーします。 すると b, c はそれぞれ別の場所に a の領域をコピーした上で要素を追加することになるので、 b と c の Data は別の場所を指しています。

一方、後者のコードでは、a を構成する過程で a 自体の Cap が十分大きくなっており、 b, c の append() で領域のコピーが発生せず、もともとの a の領域がそのまま使われてしまいます。 つまり、b と c の Data は同じ領域を指しています。結果として、 c への append() で b[5] は上書きされてしまいました。

既存のSliceの末尾に要素を追加した別のSliceが必要なケースでは、ちゃんと別のSliceを make() 等で確保し、 copy() したうえで append() すると間違いを防げます。


補足(追記)

f(a[:])b := a[:] のように [:] 表記で書けばいいのでは? と勘違いしてしまうことがありますが、 Go における Slice では a[:] という表記で Data が指す領域のコピーは発生しません(Pythonならコピーされそうですが)。

b := a[:len(a):len(a)] という書式を使うと、 Cap が len(a) に制限されるので、上記のような場合でも c への append で b が上書きされることはなくなります。ただし、その場合もb や c に append() するまでコピーはされず、 a の領域を b, c が共有している点には注意が必要です。複製が必要なら make() & copy() しましょう。


まとめ


  • Slice を純粋な参照と考えるとハマる

  • 参照のように使う場合は、Slice がどういうものかをよく理解したうえで使う


    • 関数にはリードオンリー用途で渡す

    • あるいは、長さ・容量を確保したうえで渡す

    • 複製が必要な場合、新しく make() & copy() する