UdemyでGoの配列とスライスの扱いを学んだので初心者ながらに少しだけ深堀りしてみた

f:id:tanabebe:20191118123120p:plain

現在Udemyで現役シリコンバレーエンジニアが教えるGo入門 + 応用でビットコインシストレFintechアプリの開発を受講しています。講義の中でスライスのmakeとcapacityの内容があり、スライスと配列についての挙動が気になったのでまとめました。

はじめに

受講している内容

現役シリコンバレーエンジニアが教えるGo入門 + 応用でビットコインのシストレFintechアプリの開発

講師の酒井潤さんについて

twitter.com

シリエン戦隊JUN TVでYouTuberとして活動もされています。シリコンバレーのエンジニア事情などとても面白いです。 www.youtube.com

Splunkで働いている現役シリコンバレーエンジニアの方の講義を手軽に受けられるのは魅力的です。講義の内容や話のテンポも無駄な事を削ぎ落としていると感じたので、私にはとても心地良く感じました。シリコンバレーの風景なども動画におさめられているのはとても面白いと思いました。私はもはやファンになってしまっているので自身の学習内容にマッチしているのであれば、私は酒井潤さん推しです。気になる方は是非チェックしてみて下さい。

実践していく

講義から学んだGoの配列とスライスについては14〜16のレクチャーが該当となり、動画で言うと10分ほどです。プログラムは以下に公開しています。講義内容には影響ないように考慮しています。
リポジトリ内のreflectのケースは当記事では未記載

github.com

配列について

宣言方法

以下のような形で宣言する事が可能です。

var array1 [2]int
array2 := [3]int{1, 2, 3}
array3 := [...]int{1, 2, 3, 4}

配列の挙動を試す

以下のようにして、配列内の長さ、容量、値、アドレスを見てみます。

var array1 [2]int
fmt.Printf("array1 => length=%d capacity=%d value=%v address=%p \n", len(array1), cap(array1), array1, &array1)

array2 := [4]int{}
fmt.Printf("array2 => length=%d capacity=%d value=%v address=%p \n", len(array2), cap(array2), array2, &array2)

array3 := [4]int{1, 2, 3}
fmt.Printf("array3 => length=%d capacity=%d value=%v address=%p \n", len(array3), cap(array3), array3, &array3)
array3[3] = 4
fmt.Printf("array3 => length=%d capacity=%d value=%v address=%p \n", len(array3), cap(array3), array3, &array3)
実行結果

実行すると以下となります。

array1 => length=2 capacity=2 value=[0 0] address=0xc000016050 
array2 => length=4 capacity=4 value=[0 0 0 0] address=0xc00008e000 
array3 => length=4 capacity=4 value=[1 2 3 0] address=0xc0000180e0 
array3 => length=4 capacity=4 value=[1 2 3 4] address=0xc0000180e0

配列は要素数を宣言時に必ず決めるため、各変数での挙動が上記結果となります。アドレスを出力しているのはメモリ上に確保されるアドレスに変化が有るかどうかを見たかったためです。

配列の注意点

配列には要素を追加することは出来ないため、以下のようにするとエラーとなります。

// 配列にappendすることは出来ない
array3 = append(array3, 4)

配列の値の取り出し方

パターン1
// indexは不要なので値のみを出力する
for _, v := range array3 {
    fmt.Println(v)
}
パターン2
for i := 0; i < len(array3); i++ {
    fmt.Println(array3[i])
}

配列まとめ

  • 配列の要素数は固定長
  • 宣言時に要素数を決めるため、宣言の要素追加は不可能
  • メモリ上に容量を無駄に確保される事がない
  • appendは出来ないため、書き換える場合は要素位置を指定する

スライスについて

ここが今回のメインです。講義内でもスライスの宣言方法によってのメモリ上へどのように確保されるのかという点は触れているのですが、この時点では「まだ、気にすることはない」という事から最初はスルーしていました。深く考えると進まないのも事実です。ですが良く使いそうだなと感じたので、気になって仕方ない…という事で1度立ち止まって色々と試してみました。

