ただの技術メモ

個人備忘録

スライスの内部実装と挙動の確認

スライスの挙動でなんでこうなるんだ?と度々なるのでまとめてみる。

配列

まず配列について。

配列は長さを含めて型。

An array’s size is fixed; its length is part of its type ([4]int and [5]int are distinct, incompatible types).

go.dev

長さ4の配列は以下のような感じで

var arr [4]int

メモリ内では、4つの整数値を順番に並べて表現される。

f:id:chann_r:20220302173842p:plain

Goの配列は値であり、配列を格納した変数は配列全体を表す。(C言語では配列の最初の要素へのポインタ)

Go’s arrays are values. An array variable denotes the entire array; it is not a pointer to the first array element (as would be the case in C).

スライスの基本

スライスの定義

スライスは配列へのポインタと、長さと容量を持った値。

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

github.com

スライスは内部配列のポインタを参照しているので、関数に渡すときは値渡しでもポインタ渡しでも書き換えが可能。

go.dev

初期化

以下のような形で初期化することが多い。

s1 := make([]int, 0, 5) // 内部配列のサイズが5で値が入っていない(ように見える)スライス(内部配列には[0 0 0 0 0]で初期化されているはず)
fmt.Println(s) // []

s2 := make([]int, 5) // 内部配列のサイズも値の入っているサイズも5のスライス
fmt.Println(s) // [0 0 0 0 0]

インデックス操作

コロン:で区切ることでslicingすることができる。

例えば、b[1:4]はスライスbのインデックス 1 から 3(紛らわしいけど4は含まない) までの要素を含むスライスを作成する 。

s := []int{0, 1, 2, 3, 4}
s1 := s[1:4]
fmt.Println(s1, len(s1), cap(s1)) // [1 2 3] 3 4

容量は切り取ったスライスの先頭から参照している内部配列の容量までを数える。

The capacity is the number of elements in the underlying array (beginning at the element referred to by the slice pointer).

再度slicingすれば容量まで大きくすることができる。

s := []int{0, 1, 2, 3, 4}
s1 := s[1:4]
fmt.Println(s1, len(s1), cap(s1)) // [1 2 3] 3 4 

s2 := s1[:cap(s1)]
fmt.Println(s2, len(s2), cap(s2)) // [1 2 3 4] 4 4

このようにslicingして値が一見見えなくても内部配列では値を保持しており、 GC されず値が残る場合がある。

かなりの要素数slicingするようであれば注意したい。

Since the slice references the original array, as long as the slice is kept around the garbage collector can’t release the array

要素の追加

append()はそのスライスから見た末尾へ値を追加(末尾+1の位置を書き換え)しスライスの長さを更新する。

内部配列の長さが足りない場合は、まず新しく長さが倍の配列を用意してそこに既存の内部配列の値をコピーし、末尾へ値を追加したのち、その新しい配列を内部配列として参照するようにスライスが持つ配列へのポインタを貼り直して、スライスの長さと容量を更新する。

arr := make([]int, 0, 0)
fmt.Println(arr, len(arr), cap(arr)) // [] 0 0

arr = append(arr, 0)
fmt.Println(arr, len(arr), cap(arr)) // [0] 1 1

arr = append(arr, 1)
fmt.Println(arr, len(arr), cap(arr)) // [0 1] 2 2

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

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

arr = append(arr, 4)
fmt.Println(arr, len(arr), cap(arr)) // [0 1 2 3 4] 5 8

コードを見た感じ、容量が256以上だと単純に2倍確保するわけでは無さそう。

https://cs.opensource.google/go/go/+/master:src/runtime/slice.go;l=196

このようにappend時に容量オーバーしてしまうと、新しいメモリ領域をOSに要求して確保し、既存のデータを新しい領域にコピーする必要がある。

なるべくこのコストを減らすために、事前にmake()で必要十分量のメモリを確保しておく工夫が必要。

ケーススタディ

シャローコピー時の挙動

シャローコピーした場合は参照している内部配列は同じなので、コピー先はコピー元の影響を受ける(逆も然り)

ただしappendの際に容量オーバーで内部配列が変わると影響は受けなくなる。

a := []int{1, 2, 3}
b := a         // シャローコピー(参照している内部配列は同じ)
fmt.Println(b) // [1 2 3]

a[0] = 111     // aとbは変数自体のメモリのアドレスは別だが、内部配列は同じなのでb[0]も111になる
fmt.Println(a) // [111 2 3]
fmt.Println(b) // [111 2 3]

a = append(a, 4) // 容量オーバーaの内部配列が変わるのでbには影響なし
fmt.Println(a)   // [111 2 3 4]
fmt.Println(b)   // [111 2 3]

go.dev

コピー元のスライスにappendした時のコピー先の挙動

appendでは対象のスライスの長さしか更新されないので、コピー元のスライスにappendした場合、コピー先が同じ配列を参照していてもコピー先のスライスの長さは更新されていないので、コピー先からは追加された要素が見えない。

a := make([]int, 0, 4)
a = append(a, 1, 2, 3)
fmt.Println(a) // [1 2 3]

b := a
a[0] = 111
fmt.Println(a) // [111 2 3]
fmt.Println(b) // [111 2 3]

a = append(a, 4) // 容量に余裕があるのでaとbは同じ内部配列を参照するが、aのappendではbのスライスの長さは更新されないのでbからは追加された4が見えない
fmt.Println(a) // [111 2 3 4]
fmt.Println(b) // [111 2 3]

fmt.Println(len(b), cap(b)) // 3 4

go.dev

appendによる内部配列の書き換え

appendはスライスから見た末尾に値を追加するので、同じ内部配列を参照しているスライスから見ると書き換えられて見えることがある。

a := make([]int, 0, 4)
a = append(a, 1, 2, 3)
fmt.Println(a) // [1 2 3]

b := a
a = append(a, 4) // 内部配列に4を追加してaのスライスの長さを更新
fmt.Println(a) // [1 2 3 4]
fmt.Println(b) // [1 2 3]

b = append(b, 5) // bのスライスから見た末尾に5を追加(内部配列の4要素目に5を入れる)するので、内部配列の4要素目の4が5に書き換えられる
fmt.Println(a)   // [1 2 3 5]
fmt.Println(b)   // [1 2 3 5]

go.dev

補足: シャローコピーとディープコピー

シャローコピー

単純に別の変数に代入するだけだとシャローコピーなので要素のアドレスは同じ。

シャローコピーは実体(データ)のコピーを行わないで、オブジェクトをコピーすること。

a := []int{1, 2, 3}
b := a // シャローコピー

fmt.Printf("a at %p, b at %p\n", &a, &b)             // 変数自体のアドレスは当然異なる
fmt.Printf("a[0] at %p, b[0] at %p\n", &a[0], &b[0]) // 内部配列は同じなので要素のアドレスは同じ

go.dev

ディープコピー

copy()を使ってディープコピーする。

a := []int{1, 2, 3}
b := make([]int, len(a))

copy(b, a) // aをbにコピー

fmt.Printf("a at %p, b at %p\n", &a, &b)             // 変数自体のアドレスは当然異なる
fmt.Printf("a[0] at %p, b[0] at %p\n", &a[0], &b[0]) // 内部配列が別なので要素のアドレスは異なる

go.dev

参考

go.dev

go.dev