Simple minds think alike

より多くの可能性を

【Golang】ポインタの値で把握するスライスの挙動 ( スライス式 / copy / append ) について

前回のスライスと配列の特徴、違いについての記事に引き続き、今回はスライスの様々な操作の挙動に関して整理していきます。

simple-minds-think-alike.hatenablog.com

新しいメモリ領域の確保は比較的重い処理なので、できるだけ行われないように工夫してプログラムを実装したいです。

スライスのどの操作で新しいメモリ領域の確保されるかを把握することで、回数を減らす工夫に繋がり、効率的なプログラムを実装できるようになるかと思います。特にスライスはGo言語でも使用頻度の高いデータ構造なので効果性が高いです。

まとめ

先にまとめです。

  • 参照型の代入では、新しいメモリ領域を確保しない
  • 簡易スライス式・完全スライス式では、新しいメモリ領域を確保しない。
  • copy は、新しいメモリ領域を確保する。
  • append は、スライスの長さが容量を超えるまでは、新しいメモリ領域を確保しない。

1つづつ詳細を見ていきたいと思います。

スライスは参照型

まずはおさらいですが、スライスは参照型なので、以下のように他の変数に代入されても要素のそれぞれの値はコピーされず、変数peopleが持っている参照のみがコピーされます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 別のスライスに代入
people2 := people

// 2つのスライスが指す配列のポインタは同じ値になる。
fmt.Println(&people[0])
=> 0xc00006c150

fmt.Println(&people2[0])
=> 0xc00006c150

図の補足ですが、以下のコードから分かるようにスライスのDataのポインタと配列の1番目の要素のポインタは同じ値になるので、このような表現にしています。

people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}

// 配列の1番目の要素のポインタの値を表示
fmt.Println(&people[0])
=> 0xc000098150

// スライスのDataのポインタの値を表示 
// 10進数の824634343760は、16進数で0xC000098150になるので同じ値だと分かります
sh := (*reflect.SliceHeader)(unsafe.Pointer(&people))
fmt.Println(sh)
=> &{824634343760 3 3}

16進数の方がポインタの値だとイメージしやすいと思うので、コード上では配列の1番目の要素のポインタの値を確認していきます。

スライス式

簡易スライス式

簡易スライス式を使うと、配列やスライスの一部を抜き出して新しいスライスを作ることができます。

slice := []string{"apple", "banana", "peace"}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
partical := slice[1:3]
fmt.Println(partial)
=> [banana peace]

簡易スライス式でできたスライスは、新しいメモリ領域を確保せず、既存の配列の参照を持ちます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
people2 := slice[1:3]

// 元のスライスの2番目の要素のポインタと
// 新しくできたスライスの1番目の要素のポインタは同じ値になる。
fmt.Println(&people[1])
=> 0xc00006c160

fmt.Println(&people2[0])
=> 0xc00006c160

なので、新しくできたスライスの要素の値を変更すると元のスライスの値も変わります。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字3-1(=2)までの要素で新しくスライスを作る
people2 := slice[1:3]
// 新しいスライスの2番目の要素を変更する
people2[1] = "竈門 禰豆子(かまど ねずこ)"

// 元のスライスの3番目の要素を表示。
fmt.Println(people[2])
=> 竈門 禰豆子(かまど ねずこ)

完全スライス式

簡易スライス式では新しくできるスライスの容量は自動的に決まります(元の配列の参照していない範囲になる)が、完全スライス式を使うと容量を変えられます。 簡易スライスと同様に、新しいメモリ領域を確保せずに新しいスライスができます。

//  スライスを作る
people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// 添字1〜添字2-1(=1)までの要素で最後の要素2-1(1)のスライスを新しく作る。
// 容量は1になる。
people2 := slice[1:2:2]
// 新しいスライスの1番目の要素を変更する
people2[0] = "竈門 禰豆子(かまど ねずこ)"

// 元のスライスの2番目の要素を表示。
fmt.Println(people[1])
=> 竈門 禰豆子(かまど ねずこ)

// 新しいスライスの長さと容量を表示
fmt.Println(len(people2))
=> 1
fmt.Println(cap(people2)) // 簡易スライス式の場合は2
=> 1

簡易スライス式の場合でも、完全スライス式の場合でも、新しくメモリ領域を確保することはないことが分かります。

copy / append

スライスをコピーする

スライスを別の変数に代入したり、簡易スライス式で新しいスライスを作り、要素の値を変更すると元のスライスの値が変わるため、要素を新しいメモリ領域にコピーして使いたいことがあります。

