Go のスライスの注意点と落とし穴

スライスは便利なデータ構造ですが、内部構造を理解していないと思わぬバグを生むことがあります。よくある落とし穴とその対策を紹介します。

nil スライスと空スライスの違い

宣言しただけのスライスは nil ですが、空のスライスリテラルで初期化すると nil ではなくなります。

var nilSlice []int
emptySlice := []int{}
makeSlice := make([]int, 0)

fmt.Println(nilSlice == nil)   // true
fmt.Println(emptySlice == nil) // false
fmt.Println(makeSlice == nil)  // false

どちらも長さは 0 ですが、JSON にエンコードすると違いが現れます。

nilSlice := []int(nil)
emptySlice := []int{}

jsonNil, _ := json.Marshal(nilSlice)
jsonEmpty, _ := json.Marshal(emptySlice)

fmt.Println(string(jsonNil))   // null
fmt.Println(string(jsonEmpty)) // []

API のレスポンスで null[] を区別する必要がある場合は、この違いに注意してください。

append の戻り値を無視しない

append は新しいスライスを返しますが、この戻り値を使わないと要素が追加されません。

numbers := []int{1, 2, 3}
append(numbers, 4) // 戻り値を無視している
fmt.Println(numbers) // [1 2 3](変わらない)

numbers = append(numbers, 4) // 正しい使い方
fmt.Println(numbers) // [1 2 3 4]

特に関数内でスライスに要素を追加して呼び出し元に反映させたい場合は、スライスを返すか、ポインタを渡す必要があります。

背後の配列の共有による意図しない変更

スライシングで作ったスライスは、元のスライスと背後の配列を共有します。

original := []int{1, 2, 3, 4, 5}
slice := original[1:4]

slice[0] = 999
fmt.Println(original) // [1 999 3 4 5]

独立したコピーが必要な場合は、copy を使うか、append でコピーします。

original := []int{1, 2, 3, 4, 5}
slice := append([]int{}, original[1:4]...)

slice[0] = 999
fmt.Println(original) // [1 2 3 4 5](影響なし)

メモリリークの可能性

大きなスライスの一部だけを使い続けると、使っていない部分もメモリに残り続けます。

func getFirstThree(data []int) []int {
    return data[:3] // 元の大きな配列を参照し続ける
}

この関数が 100 万要素のスライスを受け取った場合、返されるスライスは 3 要素しか参照しませんが、背後の 100 万要素の配列はガベージコレクションされません。

func getFirstThree(data []int) []int {
    result := make([]int, 3)
    copy(result, data[:3])
    return result // 新しい配列を参照、元の配列は解放される
}

大きなデータから小さな部分を切り出して長期間保持する場合は、明示的にコピーすることでメモリリークを防げます。

for range でのポインタの罠

ループ変数のアドレスを保存すると、すべて同じアドレスを指してしまいます。

numbers := []int{1, 2, 3}
pointers := []*int{}

for _, v := range numbers {
    pointers = append(pointers, &v)
}

for _, p := range pointers {
    fmt.Println(*p) // すべて 3
}

これはループ変数 v が毎回再利用されるためです。正しくは、インデックスで元のスライスを参照します。

numbers := []int{1, 2, 3}
pointers := []*int{}

for i := range numbers {
    pointers = append(pointers, &numbers[i])
}

for _, p := range pointers {
    fmt.Println(*p) // 1, 2, 3
}

並行処理での競合

スライスはスレッドセーフではありません。複数のゴルーチンから同時にアクセスすると、データ競合が発生する可能性があります。

// 危険なコード
var numbers []int

go func() {
    numbers = append(numbers, 1)
}()

go func() {
    numbers = append(numbers, 2)
}()

並行処理でスライスを扱う場合は、sync.Mutex でロックするか、チャネルを使ってアクセスを直列化する必要があります。