Go のスライスでハマッたところ
intro
先日 GoのSliceもヤバイ - Qiita こんな記事をみて、別の挙動だけどスライスの内部を理解しきれていなかった頃のことを思い出した。
結構前に謎に思っていた挙動についての話。 以前この挙動を解説しようと思って、前提として書いたスライスの内部構造の記事が、 Go のスライスの内部実装 だったのですが、そっちを書き終わって満足してしまい、本題を忘れていました。
この挙動は、先のブログで説明した内容がわかっていないと、なかなか理解できないかも。わかってしまえば簡単ですが。
やりたいのは、関数側でスライスを操作したときの呼び出し側での結果。 順を追ってみてみます。
配列を関数内で変更する
関数は値渡しで、配列はそれ自体が値なので、まるっとコピーされます。 以下の例は、戻り値で返さないと、呼出側は変化しません。
package main import ( "log" ) func init() { log.SetFlags(log.Lshortfile) } func increment(a [3]int) { // a は配列自体のコピー for i := range a { a[i] += 1 } log.Println(a) // [2 3 4] } func main() { a := [3]int{1, 2, 3} increment(a) log.Println(a) // [1 2 3] }
スライスを関数内で変更する
スライスは、配列(以下、内部配列という)への参照を持つ構造になっているので、スライスを値渡しして関数内で操作した場合、同じ内部配列を参照しているスライス(つまり同じスライス)にも、変更が反映される��
package main import ( "log" ) func init() { log.SetFlags(log.Lshortfile) } func increment(a []int) { // slice の値渡しなので、参照先の配列は同じ for i := range a { a[i] += 1 // ここは参照先配列の値を変える } log.Println(a) // [2 3 4] } func main() { a := []int{1, 2, 3} // slice increment(a) log.Println(a) // [2 3 4] }
関数内でスライスに append() (cap 無し)
しかし、 append() は、 cap のサイズが足らない場合は、内部配列を確保しなおすため、以下は戻り値としてスライスを返さないと、呼び出し側のスライスには変更が反映されない。
func add(a []int) { a = append(a, 4) // 新しい内部配列を作る log.Println(a) // [1 2 3 4] } func main() { a := []int{1, 2, 3} // この時点で cap は 3 add(a) log.Println(a) // [1 2 3] }
スライス自体のポインタを渡す方法を使うならこう。
func add(a *[]int) { *a = append(*a, 4) // 新しい内部配列を作る log.Println(*a) // [1 2 3 4] } func main() { a := []int{1, 2, 3} // この時点で cap は 3 add(&a) log.Println(a) // [1 2 3 4] }
スライスを関数内で append() (cap あり)
append() はもし cap に余裕があるなら、新しい内部配列は作りなおさず、そのまま末尾に追加する。内部配列を作り直さない場合、 append() の戻り値は同じスライスらしい。(なので、 append() の戻り値をとらなくてもよさそうだが、戻り値をとらないとコンパイルエラーになる)
func main() { a := []int{1, 2, 3} log.Printf("%p, %v", a, a) // 0x18244010, [1 2 3] a = append(a, 4) log.Printf("%p %v", a, a) // 0x18239240 [1 2 3 4] a = make([]int, 3, 4) a[0], a[1], a[2] = 1, 2, 3 // まだ cap に余裕がある log.Printf("%p, %v", a, a) // 0x18244080, [1 2 3] a = append(a, 4) log.Printf("%p %v", a, a) // 0x18244080 [1 2 3 4] }
ここまでが前提。
問題の挙動
append() を関数内で行う。 この場合、 cap に余裕があった場合は、内部配列の空き部分にそのまま追加されて、呼び出し側にも反映されるだろうという予想。
しかし、実際はそうならない。
package main import ( "log" ) func init() { log.SetFlags(log.Lshortfile) } func add(a []int) { a = append(a, 4) // 配列自体は更新されないはず log.Printf("%p, %v", a, a) // 0x18244010, [1 2 3 4] } func main() { a := make([]int, 3, 4) a[0], a[1], a[2] = 1, 2, 3 // まだ cap に余裕がある log.Printf("%p, %v", a, a) // 0x18244010, [1 2 3] add(a) log.Printf("%p, %v", a, a) // 0x18244010, [1 2 3] }
内部配列には余裕があり、そこに追加されているはず。 スライスのアドレスは変わっていないため、間違いなく同じものを表示している。 しかし、関数内では 4 つになっているのに、呼び出し側が更新されていないのはなぜか。またはどうしたら表示されるようになるか。
関数のシグネチャは一切変えず、最後の出力の手前に一行追加するだけで解決できます。
良かったら考えてみてください。
正解
a = a[:cap(a)]
実際にはスライスをポインタ渡しすれば、それでも解決します。
解説
Go のスライスは以下のような構造になっています。
http://golang.org/pkg/reflect/#SliceHeader
type SliceHeader struct { Data uintptr Len int Cap int }
Data が、内部配列への参照を持ち、それとは別に、 len() と cap() で取れる Len, Cap を持っています。
関数で渡されるのは、この構造体の値渡しで、 Data が参照だから同じ内部配列が見えている。
ここで、 len(), cap() の出力を見てみす。
func add(a []int) { a = append(a, 4) // 配列自体は更新されないはず log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3 4], 4, 4 } func main() { a := make([]int, 3, 4) a[0], a[1], a[2] = 1, 2, 3 // まだ cap に余裕がある log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3], 3, 4 add(a) log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3], 3, 4 }
このように、内部配列は変わっていないのに、 append() によって cap が更新されたにも関わらず、 Slice の情報は値渡しなので、それが呼び出し側に反映されていないのが原因。
で、これは以下のように更新できます。
a = a[:cap(a)]
a[low:high] とした場合の high は cap までという言語仕様になっており、実際は内部配列全体を渡せるということ。
内部配列は、 main() でも add() でも同じなので以下のように出力されます。
func add(a []int) { a = append(a, 4) // 内部配列は変わっていないはず log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3 4], 4, 4 } func main() { a := make([]int, 3, 4) a[0], a[1], a[2] = 1, 2, 3 // まだ cap に余裕がある log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3], 3, 4 add(a) log.Printf("%v, %v, %v", a, len(a), cap(a)) // [1 2 3], 3, 4 log.Println(a[:4]) // [1 2 3 4] }
ということで、スライスをポインタ渡しすれば、両側の Cap が更新されてそれでも済むのですが。
こうした実装方法がどうかという点はおいておき、内部配列を知っておく機会として、この挙動が理解できると、内部配列の re-allocation をとにかく削り、最適な cap() を意識してチューニングしたプログラムを組むような場合に、もしかしたら役に立つかもしれません。
outro
最初に言った GoのSliceもヤバイ - Qiita の記事、では 「あるものを正しく使うのにその実装まで理解していなければならないというのは好みではありません。」とした一方で、「Goのような低級言語では、利用者が実装まで知っていなければならないのもある程度仕方ないことかもしれませんが。」と締められていました。
Goはリテラルで生成して、append()を使ってるだけならあまり気にしないでいいように作っていて、内部をわかっているなら、make()で生成してcap()を意識するという使いかたもできるという住み分けになってるかなという理解。特に後者は低レベルのチューニングをやる人にとっては必要だと思うし。 でも、ある程度やっていたら内部をわかってないと正しく使えない(ハマる)っていうのは、どの言語でも同じなんじゃないかなとも思う。