FanXing Blog

FanXing Blog

一个热爱编程的高中生,正在努力成为一位优秀的后端工程师~

Day1. Go言語の精進の道:スライスの原理と効率的な使用方法

最近、会社のプロジェクトの執筆に忙しく、golang の学習が怠ってしまったため、Go言語精進の道 シリーズの記事を開始し、学習過程での理解とノートを記録します。


スライスについて#

スライスは、Go 言語において配列に基づいて提供される抽象データ型であり、Go 言語では、配列を使用する必要があるほとんどのシーンでスライスを代わりに使用できます。
Go 言語において、スライスは配列を完璧に代替し、より柔軟で効率的なデータシーケンスアクセスインターフェースを提供します。

スライスとは何か?#

Go 言語のスライスを理解する前に、まず配列を理解する必要があります。

スライスとは?#

Go において、配列は固定長で、同じ型の要素を収容する連続したシーケンスです。したがって、Go の配列には 2 つの属性があります:要素の型配列の長さ、これら 2 つの型が同じ配列は等価です。つまり:

var a [8]int
var b [8]int

ここで a の配列の長さは 8 で、要素の型は int であり、b と同じなので、ab は等価であると言えます。

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 つのストレージ要素のスロットがあるため、scap は 7 です。

もちろん、1 つの配列に対して複数の操作用スライスを作成することもできますが、作成された複数のスライスは同じ配列の記述子であるため、任意のスライスの変更は他のスライスにも反映されます。

スライスに対する操作を通じて新しいスライスを作成することもでき、この操作を リスライス と呼びます。同様に、古いスライスに基づいて作成された新しいスライスは同じ基盤配列を共有するため、新しいスライスの変更も古いスライスに反映されます。

したがって、スライスがパラメータとして渡されるとき、渡されるのは runtime.slice であり、基盤配列がどれほど大きくても、スライスによるパフォーマンス損失は非常に小さく、一定であり、無視できるほどです。これが関数内で通常スライスを使用する理由の 1 つです。もう 1 つの理由は、スライスがポインタ配列よりも強力な機能を提供できることです。たとえば、インデックスアクセス、境界オーバーフローのチェック、動的拡張などです。

スライスの高度な特性#

動的拡張#

スライスはゼロ値が使用可能な概念を満たしており、スライスには高度な特性があります:動的拡張、つまりゼロ値スライスであっても、append というプリデファインド関数を通じて要素の代入操作を行うことができます。

var k []int
k = append(k, 1)

k はゼロ値で初期化されているため、k は対応する基盤配列にバインドされていません。しかし、append 操作を経た後、k は明らかに自分の基盤配列にバインドされました。

以下に、一連の実践を通じて、各 append 後の lencap を示します:

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 操作がどのようにスライスを動的に拡張するかを視覚的に示しています:

image

  1. 最初に s はゼロ値であり、この時点で s はまだ基盤配列にバインドされていません。
  2. append によって、スライス s に要素 11 が追加され、この時点で基盤配列 u1 が割り当てられ、配列の長さは 1 です。次に、s 内部の arrayu1 を指すようになり、したがってこの時点で len = 1, cap = 1 です。
  3. 再度 append により、12 がスライス s に挿入されますが、この時点でスライスには明らかに 12 を格納するスペースがありません。したがって、再度拡張が必要です。基盤配列 u2 が作成され、長さは 2(u1 配列の長さ * 2)で、u1 のすべての要素が u2 にコピーされ、最後に arrayu2 を指すようになり、len = 2, cap = 2 に設定されます。
  4. append により、スライス s に再度要素 13 を挿入しますが、この時点でスライスのスペースが再び不足しているため、再度拡張が必要です。新しい基盤配列 u3 が作成され、長さは 4(u2 配列の長さ * 2)で、u2 のすべての要素が u3 にコピーされ、次に arrayu3 を指すようになり、len = 3, cap = 4 に設定されます。
  5. append により、スライス s に要素 14 を挿入しますが、この時点でスライスの cap = 4 でスペースが十分です。したがって、14 は次の要素の位置、つまり u3 の末尾に置かれ、s 内部の len が 1 増加し、4 になります。
  6. 最後に再度 append により、スライス s に 15 を挿入しますが、この時点でスライスの len = 4, cap = 4 でスペースが不足しているため、再度拡張が必要です。新しい基盤配列 u4 が作成され、配列の長さは 8(u3 配列の長さ * 2)で、u3 のすべての要素が u4 にコピーされ、次に arrayu4 を指すようになり、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 パラメータを持たせることをお勧めします。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。