宣言方法

スライスは以下のような形で宣言する事が可能です。

var slice1 []int
slice2 := []int{1, 2, 3, 4}
slice3 := make([]int, 10)

まずはスライスの挙動を試す

以下のようにして、スライスの長さ, 容量, 値, アドレスを見てみます。

var slice1 []int
fmt.Printf("slice1 => length=%d capacity=%d value=%v address=%p \n", len(slice1), cap(slice1), slice1, slice1)
slice2 := []int{1, 2, 3, 4}
fmt.Printf("slice2 => length=%d capacity=%d value=%v address=%p \n", len(slice2), cap(slice2), slice2, slice2)
slice3 := make([]int, 10)
fmt.Printf("slice3 => length=%d capacity=%d value=%v address=%p \n", len(slice3), cap(slice3), slice3, slice3)
実行結果

配列の時とは違い、宣言と同時に値を入れていない場合はnil扱いとなります。makeはここでは容量を10としていますが0でもnil扱いにはなりません。
また、var slice1 []intでは宣言したスライスの実体がメモリ上に確保されていますが(もしかしたら間違っているかもしれないです)どのアドレスも見ていないため0x0になります。

slice1 => length=0 capacity=0 value=[] address=0x0 
slice2 => length=4 capacity=4 value=[1 2 3 4] address=0xc000096080 
slice3 => length=10 capacity=10 value=[0 0 0 0 0 0 0 0 0 0] address=0xc00009e000

makeなしのスライスを試す

makeをせず、容量を確保しない場合のスライスを宣言します。

var slice4 []int
fmt.Printf("slice4 => length=%d capacity=%d value=%v address=%p \n", len(slice4), cap(slice4), slice4, slice4)
// appendするとどうなるか
slice4 = append(slice4, 2)
fmt.Printf("slice4 => length=%d capacity=%d value=%v address=%p \n", len(slice4), cap(slice4), slice4, slice4)
実行結果

宣言直後のslice4では容量も確保されていないため、address0x0となり、appendで値を追加すると容量が確保されています。次はmakeで宣言した場合のスライスを試していきます。

slice4 => length=0 capacity=0 value=[] address=0x0 
slice4 => length=1 capacity=1 value=[2] address=0xc0000160f8 

makeありのスライスを試す

ここではmakeした場合のスライスの挙動を見ていきます。確認したいポイントをPrintfで出力していきます。やっている事としては単純です。

// makeを使う。スライスの長さは容量との関係性が見たいので0とする
slice5 := make([]int, 0, 1)
fmt.Printf("slice5 => length=%d capacity=%d value=%v address=%p \n", len(slice5), cap(slice5), slice5, slice5)
slice5 = append(slice5, 1)
// この時点ではcapacityを超えてこないのでアドレスは変わらない
fmt.Printf("slice5 => length=%d capacity=%d value=%v address=%p \n", len(slice5), cap(slice5), slice5, slice5)
slice5 = append(slice5, 2)
// capacityを超えるのでアドレスが変わる
fmt.Printf("slice5 => length=%d capacity=%d value=%v address=%p \n", len(slice5), cap(slice5), slice5, slice5)
slice5 = append(slice5, 3)
// capacityを超えるのでまたアドレスが変わる、capacityは以前確保していたcapacity^2で増加していく
fmt.Printf("slice5 => length=%d capacity=%d value=%v address=%p \n", len(slice5), cap(slice5), slice5, slice5)
実行結果

面白い結果となりました。2度目のslice5出力までは宣言時のスライス容量を超えないためaddressは同じです。 しかし、確保した領域を超えるとaddressに変化があり、スライスの容量も自動で増えています。また、最後の出力時には拡張される前の容量^2で増えています。ここではaddressが変わっているのでslice5の容量が拡張される度に、メモリ上に領域が確保されていきます。これは扱いを知らないと意識せずに書いてしまいそうです。

