📌

【Go】スライスはちゃんと理解しておかないと危険だよという話

2024/06/22に公開

はじめに

どうもODです。
転職でWEBバックエンド開発を始めて約3ヶ月、これまで存在や概念だけは知っていたような技術のオンパレードでドタバタしております。
今回は業務でもよく登場するスライスのことについてお話ししようと思います。

スライスとは

まずGo言語には配列スライスの2つがあります。
どちらとも同じ型の複数の要素をまとめるためのものですが、

  • 配列(array):固定長
  • スライス(slice):可変長

と言う違いがあります(とんでもなくざっくりした説明ですが...)
私は処理の中で要素数が固定であれば配列を、変わりそうであればスライスを使う、といった具合に使い分けをしております。

スライスの定義

Goにおけるスライスの定義は下記の通りになっています。

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

https://go.dev/src/runtime/slice.go
つまり、スライスは内部的に配列(基底配列)を参照するもの、ということです。
図で書くと下記のようなイメージとなります。
image.png
<b>この概念をちゃんと理解することが後々重要になります...!!</b>

スライスの長さと容量

スライスの初期化を行う際にmakeをよく使うことになるのですが、引数に長さと容量を渡します。

s := make([]int, 2, 5)

image.png
つまり、容量(cap)として定義した数だけ箱を作り、長さ(len)で定義した数だけ初期値を入れる、みたいな動きになります。

  • 容量:確保している箱の数
  • 長さ:値が入っている箱の数

要素を増やしていった場合の長さと容量の変化

基本的にスライスはappendを使って要素を追加します。

s = append(s, 1)
fmt.Println(s) // [0, 0, 1]

下記図で示している通り、要素が割り当てられていない灰色の箱に1を保存する形になります。
image.png

追加し続けると容量を超えるタイミングが訪れることになりますが、その際スライスは<b>「元の配列の容量の2倍に当たる容量を持った配列を新たに確保して、元の配列が保持していたすべての要素をコピーする」</b>と言う動きを行います。(厳密には2倍にならないケースもありますが...)

s = append(s, 2, 3, 4)
fmt.Println(s) // [0, 0, 1, 2, 3, 4]

image.png
内部的にこういった動きを行うため、容量を増加させる回数が多くなると処理が遅くなってしまいます。
そのため、for文内でappendを行うような場合、最初から容量をある程度確保しておくことが大切です。

// 例:あるスライスに格納された値全てに+100したスライスを作成する
s1 := []int{1, 2, 3, ...}
s2 := make([]int, 0, len(s1)) // 容量をs1の長さ分確保する
for i range s1 {
    i += 100
    s2 = append(s2, 100)
}

スライスのコピーについて

ようやく本題に入れます。(長々説明してすみません。)
s1というスライスを作った後に、s2にコピーしたい場面が出てくると思います。この場合、主に直接代入する方法とcopy関数を使う方法の2つがあります。
この違いをしっかりと理解しておかなければ、思わぬ挙動を示してしまう可能性がありますので、違いを把握しておきましょう。

直接代入をする場合

まず直接代入のコードを書いてvalue, len, capを出力してみます。

s1 := make([]int, 3, 5)
s2 := s1

fmt.Printf("s1 value: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2 value: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

// 出力
// s1 value: [0 0 0], len: 3, cap: 5
// s2 value: [0 0 0], len: 3, cap: 5

では、s2の0番目の要素を1に変更してみます。

s2[0] = 1
fmt.Printf("s1 value: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2 value: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

// 出力
// s1 value: [1 0 0], len: 3, cap: 5
// s2 value: [1 0 0], len: 3, cap: 5

するとs2だけでなくs1の0番目の要素が1になってしまいました。
なぜこうなるかと言うと、s1とs2は同じ基底配列を参照している状態だから、です。
このようにs2 = s1で定義することを参照渡し、と呼びます。
image.png

次は、s2にappendで要素を追加してみましょう。

s2 = append(s2, 2)
s2 = append(s2, 3)
fmt.Printf("s1 value: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2 value: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

// 出力
// s1 value: [1 0 0], len: 3, cap: 5
// s2 value: [1 0 0 2 3], len: 5, cap: 5

先ほど0番目の要素を変えた時とは違いs1の出力が変わっていません。
なぜかというと、s1のlenが3のままなので、出力も3つしか出てこないんですね。(おそらくPrintの仕様)
ですが、参照している基底配列自体は同じであるためs2の0番目の要素を書き換えるとs1も変わります。

s2[0] = 6
fmt.Printf("s1 value: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2 value: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

// 出力結果
// s1 value: [6 0 0], len: 3, cap: 5
// s2 value: [6 0 0 2 3], len: 5, cap: 5

image.png

最後にもう一度appendを使ってs2に要素を渡した後に、再度0番目の要素を書き換えてみます。

s2 = append(s2, 4)
s2[0] = 9
fmt.Printf("s1 value: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2 value: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

// 出力結果
// s1 value: [6 0 0], len: 3, cap: 5
// s2 value: [9 0 0 2 3 4], len: 6, cap: 10

この場合、s2の0番目の要素は書き変わっているが、s1はそのままですね。
こちらの章で説明しましたが、capを超えて要素を追加するとき、新しく基底配列を作成しそこを参照することになります。
そのため、4を追加したことでs1, s2が別々の基底配列を参照する状態になりs2の要素を変更してもs1は変更されなくなった、という訳です。
image.png

copyを使う場合

copy関数を最初から新しい基底配列を作って渡すことになるので、直接代入のような動きはなくなります。

s3 := make([]int, 3, 5)
s4 := make([]int, 3)
copy(s4, s3)
s4 = append(s4, 1)
fmt.Printf("s3 value: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))
fmt.Printf("s4 value: %v, len: %d, cap: %d\n", s4, len(s4), cap(s4))

// 出力
// s3 value: [0 0 0], len: 3, cap: 5
// s4 value: [0 0 0 1], len: 4, cap: 6

まとめ

思っていた以上に記事が長くなってしまいましたが、スライスについての知見を少しでも深めるきっかけになれたら嬉しいです。
お付き合いいただきありがとうございました!

Discussion