第7章: 配列とスライス - 深層理解
学習目標
この章を終えると、以下ができるようになります:
- 配列とスライスの違いをメモリレベルで理解できる
- スライスヘッダの内部構造を完全に理解できる
- append()の再割り当てメカニズムを説明できる
- make、append、copyの動作をCPU命令レベルで理解できる
- スライスの落とし穴を回避し、最適なパフォーマンスを実現できる
---
イントロダクション: なぜスライスが必要なのか
🔑 根本的な問い: なぜGoには配列とスライスという2つの似た概念が存在するのか?
答えは、メモリの安全性とパフォーマンスのトレードオフにあります。
他言語との比較
┌─────────────┬──────────────────┬───────────────────┐
│ 言語 │ 固定長配列 │ 可変長配列 │
├─────────────┼──────────────────┼───────────────────┤
│ C │ int arr[5] │ malloc + realloc │
│ C++ │ std::array<T, N> │ std::vector<T> │
│ Rust │ [T; N] │ Vec<T> │
│ Go │ [N]T │ []T (slice) │
└─────────────┴──────────────────┴───────────────────┘
💡 重要: Goのスライスは、CやC++の「動的配列」とは異なり、配列への参照(ビュー)です。
---
配列: 固定長のメモリブロック
配列の基本
配列は、コンパイル時にサイズが確定する、連続したメモリ領域です。
// 配列の宣言
var arr [5]int // [0 0 0 0 0]
var names [3]string // ["" "" ""]
// 初期化
arr := [5]int{1, 2, 3, 4, 5}
names := [3]string{"Alice", "Bob", "Carol"}
// 一部だけ初期化
arr := [5]int{1, 2} // [1 2 0 0 0]
// 長さを自動推論
arr := [...]int{1, 2, 3, 4, 5} // [5]int
// インデックス指定で初期化
arr := [5]int{0: 10, 2: 20, 4: 30} // [10 0 20 0 30]
メモリレイアウト: 配列はどこに配置されるのか
🔑 配列のメモリ配置:
スタック上の配列: arr := [5]int{1, 2, 3, 4, 5}
スタックフレーム(関数のローカル変数領域):
┌─────────────────────────────────────────┐
│ &arr (配列の先頭アドレス) │
├─────────────────────────────────────────┤
│ [0] = 1 (0x00 00 00 00 00 00 00 01) │ ← offset 0
│ [1] = 2 (0x00 00 00 00 00 00 00 02) │ ← offset 8
│ [2] = 3 (0x00 00 00 00 00 00 00 03) │ ← offset 16
│ [3] = 4 (0x00 00 00 00 00 00 00 04) │ ← offset 24
│ [4] = 5 (0x00 00 00 00 00 00 00 05) │ ← offset 32
└─────────────────────────────────────────┘
合計: 5 × 8バイト = 40バイト
⚠️ 重要な事実: 配列は値型です。つまり、代入やパラメータ渡しで全体がコピーされます。
なぜ配列はコピーされるのか?
CPUレベルで見ると、配列の代入は以下のような処理になります:
arr1 := [3]int{1, 2, 3}
arr2 := arr1 // コピーが発生
アセンブリレベルの疑似コード:
; arr1 のメモリ領域を arr2 へコピー
; x86-64 での例
MOV RAX, [arr1+0] ; arr1[0] を RAX に読み込み
MOV [arr2+0], RAX ; RAX を arr2[0] に書き込み
MOV RAX, [arr1+8] ; arr1[1] を RAX に読み込み
MOV [arr2+8], RAX ; RAX を arr2[1] に書き込み
MOV RAX, [arr1+16] ; arr1[2] を RAX に読み込み
MOV [arr2+16], RAX ; RAX を arr2[2] に書き込み
; 合計: 6命令(読み込み3 + 書き込み3)
💡 パフォーマンスへの影響:
- 小さい配列(数要素): コピーは高速
- 大きい配列(100要素以上): コピーコストが高い → スライスを使うべき
配列のサイズは型の一部
var arr1 [3]int
var arr2 [5]int
// arr1 = arr2 // エラー!型が異なる
🔑 なぜこうなっているのか?
Goのコンパイラは、コンパイル時にメモリレイアウトを確定する必要があります。
[3]int のレイアウト:
┌────┬────┬────┐
│ 8B │ 8B │ 8B │ = 24バイト
└────┴────┴────┘
[5]int のレイアウト:
┌────┬────┬────┬────┬────┐
│ 8B │ 8B │ 8B │ 8B │ 8B │ = 40バイト
└────┴────┴────┴────┴────┘
サイズが違う → 型も違う
---
スライス: 動的配列の抽象化
スライスとは何か
スライスは、配列への参照(ビュー)+ メタデータです。
🔑 スライスの3つの要素:
- ポインタ: 配列の先頭要素へのポインタ
- 長さ (len): 現在アクセス可能な要素数
- 容量 (cap): 割り当てられたメモリの最大要素数
スライスヘッダの内部構造
Goランタイムでは、スライスは以下のような構造体として表現されます:
// runtime/slice.go の実装(簡略版)
type slice struct {
array unsafe.Pointer // 8バイト(64ビットシステム)
len int // 8バイト
cap int // 8バイト
}
// 合計: 24バイト
💡 重要: スライス変数自体は24バイトしかありません。実際のデータは別の場所(配列)に格納されています。
メモリレイアウトの詳細
s := make([]int, 3, 5) // len=3, cap=5
スタック(ローカル変数s):
┌────────────────────────────┐
│ array: 0x7fff0000 ────────┼─┐
│ len: 3 │ │
│ cap: 5 │ │
└────────────────────────────┘ │
24バイト │
│
↓
ヒープ(実際の配列):
0x7fff0000:
┌────┬────┬────┬────┬────┐
│ 0 │ 0 │ 0 │ ?? │ ?? │ ← 5つ分のスペース
└────┴────┴────┴────┴────┘
↑ ↑
└─ len=3 ──┘
└────── cap=5 ──────┘
⚠️ len と cap の違い:
len: 現在使用している要素数cap: 割り当てられている要素数
スライスの作成方法
方法1: スライスリテラル
s := []int{1, 2, 3}
内部では以下が実行されます:
1. ヒープに配列を割り当て: arr := [3]int{1, 2, 3}
2. スライスヘッダを作成:
- array = &arr[0]
- len = 3
- cap = 3
方法2: make
s := make([]int, 5) // len=5, cap=5
s := make([]int, 5, 10) // len=5, cap=10
makeの内部動作(疑似コード):
func make_slice(typ Type, len, cap int) slice {
// 1. メモリを割り当て
mem := runtime.malloc(typ.size * cap)
// 2. ゼロ初期化
runtime.memclr(mem, typ.size * len)
// 3. スライスヘッダを返す
return slice{
array: mem,
len: len,
cap: cap,
}
}
💡 make([]int, len, cap) vs make([]int, len):
make([]int, 5): len=5, cap=5make([]int, 5, 10): len=5, cap=10(将来の拡張を見越して容量を確保)
方法3: 配列からスライス
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4] // [2 3 4]
スライシング操作の内部:
元の配列:
arr:
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
0 1 2 3 4
arr[1:4] の結果:
s := slice{
array: &arr[1], // インデックス1を指す
len: 3, // 4 - 1 = 3
cap: 4, // 5 - 1 = 4(配列の末尾まで)
}
s:
┌─────────────────┐
│ ↓
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
↑───len=3─↑
↑────cap=4────↑
⚠️ 重要: スライスは元の配列を共有します。スライスを変更すると、元の配列も変更されます。
---
スライシング操作の詳細
基本的なスライシング
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s[2:5] // [2 3 4]
s[:3] // [0 1 2]
s[7:] // [7 8 9]
s[:] // [0 1 2 3 4 5 6 7 8 9](全体のコピー)
3つの引数を取るスライシング: s[low:high:max]
🔑 完全なスライシング構文:
s[low:high:max]
low: 開始インデックスhigh: 終了インデックス(含まない)max: 容量の上限
計算式:
- 新しい len =
high - low - 新しい cap =
max - low
なぜ max パラメータが必要なのか?
💡 理由: スライスの容量を制限して、意図しない共有を防ぐため。
例:
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := arr[2:5] // len=3, cap=8(arr[2]から末尾まで)
s2 := arr[2:5:5] // len=3, cap=3(容量を制限)
メモリレイアウト:
arr:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑───len=3─↑
s1: cap=8
┌───────────────────────────────────────┐
│ │
│ s2: cap=3 │
│ ┌───────────────┐ │
│ │ │ │
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
この違いの影響:
s1 = append(s1, 99) // s1は容量があるので、arrを直接変更
s2 = append(s2, 99) // s2は容量がないので、新しい配列を割り当て
---
append: スライスの拡張メカニズム
appendの基本
s := []int{1, 2, 3}
s = append(s, 4) // [1 2 3 4]
s = append(s, 5, 6, 7) // [1 2 3 4 5 6 7]
⚠️ 必ず戻り値を代入すること:
s := []int{1, 2, 3}
append(s, 4) // 間違い!元のsは変わらない
s = append(s, 4) // 正しい
appendの内部動作
🔑 appendのアルゴリズム:
// runtime/slice.go の簡略版
func append(s slice, elem int) slice {
if s.len < s.cap {
// ケース1: 容量に余裕がある
s.array[s.len] = elem
s.len++
return s
} else {
// ケース2: 容量が不足 → 再割り当て
newcap := calculateNewCap(s.cap, s.len+1)
newarray := malloc(newcap * sizeof(int))
// 既存データをコピー
copy(newarray, s.array, s.len)
// 新要素を追加
newarray[s.len] = elem
// 新しいスライスを返す
return slice{
array: newarray,
len: s.len + 1,
cap: newcap,
}
}
}
ケース1: 容量に余裕がある場合
Before:
s = {array: 0x1000, len: 3, cap: 5}
0x1000:
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ ? │ ? │
└───┴───┴───┴───┴───┘
↑─len=3─↑
↑────cap=5────↑
s = append(s, 4)
After:
s = {array: 0x1000, len: 4, cap: 5}
0x1000:
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ ? │ ← 同じメモリアドレス
└───┴───┴───┴───┴───┘
↑──len=4──↑
↑────cap=5────↑
💡 ポイント: メモリ割り当ては発生しない(O(1)の操作)
ケース2: 容量が不足する場合
Before:
s = {array: 0x1000, len: 5, cap: 5}
0x1000:
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ ← 満杯
└───┴───┴───┴───┴───┘
s = append(s, 6)
Step 1: 新しい配列を割り当て(容量を拡大)
0x2000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ← cap=10(2倍)
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
Step 2: 既存データをコピー
0x2000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ ? │ ? │ ? │ ? │ ? │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
Step 3: 新要素を追加
0x2000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ ? │ ? │ ? │ ? │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑────len=6────↑
↑──────────cap=10─────────↑
After:
s = {array: 0x2000, len: 6, cap: 10}
⚠️ パフォーマンス影響:
- メモリ割り当て:
malloc(newcap sizeof(T)) - データコピー:
memcpy(dst, src, len sizeof(T)) - 古いメモリの解放: GCが後で回収
容量拡張のアルゴリズム
🔑 Goの容量拡張戦略:
// runtime/slice.go の簡略版
func growslice(oldcap, mincap int) int {
newcap := oldcap
doublecap := newcap + newcap
if mincap > doublecap {
newcap = mincap
} else {
if oldcap < 1024 {
// 小さいスライス: 2倍に拡張
newcap = doublecap
} else {
// 大きいスライス: 1.25倍に拡張
for newcap < mincap {
newcap += newcap / 4
}
}
}
return newcap
}
💡 なぜ2倍と1.25倍なのか?
小さいスライス(< 1024要素): 2倍拡張
- 理由: 再割り当ての回数を減らす
- トレードオフ: メモリの無駄は許容範囲
大きいスライス(≥ 1024要素): 1.25倍拡張
- 理由: メモリの無駄を抑える
- トレードオフ: 再割り当ての回数が増える
実際の例:
要素数 次の容量
0 → 1
1 → 2
2 → 4
4 → 8
8 → 16
...
512 → 1024
1024 → 1280 (1024 * 1.25)
1280 → 1600 (1280 * 1.25)
append の償却計算量
🔑 append の時間計算量:
- 最良の場合: O(1)(容量に余裕がある)
- 最悪の場合: O(n)(再割り当てが必要)
- 償却計算量: O(1)(長期的には定数時間)
償却計算量の証明(簡略版):
n 個の要素を append する総コスト:
1. 通常の append: n 回 × O(1) = O(n)
2. 再割り当て: log₂(n) 回(容量が2倍になるたび)
各再割り当てのコスト: O(current_size)
総再割り当てコスト = 1 + 2 + 4 + 8 + ... + n/2
= n - 1
= O(n)
総コスト = O(n) + O(n) = O(n)
1要素あたり = O(n) / n = O(1)
---
copy: スライスのコピー
copy の基本
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Println(dst) // [1 2 3 4 5]
fmt.Println(n) // 5(コピーした要素数)
copy の内部動作
// runtime/slice.go の簡略版
func copy(dst, src slice) int {
n := min(dst.len, src.len)
if n == 0 {
return 0
}
// メモリ領域が重なっている場合も正しく処理
memmove(dst.array, src.array, n * sizeof(element))
return n
}
🔑 copy の特徴:
- コピー数は min(len(dst), len(src))
- メモリが重なっていても安全(memmove を使用)
- 返り値はコピーした要素数
copy のパターン
パターン1: 全体をコピー
src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
copy(dst, src)
Before:
src: [1, 2, 3, 4, 5]
dst: [0, 0, 0, 0, 0]
After:
src: [1, 2, 3, 4, 5] ← 変更なし
dst: [1, 2, 3, 4, 5] ← コピーされた
パターン2: 一部だけコピー
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // n=3
src: [1, 2, 3, 4, 5]
↓ ↓ ↓
dst: [1, 2, 3] ← 3要素だけコピー
パターン3: 大きいdstにコピー
src := []int{1, 2, 3}
dst := make([]int, 5)
n := copy(dst, src) // n=3
src: [1, 2, 3]
↓ ↓ ↓
dst: [1, 2, 3, 0, 0] ← 残りはゼロ値のまま
パターン4: 自分自身にコピー(削除の実装)
s := []int{1, 2, 3, 4, 5}
// インデックス2の要素を削除
i := 2
copy(s[i:], s[i+1:])
s = s[:len(s)-1]
Step 1: s[i+1:] を s[i:] にコピー
Before:
s: [1, 2, 3, 4, 5]
↑───────↑
i i+1
After copy:
s: [1, 2, 4, 5, 5]
↑──────↑
コピーされた
Step 2: 最後の要素を切り捨て
s = s[:len(s)-1]
s: [1, 2, 4, 5]
copy vs append のパフォーマンス
// 方法1: copy
dst := make([]int, len(src))
copy(dst, src)
// 方法2: append
dst := append([]int{}, src...)
💡 パフォーマンス比較:
┌──────────────┬────────────┬──────────────┐
│ 方法 │ メモリ │ 速度 │
├──────────────┼────────────┼──────────────┤
│ copy │ 1回割り当て│ 高速 │
│ append │ 1回割り当て│ やや遅い │
└──────────────┴────────────┴──────────────┘
🔑 推奨:
- 大きいスライス:
copyを使う - 小さいスライス: どちらでもOK
- コードの意図:
copyの方が明確
---
スライスの削除・挿入パターン
削除パターン
パターン1: 順序を保持する削除
func remove(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}
s := []int{1, 2, 3, 4, 5}
s = remove(s, 2) // [1, 2, 4, 5]
内部動作:
s: [1, 2, 3, 4, 5]
Step 1: s[:2] を作成
[1, 2]
Step 2: s[3:] を append
[1, 2] + [4, 5] = [1, 2, 4, 5]
⚠️ 注意: append(s[:i], s[i+1:]...) は新しい配列を割り当てる可能性がある。
パターン2: 順序を保持しない高速削除
func removeFast(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}
s := []int{1, 2, 3, 4, 5}
s = removeFast(s, 2) // [1, 2, 5, 4] (順序が変わる)
内部動作:
s: [1, 2, 3, 4, 5]
↑ ↑
i len-1
Step 1: s[i] = s[len-1]
s: [1, 2, 5, 4, 5]
↑
置き換え
Step 2: s = s[:len-1]
s: [1, 2, 5, 4]
💡 パフォーマンス比較:
remove: O(n)(後続要素をシフト)removeFast: O(1)(1回の代入のみ)
挿入パターン
func insert(s []int, i int, v int) []int {
s = append(s, 0) // スライスを1つ拡張
copy(s[i+1:], s[i:]) // i以降を右にシフト
s[i] = v // vを挿入
return s
}
s := []int{1, 2, 4, 5}
s = insert(s, 2, 3) // [1, 2, 3, 4, 5]
内部動作:
s: [1, 2, 4, 5]
Step 1: append で拡張
s: [1, 2, 4, 5, 0]
Step 2: copy(s[3:], s[2:])
[1, 2, 4, 5, 0]
↓ ↓
[1, 2, 4, 4, 5]
↑────コピー
Step 3: s[2] = 3
s: [1, 2, 3, 4, 5]
---
スライスの落とし穴と回避策
落とし穴1: スライスの共有
⚠️ 問題:
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[0:3] // [1, 2, 3]
s2 := arr[2:5] // [3, 4, 5]
s1[2] = 99
fmt.Println(s1) // [1, 2, 99]
fmt.Println(s2) // [99, 4, 5] ← s2も変わる!
fmt.Println(arr) // [1, 2, 99, 4, 5]
メモリレイアウト:
arr:
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
↑───s1────↑
↑───s2────↑
s1[2] と s2[0] は同じメモリを指している!
🔑 解決策: copy を使う
arr := [5]int{1, 2, 3, 4, 5}
s1 := make([]int, 3)
copy(s1, arr[0:3])
s2 := make([]int, 3)
copy(s2, arr[2:5])
s1[2] = 99
fmt.Println(s1) // [1, 2, 99]
fmt.Println(s2) // [3, 4, 5] ← 変わらない
落とし穴2: appendと容量
⚠️ 問題:
s1 := make([]int, 3, 10) // len=3, cap=10
copy(s1, []int{1, 2, 3})
s2 := s1 // 同じ配列を参照
s1 = append(s1, 4) // 容量があるので同じ配列に追加
s1[0] = 99
fmt.Println(s1) // [99, 2, 3, 4]
fmt.Println(s2) // [99, 2, 3] ← s2も影響を受ける!
メモリレイアウト:
Before append:
s1: {array: 0x1000, len: 3, cap: 10}
s2: {array: 0x1000, len: 3, cap: 10}
0x1000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ ? │ ? │ ? │ ? │ ? │ ? │ ? │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑─s1─↑
↑─s2─↑
After append(s1, 4):
s1: {array: 0x1000, len: 4, cap: 10} ← len変更
s2: {array: 0x1000, len: 3, cap: 10} ← len変わらず
0x1000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ ? │ ? │ ? │ ? │ ? │ ? │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑─s2─↑ ← 同じ配列を共有
↑──s1──↑
s1[0] = 99 の後:
0x1000:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│99 │ 2 │ 3 │ 4 │ ? │ ? │ ? │ ? │ ? │ ? │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑── s2[0] も 99 になる!
🔑 解決策: フルスライス式で容量を制限
s1 := make([]int, 3, 10)
copy(s1, []int{1, 2, 3})
s2 := s1[:len(s1):len(s1)] // cap も len に制限
s1 = append(s1, 4) // 容量がないので新しい配列を割り当て
s1[0] = 99
fmt.Println(s1) // [99, 2, 3, 4]
fmt.Println(s2) // [1, 2, 3] ← 影響を受けない
落とし穴3: ループ内でのappend
⚠️ 問題: 無限ループの可能性
s := []int{1, 2, 3}
for _, v := range s {
s = append(s, v) // 危険!
}
// 実行結果は未定義
🔑 なぜ問題なのか?
Iteration 1:
s: [1, 2, 3]
range は初期状態の len=3 を記憶
Iteration 2:
s: [1, 2, 3, 1]
range はまだ3回しかループしない
Iteration 3:
s: [1, 2, 3, 1, 2]
最終結果:
s: [1, 2, 3, 1, 2, 3] ← 意図しない動作
🔑 解決策1: 長さを事前に保存
s := []int{1, 2, 3}
length := len(s)
for i := 0; i < length; i++ {
s = append(s, s[i])
}
fmt.Println(s) // [1, 2, 3, 1, 2, 3]
🔑 解決策2: コピーをループ
s := []int{1, 2, 3}
sCopy := append([]int{}, s...) // コピー
for _, v := range sCopy {
s = append(s, v)
}
落とし穴4: nilスライスと空スライス
// nilスライス
var s1 []int
fmt.Println(s1 == nil) // true
fmt.Println(len(s1)) // 0
fmt.Println(cap(s1)) // 0
// 空スライス
s2 := []int{}
fmt.Println(s2 == nil) // false
fmt.Println(len(s2)) // 0
fmt.Println(cap(s2)) // 0
メモリレイアウト:
nil スライス:
s1: {array: nil, len: 0, cap: 0}
空スライス:
s2: {array: 0x..., len: 0, cap: 0}
↑ nilではない(空の配列を指す)
💡 どちらを使うべきか?
┌──────────────────────┬──────────────┬────────────┐
│ ユースケース │ 推奨 │ 理由 │
├──────────────────────┼──────────────┼────────────┤
│ 関数の戻り値 │ nil │ 明示的 │
│ JSONエンコード │ []int{} │ null vs [] │
│ 初期化 │ どちらでも │ 動作同じ │
└──────────────────────┴──────────────┴────────────┘
---
2次元スライス
2次元スライスの作成
// 方法1: リテラル
matrix := [][]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
}
// 方法2: makeで作成
rows, cols := 3, 4
matrix := make([][]int, rows)
for i := range matrix {
matrix[i] = make([]int, cols)
}
メモリレイアウト
matrix := [][]int{
{1, 2, 3},
{4, 5, 6},
}
メモリ構造:
matrix:
┌─────────────────────┐
│ []*slice │
│ ├→ {array: 0x1000} │───→ [1, 2, 3]
│ └→ {array: 0x2000} │───→ [4, 5, 6]
└─────────────────────┘
💡 重要: 各行は独立したスライスです。
---
スライスのパフォーマンス最適化
最適化1: 事前に容量を確保
❌ 悪い例:
var s []int
for i := 0; i < 1000000; i++ {
s = append(s, i) // 何度も再割り当て
}
✅ 良い例:
s := make([]int, 0, 1000000) // 容量を事前確保
for i := 0; i < 1000000; i++ {
s = append(s, i) // 再割り当てなし
}
🔑 パフォーマンス差:
再割り当ての回数:
悪い例: log₂(1000000) ≈ 20回
良い例: 0回
実行時間:
悪い例: ~100ms
良い例: ~10ms ← 10倍速い
最適化2: スライスのリセット
新しいスライスを作るのではなく、既存のスライスを再利用:
// 悪い例
func process() {
s := make([]int, 0, 100)
// 処理
}
// 良い例
var pool = make([]int, 0, 100)
func process() {
s := pool[:0] // 長さを0にリセット(容量は維持)
// 処理
pool = s // 再利用のために保存
}
最適化3: スライスのバッファプール
import "sync"
var slicePool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
func process() {
buf := slicePool.Get().([]byte)
buf = buf[:0] // リセット
// 処理
slicePool.Put(buf) // プールに返す
}
---
高度なトピック: スライスとGC
スライスのメモリリーク
⚠️ 問題:
func loadBigFile() []byte {
data := readFile("big.txt") // 1GBのファイル
return data[:100] // 最初の100バイトだけ返す
}
メモリレイアウト:
data:
┌────────────────────────────────────┐
│ 1GB のデータ │
└────────────────────────────────────┘
↑──100バイト─↑
返されたスライスは100バイトしか使わないが、
1GB全体への参照を保持している!
🔑 解決策: copyを使う
func loadBigFile() []byte {
data := readFile("big.txt")
result := make([]byte, 100)
copy(result, data)
return result // 元の1GBは GC で回収される
}
---
まとめ
この章では、配列とスライスについて深く学びました。
重要ポイント
🔑 配列:
- 固定長、値型、スタックに配置
- サイズは型の一部
- コピーコストが高い
🔑 スライス:
- 動的サイズ、参照型
- 内部構造: ポインタ + len + cap
- appendは償却O(1)
🔑 append:
- 容量がある: O(1)
- 容量不足: O(n)(再割り当て)
- 容量拡張: <1024要素は2倍、≥1024は1.25倍
🔑 落とし穴:
- スライスの共有
- appendと容量
- ループ内append
- nilスライス vs 空スライス
ベストプラクティス
✅ 推奨:
- 配列よりスライスを使う
- makeで容量を事前確保
- appendの戻り値を必ず受け取る
- copyで安全なコピー
- フルスライス式で容量を制限
❌ 非推奨:
- 大きい配列をコピー
- 容量を指定しないmake
- ループ内で無限append
- nilチェックなしのスライス操作
---
次の章では、マップについて学びます。マップもスライスと同様に参照型ですが、内部実装は全く異なります。