slice5 => length=0 capacity=1 value=[] address=0xc00008c160 
slice5 => length=1 capacity=1 value=[1] address=0xc00008c160 
slice5 => length=2 capacity=2 value=[1 2] address=0xc00008c190 
slice5 => length=3 capacity=4 value=[1 2 3] address=0xc0000940e0 

スライスの宣言パターンによるベンチマークを取る

スライスの宣言時に明示的に容量を確保しない場合、容量が拡張される度にメモリ上に新たな領域が確保され、無駄にメモリを喰い潰して行くという挙動だと思います。大量にループを回した場合、処理速度に大きく影響がありそうだと感じました。
Goの標準パッケージtestingを使用してベンチマークを取り、宣言パターンによって処理速度にどれほどの差があるか見ていきます。
_test.goと終わるファイルを作成することでtest対象と出来ます。また、ベンチマークを取りたいのでBenchmarkから始まるテスト関数を作成してgo test -bench . -benchmemで実行します。

// slice_test.go
package main

import "testing"

// sliceの容量を指定しない場合
func BenchmarkInitSliceVariable(b *testing.B) {
    var target []int
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        target = append(target, i)
    }
}

// sliceでmakeで容量を確保しているがlength指定している場合(値が0で初期化されている場合)
func BenchmarkSliceCapacityNo(b *testing.B) {
    var target = make([]int, b.N)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        target = append(target, i)
    }
}

// sliceのmake時にc容量を設定する場合
func BenchmarkSliceCapacityYes(b *testing.B) {
    var target = make([]int, 0, b.N)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        target = append(target, i)
    }
}
実行結果

以下結果の通り、スライスの宣言方式で処理速度が大きく変わりました。

BenchmarkInitSliceVariable-4    100000000               22.3 ns/op            49 B/op          0 allocs/op
BenchmarkSliceCapacityNo-4      200000000               93.6 ns/op            57 B/op          0 allocs/op
BenchmarkSliceCapacityYes-4     2000000000               6.80 ns/op            0 B/op          0 allocs/op

実行結果についての見方は以下参考にさせていただきました。

qiita.com

// 関数の実行回数、有用な結果が得られるまで実行される
// 多ければ多いほど良い
2000000

// 1回の実行にかかった時間
// 少ないほど良い
815 ns/op

// 実行ごとに割り当てられたメモリのサイズ
// 少ないほど良い
336 B/op

// 1回の実行でメモリアロケーションが行われた回数
// 少ないほど良い
9 allocs/op

今回の結果に当てはめると以下ですね。

// 関数の実行回数、有用な結果が得られるまで実行される(多いほど良い)
100000000
200000000
2000000000

// 1回の実行にかかった時間(少ないほど良い)
22.3 ns/op
93.6 ns/op
6.80 ns/op

// 実行ごとに割り当てられたメモリのサイズ(少ないほど良い)
49 B/op
57 B/op
0 B/op

//1回の実行でメモリアロケーションが行われた回数(少ないほど良い)

0 allocs/op
0 allocs/op
0 allocs/op
スライスまとめ
  • 素数は可変長
  • スライスのmake時は要素数がわかっているなら宣言時にlenで確保
  • make([]int, 要素数)としてしまいそう注意が必要
  • 宣言時は長さが膨大だとスライスの中身は型による初期化が行われる のでlength0が良い

まとめ

講義の内容では約10分ほどの配列とスライスですが、とても考えさせられました。アロケーション0だったのが私の考えていた想定と違ったので、アロケーション回数が大きく増えてしまうようなバッドプラクティスなども今後学習を続ける中で深堀りしていきたいと思っています。

参考

testing - The Go Programming Language

Golangでベンチマークを取ってみた - Qiita

go - The Go Programming Language