のんびり精進

調べた情報などをおすそ分けできれば。

Goのスライスの性質を再確認

下記のようなコードが期待通りの結果にならないというのを先日 Twitter で見ました。 そのツイートのままではありませんが似たコードです。

問題のコード

func main() {
    s := []string{"0", "1", "2", "3", "4"}
    fmt.Println(s)                 // [0 1 2 3 4]

    s2 := append(s[:2], s[3:]...)  // 二番目の要素を削除
    fmt.Println(s2)                // [0 1 3 4]
    fmt.Println(s)                 // 元のスライス [0 1 3 4 4]
}

先頭を「0番目」と呼ぶとすると、s2 の2番目の要素が削除されるのを期待するわけですが、削除されるだけでなく元のスライスに影響してしまっています。

SliceTricks に書かれている方法

スライスの一部の要素を削除する方法は、公式 WikiSlickTricks では次のようになっています。

a = append(a[:i], a[i+1:]...)
// or
a = a[:i+copy(a[i:], a[i+1:])]

先ほどのコードではこの一つ目のほうと同じ方法が使われています(それなのにダメ)。 二つ目の方法ではどうでしょうか。

s2 := s[:2+copy(s[2:], s[3:])]
fmt.Println(s2)                  // [0 1 3 4]
fmt.Println(s)                   // 元のスライス [0 1 3 4 4]

同じように元のスライスに影響してしまいました。

上記の方法にはメモリリークの恐れがあり、次のようにすれば防げるとも書かれています。

copy(a[i:], a[i+1:])
a[len(a)-1] = nil // or the zero value of T
a = a[:len(a)-1]

でも元のスライスを削除結果で上書きするときにしか使えないため、今回の問題の解決にはなりません。 *1

対策

結論を言ってしまえば、記述を二文字増やすだけです。

//s2 := append(s[:2], s[3:]...)
// ↓ 変更
s2 := append(s[:2:2], s[3:]...)

少し前に書いた記事と共通したスライスの性質に関することなので、この方法に気づけました。

kabochapo.hateblo.jp

でも怖いですよね。 そういうものだとわかっていても、自分が書くときに毎回確実に気づける自信はありません。 気づけなければ潜在的な不具合となってしまいます。

そこで、スライスを扱うときには容量までを意識する ことを脳に刻み込むためにこの記事を書きました。

原因を見る前にスライスの性質を確認

情報を出力する関数は前回と同じものを使います。

func showSliceInfo(s []string) {
    fmt.Printf("len:%d cap:%d %p %v\n", len(s), cap(s), s, s)
}

値の変化だけでなく長さ・容量・アドレスも見ます。

s := []string{"0", "1", "2", "3", "4"}
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 2 3 4]

// 2番目の要素までをスライス
// 容量は5のまま、かつ同じ配列を参照したまま
s2 := s[:2]
showSliceInfo(s2)  // len:2 cap:5 0xc00005c050 [0 1]

// 長さを5に戻すと元のスライスと同じになる
// 参照先配列も同じまま
s3 := s2[:5]
showSliceInfo(s3)  // len:5 cap:5 0xc00005c050 [0 1 2 3 4]

// 3番目以降の要素だけをスライス
// もともと4番目までしかないので長さも容量も2になる
// 参照先は元の配列の3番目~
s4 := s[3:]
showSliceInfo(s4)  // len:2 cap:2 0xc00005c080 [3 4]

// s2にs4をappend
// 長さ2+2で4になるが容量はs2と同じ5のまま
s5 := append(s2, s4...)
showSliceInfo(s5)  // len:4 cap:5 0xc00005c050 [0 1 3 4]

// s2はsと同じ配列を参照しているのでs2の変更がsにも反映されている
// sは長さも容量も5であり4番目の要素は元のまま
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 3 4 4]

// appendした結果を入れたs5も長さを5にすれば4番目の要素が出てくる
s6 := s5[:5]
showSliceInfo(s6)  // len:5 cap:5 0xc00005c050 [0 1 3 4 4]

// 元のスライスを容量も指定してスライス
// 参照先の配列は同じまま
s7 := s[:2:2]
showSliceInfo(s7)  // len:2 cap:2 0xc00005c050 [0 1]

// 容量を2にしたものを5に戻すことはできないので
// 3~4番目の要素が出てきてしまうことはない
//s8 := s7[:2:5]

// 長さと容量が2のスライスに一つappend
// 長さ・容量・アドレスが変わる(ここがポイント)
s9 := append(s7, "5")
showSliceInfo(s9) // len:3 cap:4 0xc000016080 [0 1 5]

原因の確認

同じ感じで見てみます。

対策前

s := []string{"0", "1", "2", "3", "4"}
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 2 3 4]

s2 := s[:2]
showSliceInfo(s2)  // len:2 cap:5 0xc00005c050 [0 1]

s3 := s[3:]
showSliceInfo(s3)  // len:2 cap:2 0xc00005c080 [3 4]

s4 := append(s2, s3...)
showSliceInfo(s4)  // len:4 cap:5 0xc00005c050 [0 1 3 4]
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 3 4 4]

append するスライス(s3)もされるスライス(s2)も、append した結果を入れたスライス(s4)も、参照する配列は元のスライス(s)の参照先と同じです(s3 は3番目以降の要素を切り出しているのでその分ずれた位置です)。

同じ配列が書き換わるので s に影響が出るのは当然ですね。

対策後

s := []string{"0", "1", "2", "3", "4"}
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 2 3 4]

s2 := s[:2:2]
showSliceInfo(s2)  // len:2 cap:5 0xc00005c050 [0 1]

s3 := s[3:]
showSliceInfo(s3)  // len:2 cap:2 0xc00005c080 [3 4]

s4 := append(s2, s3...)
showSliceInfo(s4)  // len:4 cap:5 0xc000016080 [0 1 3 4]
showSliceInfo(s)   // len:5 cap:5 0xc00005c050 [0 1 2 3 4]

append するスライス(s3)もされるスライス(s2)も、参照する配列は先ほどと同様に元のスライス(s)の参照先と同じです(s3 の位置のずれについては同上)。

ところが、append した結果を入れたスライス(s4)は異なるアドレスになっています。 これは先ほどスライスの性質を見たときと同じ挙動です。

そうなるのは、s2 の容量が 2 であり、s3 を append するだけの空きがないためです。 append 時に容量が足りなければ自動的に拡張されますが、その際に新たな場所に配列が用意されるのです。

s4 と s の裏にある配列は異なるものとなり、s に影響することがないというわけです。

*1:そもそも int 型のゼロ値である 0 で末尾を埋めて長さを一つ減らしても容量は元の 5 のままです。本当にメモリリーク対策になるのでしょうか…。