そのような場合、copyやappendを使って、新しい領域を作って値を入れます。内容が同じでも、それぞれ異なる領域に要素の値を持っていることが分かります。

people := []string{
  "竈門 炭治郎(かまど たんじろう)", 
  "我妻 善逸(あがつま ぜんいつ)", 
  "嘴平 伊之助(はしびら いのすけ)",
}
// スライスが指す配列の最初の要素のポインタを確認。
fmt.Println(&people[0])
=> 0xc00006c150

// makeで必要な分の領域を確保して、copyで値をコピーする
people2 := make([]string, len(people))
copy(people2, people)
fmt.Println(people2)
=> [竈門 炭治郎(かまど たんじろう) 我妻 善逸(あがつま ぜんいつ) 嘴平 伊之助(はしびら いのすけ)]

// 長さ、容量が3の新しいスライスができ、参照する配列は元のスライスとは別の領域。
fmt.Println(len(people2))
=> 3
fmt.Println(cap(people2))
=> 3
fmt.Println(&people2[0])
=> 0xc00006c180

// 空のスライスに要素をappendで入れる
people3 := append([]string(nil), people...)
fmt.Println(people3)
=> [竈門 炭治郎(かまど たんじろう) 我妻 善逸(あがつま ぜんいつ) 嘴平 伊之助(はしびら いのすけ)]

// 長さ、容量が3の新しいスライスができ、参照する配列は元のスライスとは別の領域。
fmt.Println(len(people3))
=> 3
fmt.Println(cap(people3))
=> 3
fmt.Println(&people3[0])
=> 0xc00006c1b0

既存のスライスよりも多くの要素を格納する場合、makeで十分な領域を確保して使うと、都度新しいメモリ領域の確保が発生せず、パフォーマンス的に良いです。

次にappendで新しい要素をスライスに追加した時にどのような場合に、新しい領域が確保されるか見ていきたいと思います。

appendの挙動

make でメモリ領域を確保して、append でスライスに要素を追加するコードをいくつか実行してみます。どのような場合に、新しいメモリ領域を確保し、どの程度の容量になるのかを把握しておくと良いです。

// 長さ3、容量4のスライスを作る
people := make([]string, 3, 4)
people[0] =  "竈門 炭治郎(かまど たんじろう)"
people[1] = "我妻 善逸(あがつま ぜんいつ)"
people[2] =  "竈門 炭治郎(かまど たんじろう)"

// この時点でのポインタの値を確認
fmt.Println(&people[0])
=> 0xc0000be040

// 要素を1つ追加
people = append(people, "竈門 禰豆子(かまど ねずこ)")

fmt.Println(people)
[竈門 炭治郎(かまど たんじろう) 
 我妻 善逸(あがつま ぜんいつ)
 竈門 炭治郎(かまど たんじろう)
 竈門 禰豆子(かまど ねずこ)]

// ポインタの値が変わっていないことを確認。
// 新しい領域は確保されず、既存の領域に値が入る。
fmt.Println(&people[0])
=> 0xc0000be040
// 長さ4、容量4になることを確認
fmt.Println(len(people))
=> 4
fmt.Println(cap(people))
=> 4

// 再度要素を1つ追加
people = append(people, "冨岡義勇(とみおかぎゆう)")
fmt.Println(people)
[竈門 炭治郎(かまど たんじろう)
 我妻 善逸(あがつま ぜんいつ)
 竈門 炭治郎(かまど たんじろう)
 竈門 禰豆子(かまど ねずこ)
 冨岡義勇(とみおかぎゆう)]

// 既存の2倍の容量で新しい領域が確保され
// そこに既存のデータが移されたうえで、要素が追加される
=> 0xc000108000
// 長さ5、容量8になる
fmt.Println(len(people))
=> 5
fmt.Println(cap(people))
=> 8

append の実装(2021/1/23時点)は下記の実装になっています。この処理の中で、新しい領域の確保が必要か決めていて、新しい領域を確保する必要があればgrowslice の実装の中で新しい領域が確保されます。

現在の実装では、容量が足りなくなると1 => 2 => 4 => 8 => 16 => …のように、倍々に新しい領域を確保していきます(容量が1,024までの場合)が、今後は挙動が変わるかもしれません。

スライス操作のテクニック

以下のSliceTricksという公式のwikiページに様々なスライス操作に関して記載されているので、一読しておくと、実業務で効率的なスライス操作を実装できるかと思います。

SliceTricks · golang/go Wiki · GitHub

参考資料