第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=5
  • make([]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チェックなしのスライス操作

---

次の章では、マップについて学びます。マップもスライスと同様に参照型ですが、内部実装は全く異なります。