はじめに
こちらは、RetailAI Adventurers Advent Calendar 2023の15日目の記事です。
昨日はtanabe_shogoさんの『GCPでDuet AI使ってみた』でした。
この記事では、Goでよく使われるSliceの仕組みについてご紹介しようと思います。ここで書く内容は、『100 Go Mistakes and How to Avoid Them』の『#20: Not understanding slice length and capacity』の章を読んで個人的に噛み砕いたものになります。
筆者の裏の意図としては、Sliceに関する本書での内容を復習して自分なりの理解をアウトプットするためのものです。そのため、自分の理解が足りない点などありましてもどうかご容赦ください。できたらコメント等で教えていただけると大変有り難いです
(本書の日本語の翻訳書『Go言語 100Tips - ありがちなミスを把握し、実装を最適化する』も今年出版されています!著者さんには他のGoの翻訳書でも大変お世話になっております。ここでお礼を)
Sliceとは?
Sliceとは、複数の要素を持つことができるデータ構造ですが、長さが固定されたArrayとは異なり、長さを動的に変更できます。
しかしながら、Sliceに格納するデータは、実際には裏のArray(backing array: 基底配列)に格納されており、Slice自体が持っているデータは以下の3つです。
- backing arrayへのポインタ
- 長さ(length):そのSliceが(プログラマーに対して明示的に)含んでいる要素の数
- 容量(capacity):そのSliceを裏で支えるbacking arrayの要素の数
以下は、簡単なコードとイメージ図をもとに、さらに詳細へと進めていきます。
初期化
以下のコードは、長さは3、容量は6のSliceを初期化します。
// sample 1
s := make([]int, 3, 6)
図にすると以下のような感じです。
長さで指定した要素には宣言した型(type)のゼロ値(zero value)で初期化されます。ここではint
型なので、0
が入ります。
sample 1のイメージ図
slice (s)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 3 | 6 |
+-----+-----+-----+
|
| backing array
| +-----+-----+-----+-----+-----+-----+
-> | 0 | 0 | 0 | | | |
+-----+-----+-----+-----+-----+-----+
要素への代入
以下のように、要素に値を代入して上書くことができます。
// sample 2
s[1] = 1
sample 2のイメージ図
slice (s)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 3 | 6 |
+-----+-----+-----+
|
| backing array
| +-----+-----+-----+-----+-----+-----+
-> | 0 | 1 | 0 | | | |
+-----+-----+-----+-----+-----+-----+
一方で、容量は6ですが、長さは3で初期化されているため、
長さの範囲外の要素へアクセスして代入しようとすると、以下のようにpanicが発生します。
s[3] = 0
panic: runtime error: index out of range [3] with length 3
では、せっかくメモリ(容量)は6まで確保されているのに、どうやったら使えるのでしょうか?
その方法の一つが、組み込み関数のappnedです。
appendによる追加
以下のように、append
によって要素を追加することができます。
// sample 3
s = append(s, 2)
sample 3のイメージ図
slice (s)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 3 | 6 |
+-----+-----+-----+
|
| backing array
| +-----+-----+-----+-----+-----+-----+
-> | 0 | 1 | 0 | 2 | | |
+-----+-----+-----+-----+-----+-----+
appendによる追加; 容量を超える場合
さらに、容量である6
を超える数の要素を追加します。
// sample 4
s = append(s, 3, 4, 5)
すると、内部的に、Goは、2倍の容量である12
を確保した別のarrayを生成し、そこに元のarrayから値をすべてコピーし、新しいarrayを参照するポインタに変更し、最後に新しい要素の値5
を代入します。
sample 4のイメージ図
slice (s)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 3 | 12 |
+-----+-----+-----+
|
| original array
| +-----+-----+-----+-----+-----+-----+
| | 0 | 1 | 0 | 2 | 3 | 4 |
| +-----+-----+-----+-----+-----+-----+
| |
switch copy
| |
| V
| new backing array | 2x added ->
| +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
- >| 0 | 1 | 0 | 2 | 3 | 4 | 5 | | | | | |
+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
もう参照されなくなった元のarrayは、ヒープ(heap)上に割り当てられている場合、ガーベージコレクション(GC)により最終的に開放されます。
このように、実は、Goでは自動的に、Sliceの要素数が1024
になるまで、容量が2倍
ずつ増え、それ以降は25%
ずつ増えていきます。
(要素数が1024
以降は25%
ずつ増加という記述は、翻訳書やgo projectのcommitから見つけました)
つまり、この処理は、容量が確保されていない限り、appendが呼ばれる度に行われてしまいます。
そのため、予めsliceに格納するデータの最大数が分かっている場合は、以下のように、容量を指定してsliceを初期化することが推奨されています。
s := make([]int, 0, 7)
(本書では、『#21: Inefficient slice initialization』の中で、詳しく説明されています)
Slice化(slicing)
slicingとは、あるslice(またはarray)の要素を部分的に操作する方法です。
以下のように、1つ目のインデックスの要素は含まれ、2つ目のインデックスの要素は含まれない部分集合的なsliceを作ることができます。
// sample 5
s1 := make([]int, 3, 6)
s2 := s1[1:3]
slicingしたs2
の長さは指定した範囲に含まれる要素の数=2
になりますが、容量は5
となります。
つまり、あくまで同じbacking arrayを参照していることが分かります。
sample 5のイメージ図
slice (s1) slice (s2)
+-----+-----+-----+ +-----+-----+-----+
| ptr | len | cap | | ptr | len | cap |
+-----+-----+-----+ +-----+-----+-----+
| * | 3 | 6 | | * | 2 | 5 |
+-----+-----+-----+ +-----+-----+-----+
| |
| |
+------+ +-----------+
| |
V V
+-----+-----+-----+-----+-----+-----+
| 0 | 0 | 0 | | | |
+-----+-----+-----+-----+-----+-----+
backing array
そのため、s1
とs2
でインデックスは異なりますが、両方のSliceに含まれている要素の値を変更すると、どちらも変更されているのが分かります。
// sample 6
s1[1] = 100
fmt.Printf("s1: %v\n", s1)
fmt.Printf("s2: %v\n", s2)
s1: [0 100 0]
s2: [100 0] # s2[0]の値も変更されている
sample 6のイメージ図
slice (s1) slice (s2)
+-----+-----+-----+ +-----+-----+-----+
| ptr | len | cap | | ptr | len | cap |
+-----+-----+-----+ +-----+-----+-----+
| * | 3 | 6 | | * | 2 | 5 |
+-----+-----+-----+ +-----+-----+-----+
| |
+------+ +-----------+
| |
V V
+-----+-----+-----+-----+-----+-----+
| 0 | 100 | 0 | | | |
+-----+-----+-----+-----+-----+-----+
^ backing array ^
| |
+--s1 & s2 lens --+
ここで、s2
に新しい要素2
を追加します。
// sample 7
s2 = append(s2, 2)
fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
s1
の長さは3
のままであるため、s1
はbacking arrayの1番目から3番目までしか参照しません。
そのため、今回backing arrayの4番目に追加された2
は出力されません。
s1: [0 100 0], len: 3, cap: 6 # s1への追加の影響は無い
s2: [100 0 2], len: 3, cap: 5
sample 7のイメージ図
slice (s1) slice (s2)
+-----+-----+-----+ +-----+-----+-----+
| ptr | len | cap | | ptr | len | cap |
+-----+-----+-----+ +-----+-----+-----+
| * | 3 | 6 | | * | 3 | 5 |
+-----+-----+-----+ +-----+-----+-----+
| |
+------+ +-----------+
| |
V V
+-----+-----+-----+-----+-----+-----+
| 0 | 100 | 0 | 2 | | |
+-----+-----+-----+-----+-----+-----+
^ ^ ^ ^
| | | |
+-----|--s1's len-+ |
| |
+--------s2's len-+
さらに、s2
に対してbacking arrayの容量(5
)を超えるまで要素を追加します。
// sample 8
s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5)
すると、前回と同様で、容量が2倍の新しいarrayを生成し、それに対して今までの値をコピーし、ポインタを切り替え、最後の新しい要素を追加します。
s1: [0 100 0], len: 3, cap: 6
s2: [100 0 2 3 4 5], len: 6, cap: 10
sample 8のイメージ図
slice (s1)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 3 | 6 |
+-----+-----+-----+
|
+------+
|
V original backing array for s1
+-----+-----+-----+-----+-----+-----+
| 0 | 100 | 0 | 2 | 3 | 4 |
+-----+-----+-----+-----+-----+-----+
^ ^ |
| | +-copy+
+----s1's len-----+ |
|
new backing array for s2 V 2x added ->
+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| 100 | 0 | 2 | 3 | 4 | 5 | | | | |
+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
^
swtich |
+-------+
|
slice (s2)
+-----+-----+-----+
| ptr | len | cap |
+-----+-----+-----+
| * | 6 | 10 |
+-----+-----+-----+
以上、sliceの長さと容量についての説明となります。
sliceを操作する上で2つの値がどういう関係にあるのか、少しでも理解が深まりましたら幸いです。
まとめ
本書『100 Go Mistakes and How to Avoid Them』では、今回のようなslice自体の構造の説明だけではなく、それに基づいて、実装上気をつけるべきTipsについてもいくつか紹介されています。また、Slice以外にGo言語に関する様々なテーマを、100つのTipsとして網羅的に紹介しています。
個人的に、Go言語の文法はさらっと学習して、プロジェクトで実際に使い始めた初中級者のGopherの方には、めちゃくちゃオススメです。自分もまだじっくり読み進めている最中で、年末までに終わらせたいです
最後に
ちょっと宣伝させていただくと、弊社Retail AIは、小売業向けのITソリューションを提供する会社でして、そのうちの一つが『Skip Cart』というプロダクト(兼ソリューション?)になります。
Skip Cartのプロダクト開発のチームがありまして、バックエンドのメイン言語はGo
(+ Python
for 機械学習系)で、プロダクト開発のチームの大半が外国籍のメンバーなので、英語でのコミュニケーションがメインです(メンバーによってはフルリモート勤務)。
私も今年の2月から、Skip Cartのバックエンドの開発チームに加わって、カタコトの英語を日々改善しながら、何とか息してます。他社さんでもあんまり無い、面白い環境だとは個人的には思っています。もしご興味のある方がいらっしゃれば、気軽にご連絡ください〜
明日は、同じSkip Cart開発チームのフロントエンドのリーダーのCaique Almeida(カイケ)
の記事です!お楽しみ〜
参考