最近、会社のプロジェクトの執筆に忙しく、golang
の学習が怠ってしまったため、Go言語精進の道
シリーズの記事を開始し、学習過程での理解とノートを記録します。
スライスについて#
スライスは、Go 言語において配列に基づいて提供される抽象データ型であり、Go 言語では、配列を使用する必要があるほとんどのシーンでスライスを代わりに使用できます。
Go 言語において、スライスは配列を完璧に代替し、より柔軟で効率的なデータシーケンスアクセスインターフェースを提供します。
スライスとは何か?#
Go 言語のスライスを理解する前に、まず配列を理解する必要があります。
スライスとは?#
Go において、配列は固定長で、同じ型の要素を収容する連続したシーケンスです。したがって、Go の配列には 2 つの属性があります:要素の型
と 配列の長さ
、これら 2 つの型が同じ配列は等価です。つまり:
var a [8]int
var b [8]int
ここで a
の配列の長さは 8 で、要素の型は int であり、b
と同じなので、a
と b
は等価であると言えます。
Go は値セマンティクスを持つため、配列変数は配列全体を表し、配列の最初の要素のポインタではありません(これは C 言語のやり方です)。Go 言語では、配列を渡す際には値コピーが使用されるため、要素の型の長さが大きい場合や要素が多い配列を渡す必要があるときには、かなりのパフォーマンス損失が生じます。
C 言語では、このようなシーンでは配列ポインタ型を使用して処理できますが、Go 言語では一般的にスライスを使用します。
スライスと配列の関係#
スライスと配列の関係は、ファイル記述子とファイルのようなものです。Go 言語において、配列は一般に「裏方」としてデータストレージの「基盤」として機能し、スライスは「表舞台」に出て、基盤のストレージに対する外部アクセスのインターフェースを提供します。
したがって、スライスは配列の「記述子」と呼ぶことができます。スライスが異なる関数間で自由に渡される理由は、「記述子」の特性によるもので、基盤のデータストレージがどれほど大きくても、記述子のサイズは常に固定です。
スライスの作成方法とランタイムでの表現#
スライスは Goランタイム
で次のように表現されます:
//$GOROOT/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
ここで、スライスには 3 つの要素があります:
array
:下層配列の特定の要素を指すポインタで、この要素がスライスの最初の要素です。len
:スライスの長さ、つまり現在のスライスの要素数です。cap
:スライスの最大容量、cap >= len
です。
ランタイム中、各スライスは runtime.slice
構造体のインスタンスであり、次の文を使用してスライスを作成できます:
s := make([]byte, 5)
スライスを作成する際、コンパイラはデフォルトで基盤の配列を作成し、cap
を指定しない場合、デフォルトで cap = len
になります。
既存の配列に対する操作を通じてスライスを作成することもできます。これを 配列のスライス化
と呼びます:
u := [10]byte{11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
s := u[3:7]
スライス s は配列 u の操作ウィンドウを開いており、s
の最初の要素は u[3]
であり、s
を通じて見ることができる配列要素は 4 つあります。スライスの cap
は基盤配列の長さに依存し、u[3]
から末尾までに 7 つのストレージ要素のスロットがあるため、s
の cap
は 7 です。
もちろん、1 つの配列に対して複数の操作用スライスを作成することもできますが、作成された複数のスライスは同じ配列の記述子であるため、任意のスライスの変更は他のスライスにも反映されます。
スライスに対する操作を通じて新しいスライスを作成することもでき、この操作を リスライス
と呼びます。同様に、古いスライスに基づいて作成された新しいスライスは同じ基盤配列を共有するため、新しいスライスの変更も古いスライスに反映されます。
したがって、スライスがパラメータとして渡されるとき、渡されるのは runtime.slice
であり、基盤配列がどれほど大きくても、スライスによるパフォーマンス損失は非常に小さく、一定であり、無視できるほどです。これが関数内で通常スライスを使用する理由の 1 つです。もう 1 つの理由は、スライスがポインタ配列よりも強力な機能を提供できることです。たとえば、インデックスアクセス、境界オーバーフローのチェック、動的拡張などです。
スライスの高度な特性#
動的拡張#
スライスはゼロ値が使用可能な概念を満たしており、スライスには高度な特性があります:動的拡張
、つまりゼロ値スライスであっても、append
というプリデファインド関数を通じて要素の代入操作を行うことができます。
var k []int
k = append(k, 1)
k はゼロ値で初期化されているため、k は対応する基盤配列にバインドされていません。しかし、append
操作を経た後、k は明らかに自分の基盤配列にバインドされました。
以下に、一連の実践を通じて、各 append
後の len
と cap
を示します:
var s []int
s = append(s, 1)
fmt.Println(len(s), cap(s)) // 1 1
s = append(s, 2)
fmt.Println(len(s), cap(s)) // 2 2
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 4
s = append(s, 5)
fmt.Println(len(s), cap(s)) // 5 8
len
の値は線形に増加していますが、cap
は不規則に増加しています。この図は、append
操作がどのようにスライスを動的に拡張するかを視覚的に示しています:
- 最初に
s
はゼロ値であり、この時点でs
はまだ基盤配列にバインドされていません。 append
によって、スライスs
に要素 11 が追加され、この時点で基盤配列u1
が割り当てられ、配列の長さは 1 です。次に、s
内部のarray
がu1
を指すようになり、したがってこの時点でlen = 1, cap = 1
です。- 再度
append
により、12 がスライスs
に挿入されますが、この時点でスライスには明らかに 12 を格納するスペースがありません。したがって、再度拡張が必要です。基盤配列u2
が作成され、長さは 2(u1 配列の長さ
* 2)で、u1
のすべての要素がu2
にコピーされ、最後にarray
がu2
を指すようになり、len = 2, cap = 2
に設定されます。 append
により、スライスs
に再度要素 13 を挿入しますが、この時点でスライスのスペースが再び不足しているため、再度拡張が必要です。新しい基盤配列u3
が作成され、長さは 4(u2 配列の長さ
* 2)で、u2
のすべての要素がu3
にコピーされ、次にarray
がu3
を指すようになり、len = 3, cap = 4
に設定されます。append
により、スライスs
に要素 14 を挿入しますが、この時点でスライスのcap = 4
でスペースが十分です。したがって、14 は次の要素の位置、つまりu3
の末尾に置かれ、s
内部のlen
が 1 増加し、4 になります。- 最後に再度
append
により、スライスs
に 15 を挿入しますが、この時点でスライスのlen = 4, cap = 4
でスペースが不足しているため、再度拡張が必要です。新しい基盤配列u4
が作成され、配列の長さは 8(u3 配列の長さ
* 2)で、u3
のすべての要素がu4
にコピーされ、次にarray
がu4
を指すようになり、len = 5, cap = 8
に設定されます。
スライスの基盤配列のスペースが不足している場合、append
は必要に応じてスライスを新しい基盤配列に自動的に割り当てます。新しい配列の長さは一定のアルゴリズムに従って拡張されます($GOROOT/src/runtime/slice.go
内の growslice
関数を参照)。新しい配列が作成された後、古い配列のすべてのデータが新しい配列にコピーされ、次に array
が新しい配列を指すようになり、これにより新しい配列がスライスの基盤配列となり、古い配列は gc
によって回収されます。
ただし、配列のスライス化
を使用して作成されたスライスの場合、スライスの cap
が上限に達すると、スライスに対して append
操作を行うと、そのスライスは元の配列とのバインドが解除されます。
u := [6]int{1, 2, 3, 4, 5, 6}
s := u[2:4]
fmt.Printf("u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 7)
fmt.Printf("append 7: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 8)
fmt.Printf("append 8: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
s = append(s, 9)
fmt.Printf("append 9: u = %v, s = %v len(s) = %d, cap(s) = %d\n", u, s, len(s), cap(s))
// 出力:
// u = [1 2 3 4 5 6], s = [3 4] len(s) = 2, cap(s) = 4
// append 7: u = [1 2 3 4 7 6], s = [3 4 7] len(s) = 3, cap(s) = 4
// append 8: u = [1 2 3 4 7 8], s = [3 4 7 8] len(s) = 4, cap(s) = 4
// append 9: u = [1 2 3 4 7 8], s = [3 4 7 8 9] len(s) = 5, cap(s) = 8
// append 10: u = [1 2 3 4 7 8], s = [3 4 7 8 9 10] len(s) = 6, cap(s) = 8
入力結果から、要素 9 を追加した後、cap(s)
が 8 になり、これによりスライスの動的拡張がトリガーされ、その時点でスライス s
は配列 u
とのバインドが解除され、すなわち s
はもはや u
の記述子ではなくなります。
可能な限り cap パラメータを使用してスライスを作成する#
append
操作は強力なツールであり、スライスがゼロ値使用可能の理念をサポートし、スライスをより使いやすくします。
しかし、原理から見ると、各拡張のたびに、古い基盤配列からすべての要素を新しい基盤配列にコピーする必要があり、このステップにかかるリソースは実際にはかなりのものです。特に要素が多い場合には。では、過剰なメモリ割り当てと要素コピーにかかるコストをどう回避するか?
有効な解決策は、コードを書く際にスライスの必要な容量を意識的に見積もり、スライス作成時に cap
パラメータを組み込んで make
関数に渡すことです。
s := make([]T, len, cap)
以下に 2 つのケースのパフォーマンステストを示します:
const sliceSize = 10000
func BenchmarkSliceInitWithoutCap(b *testing.B) {
for n := 0; n < b.N; n++ {
sl := make([]int, 0)
for i := 0; i < sliceSize; i++ {
sl = append(sl, i)
}
}
}
func BenchmarkSliceInitWithCap(b *testing.B) {
for n := 0; n < b.N; n++ {
sl := make([]int, 0, sliceSize)
for i := 0; i < sliceSize; i++ {
sl = append(sl, i)
}
}
}
得られた結果は:
goos: windows
goarch: amd64
pkg: prometheus_for_go
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkSliceInitWithoutCap
BenchmarkSliceInitWithoutCap-16 34340 34443 ns/op
BenchmarkSliceInitWithCap
BenchmarkSliceInitWithCap-16 106251 11122 ns/op
PASS
cap
パラメータを使用したスライスの append
時の平均パフォーマンスは 11122 ns/op
であり、cap
パラメータを使用しない場合の約 3 倍です。
したがって、スライスを作成する際には、cap
パラメータを持たせることをお勧めします。