1-5. スライスとマップ - 配列、append、range、共有の罠、map idiom

所要時間: 30-50分(がっつりなら2セッション分) コミット内容: ~/learn/go/day05/collections/ 一式


このレッスンのゴール

  • スライスの内部構造(ptr/len/cap)を絵で描いて説明できる
  • append の共有問題と対策(copy、明示コピー)を実行できる
  • range の値はコピー、書き換えは index 経由 を覚える
  • map の comma-ok idiom v, ok := m[k] を脊髄反射で書ける
  • map の順序が保証されないことを知り、キーソート対策を書ける

なぜ学ぶか

JS の配列メソッド(map/filter/reduce)が無いの不便そう…」 - 確かに最初は不便。でも Go のスライスは メモリ構造が透けて見える ので、JS では絶対書けない「ゼロアロケーションで100万件処理」のような世界に踏み込める。これが Go が選ばれるサーバー言語たる所以。一方で、JS の array.slice() がコピーを返すのに対し、Go の s[1:4] は内部配列を共有する という致命的な違いがあり、ここで一度ハマるのが入門者の通過儀礼。

前章とのつながり

1-4_関数 で書いた func filter(nums []int, ...) []int は本章で扱う スライスの append と range を使った典型例。本章でスライスの本質を理解すると、あの関数の挙動が「なぜ新しいスライスを返すのか」腑に落ちる。

これができると何が嬉しいか

  • 巨大なログファイルを毎秒1万行処理するようなコードが書ける
  • DB の結果セット(数万行)をスライスに詰めて返す API が書ける
  • map で集計・キャッシュ・重複排除 ができる - 業務頻出パターン

ストーリー導入: スライスは「配列を覗く窓」

巨大な配列がメモリにあって、それを 「ここからここまで」と指差す窓(ptr + len + cap) がスライス。窓を別の人に渡しても、見ている 元の配列は同じ。だから一人が値を書き換えると、全員に見える。これが共有の罠の正体。逆に「窓を共有してメモリ節約」が Go の高速性の源泉。


大前提: スライスは「配列のラッパー」

JS の配列は実質的に「可変長で何でも入る」便利なやつ。Go では:

  • 配列 ([5]int): 長さがコンパイル時に決まる、固定長。ほぼ使わない
  • スライス ([]int): 可変長。実質的にこっちが「配列」として日常的に使う

スライスは内部的に 配列を指すポインタ + 長さ + 容量 という3点セット。これを理解すると、後で出てくる「共有の罠」を回避できる。


セッション①: スライスの基本と内部構造(25-30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/day05/collections
cd ~/learn/go/day05/collections
go mod init example.com/collections
script ~/log/go_day05.log

1. 配列 ([N]T) - ほぼ使わない型

package main
 
import "fmt"
 
func main() {
	// 配列: 長さが型の一部
	var arr [3]int
	arr[0] = 10
	arr[1] = 20
	arr[2] = 30
	fmt.Println(arr) // [10 20 30]
 
	// リテラル
	arr2 := [3]string{"go", "rust", "python"}
	fmt.Println(arr2)
 
	// 長さを省略([...] でコンパイラに数えさせる)
	arr3 := [...]int{1, 2, 3, 4, 5}
	fmt.Println(len(arr3)) // 5
}

配列 [N]T の本質と「ほぼ使わない」理由

長さ N型の一部[3]int[5]int違う型。後から長さは変えられない。

使い所(あまり無い)

  • 暗号鍵やハッシュ値(長さが固定で決まっている [32]byte など)
  • 配列リテラルでテストデータを並べる時(長さ計算を省略するため [...]int

普段使うのは「スライス」の方。配列は「スライスを作るための土台」と思っておけばOK。

2. スライス ([]T) - 普段使う「リスト」

// 宣言と初期化
nums := []int{10, 20, 30}
fmt.Println(nums, len(nums))
 
// 空のスライスを作る
var empty []int           // nil スライス
empty2 := []int{}         // 空スライス(中身ゼロ)
empty3 := make([]int, 0)  // 同上
 
// 長さ指定で作る(ゼロ値で埋まる)
zeros := make([]int, 5)
fmt.Println(zeros) // [0 0 0 0 0]
 
// 長さと容量を別々に指定
buf := make([]int, 3, 10)
fmt.Println(buf, len(buf), cap(buf)) // [0 0 0] 3 10

スライスの本質: 配列を指す「窓」

スライス変数は、メモリ上で 3つの情報 を持っているだけ:

┌──────────────┐
│ ptr → [配列の先頭]
│ len = 3
│ cap = 10
└──────────────┘
  • ptr: 内部で持っている配列の先頭アドレス
  • len (length): 「今見えているサイズ」
  • cap (capacity): 「最大何個入るか」(内部配列のサイズ)

len <= cap。append で len が増え、cap を超えるとより大きい配列が新しく割り当てられる。

JS で言うと: JS の配列は内部実装はもっと複雑だが、感覚的には「自動拡張する配列」という点で同じ。Go のスライスは実装が透けて見えるので、効率を意識した書き方ができる。

make() の3形態

書き方意味
make([]int, 5)長さ5、容量5(中身ゼロ値)
make([]int, 3, 10)長さ3、容量10(残り7は予約スペース)
make(map[string]int)空のマップ
make(chan int, 4)バッファ4のチャネル

使い所: make([]T, 0, expectedSize)「最終的に何個入る予測」を伝える。append のたびに再アロケーションが起きないので速い。

3. append - 要素を追加する

nums := []int{1, 2, 3}
nums = append(nums, 4)         // [1 2 3 4]
nums = append(nums, 5, 6, 7)   // 複数まとめて
fmt.Println(nums)
 
// 別のスライスを連結(... を付ける)
extra := []int{100, 200}
nums = append(nums, extra...)
fmt.Println(nums)

append の本質

スライスに要素を追加する 唯一の正解。組み込み関数(builtin パッケージ)。

重要な性質

  • 戻り値で 新しいスライス を返す(元のスライスは書き換えない)
  • 容量が足りる時は内部配列を共有、足りない時は新しい配列をアロケート
  • 戻り値を受け取らないと無意味: append(nums, 4) だけだと結果が捨てられる
// NG: 結果を受け取っていない
append(nums, 4)
 
// OK
nums = append(nums, 4)

JS で言うと: nums.push(4) に近いが、戻り値の扱いが違う。push は in-place、append は新しいスライス。

4. lencap、スライス操作 [:]

s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
 
fmt.Println(len(s)) // 10
fmt.Println(cap(s)) // 10
 
// 部分スライス(半開区間 [low:high))
sub := s[2:5]
fmt.Println(sub)         // [2 3 4]
fmt.Println(len(sub), cap(sub)) // 3 8 ← cap は元配列の残り
 
// 省略形
fmt.Println(s[:3])  // [0 1 2]
fmt.Println(s[7:])  // [7 8 9]
fmt.Println(s[:])   // 全体

スライス操作 s[low:high] の本質

既存スライスから 新しいスライス を作る操作。範囲は [low, high)(low 含む、high 含まない)。

半開区間ルール

  • s[0:3] = インデックス 0, 1, 2 の3個
  • s[2:5] = インデックス 2, 3, 4 の3個(5番目は含まない
  • 数えやすい: 末尾 - 先頭 = 個数

省略形

  • s[:3] = 先頭から3個(s[0:3] と同じ)
  • s[3:] = 3番目以降全部
  • s[:] = 全体(コピー目的ではない、ただの参照)

JS で言うと: array.slice(start, end) と同じ概念。ただし JS の slice は コピー を作るが、Go の [:] はコピーしない(次の落とし穴)。

スライスの落とし穴: 共有による事故

s := []int{0, 1, 2, 3, 4}
sub := s[1:4]    // [1 2 3]
sub[0] = 999     // ← sub を書き換えた
fmt.Println(s)   // [0 999 2 3 4] ← 元も変わってる!

[:] で作ったスライスは、元と内部配列を共有している。一方を書き換えるともう一方にも反映される。

JS の array.slice() がコピーを返すのと違うので、JS から来ると 必ず1回はハマる

対策: 明示的にコピーする

sub := make([]int, 3)
copy(sub, s[1:4])  // sub は独立

または:

sub := append([]int{}, s[1:4]...)  // 慣用句

append でも共有が起きる

s := make([]int, 3, 10) // len=3, cap=10
s[0], s[1], s[2] = 1, 2, 3
 
t := s              // t と s は内部配列共有
t = append(t, 99)   // cap に余裕あり → 同じ内部配列に書く
 
fmt.Println(s)  // [1 2 3] ← 見えないけど、配列の4番目に 99 が入っている
fmt.Println(t)  // [1 2 3 99]
 
// 次に s に append すると t が見ている部分を破壊する
s = append(s, 777)
fmt.Println(t)  // [1 2 3 777] ← 99 が 777 に!

中級者でもハマる罠。**「append は同じ内部配列を書き換える可能性がある」**ことを覚えておく。append の戻り値が必ずしも新しい配列を指すとは限らない。

5. range でイテレーション

fruits := []string{"apple", "banana", "cherry"}
 
// インデックスと値
for i, v := range fruits {
	fmt.Println(i, v)
}
 
// インデックス不要
for _, v := range fruits {
	fmt.Println(v)
}
 
// 値不要(インデックスだけ)
for i := range fruits {
	fmt.Println(i)
}

range の本質

コレクションを順番に取り出す構文。スライス・マップ・チャネル・文字列で使える。

対象range の戻り
スライスindex, value
マップkey, value
チャネルvalue
文字列byteIndex, runeマルチバイトを正しく扱う

JS で言うと

  • for (let v of arr) = for _, v := range arr
  • for (let [i, v] of arr.entries()) = for i, v := range arr
  • for (let k in obj) = for k := range m

range の値はコピー

nums := []int{1, 2, 3}
for _, v := range nums {
	v *= 2  // v はコピー。元の nums は変わらない
}
fmt.Println(nums) // [1 2 3]
 
// 書き換えたい時はインデックス経由で
for i := range nums {
	nums[i] *= 2
}
fmt.Println(nums) // [2 4 6]

JSの forEach は元の配列を直接編集できるが、Go の range の値はスナップショット。書き換えたい時はインデックスを使う。


セッション②: マップ(25-30分)

6. マップの基本

package main
 
import "fmt"
 
func main() {
	// リテラルで初期化
	ages := map[string]int{
		"Alice": 30,
		"Bob":   25,
		"Carol": 35,
	}
	fmt.Println(ages)
 
	// 空のマップ
	scores := make(map[string]int)
	scores["math"] = 80
	scores["eng"] = 90
	fmt.Println(scores)
 
	// 値の取得
	fmt.Println(ages["Alice"]) // 30
 
	// 削除
	delete(ages, "Bob")
	fmt.Println(ages)
 
	// 個数
	fmt.Println(len(ages))
}

マップ map[K]V の本質

キーと値のペア を高速に出し入れできるデータ構造。内部はハッシュテーブル。

書き方

  • 宣言: var m map[string]intnil マップ、書き込み不可)
  • 初期化: make(map[string]int) または リテラル
  • キー型 K比較可能な型== で比較できる型。スライス・マップ・関数は使えない)

JS で言うと: JS の Map オブジェクトに相当。プレーンオブジェクト {} でも似た用途で使うが、Go の map は明示的にハッシュテーブル。

nil map に代入はパニック

var m map[string]int  // nil
m["key"] = 1          // ランタイムパニック: assignment to entry in nil map

必ず make か リテラルで初期化してから使う。読み取りは nil でもパニックしない(ゼロ値が返る)。

7. 存在チェック(comma ok idiom)

ages := map[string]int{"Alice": 30}
 
// 普通の参照: 存在しないキーはゼロ値が返る
v := ages["Bob"]
fmt.Println(v) // 0 ← Bob が居ないのか、Bob の年齢が 0 なのか分からない
 
// comma ok idiom
v, ok := ages["Bob"]
if !ok {
	fmt.Println("Bob は登録されていません")
} else {
	fmt.Println("Bob の年齢:", v)
}

comma-ok idiom の本質

マップの取得は 値の取得と「存在チェック」を同時に出来る。2つ目の戻り値 oktrue なら存在、false ならゼロ値が返っている。

使い所

  • ゼロ値と「未登録」を区別したい時: int のマップで「0」が有効値の場合は必須
  • キャッシュのヒット判定: if cached, ok := cache[key]; ok { return cached }
  • 設定の上書き有無: 「設定されていれば使う、なければデフォルト」

JS で言うと

  • JS: if (obj.key !== undefined) で確認
  • Go: _, ok := m["key"]; if ok { ... } か、if _, ok := m["key"]; ok { ... }(一行慣用句)

8. map に対する range

m := map[string]int{
	"Alice": 30,
	"Bob":   25,
	"Carol": 35,
}
 
for k, v := range m {
	fmt.Println(k, v)
}

map の順序は 保証されない

Go の map は range の度に ランダムな順序 で要素を返す(仕様で意図的にランダム化されている)。

// 同じコードを実行しても毎回違う順に出る
for k, v := range m {
	fmt.Println(k, v)
}

理由: 開発者が「順序に依存したコードを書く」のを防ぐため。Go 1.0以降、明示的にランダム化されている。

対策: 順序が欲しい時はキーをソート

import "sort"
 
keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)
 
for _, k := range keys {
	fmt.Println(k, m[k])
}

JS との違い: JS の Map は挿入順序を保持する。プレーンオブジェクトもES2015 以降は挿入順を保つ。Go の map は 意図的に順序なし

9. マップの実務ユースケース

よく使うパターン

1. 重複排除(set 代わり): Go には set 型が無いので map で代用

seen := make(map[string]bool)
for _, item := range items {
	if seen[item] {
		continue
	}
	seen[item] = true
	// 処理...
}

または値を struct{}(メモリ0バイト)にする上級テク:

seen := make(map[string]struct{})
seen["alice"] = struct{}{}
_, ok := seen["alice"]

2. 集計

counts := make(map[string]int)
for _, word := range words {
	counts[word]++ // 未登録キーでも 0 から +1 で OK
}

3. キャッシュ

cache := make(map[string]Result)
if r, ok := cache[key]; ok {
	return r
}
r := compute(key)
cache[key] = r

map の落とし穴

  • キー型に注意: スライスはキーにできない(map[[]int]string は NG)
  • 並行書き込みは禁止: 複数のゴルーチンから同時に書くと クラッシュする(並行アクセスは sync.Mapsync.Mutex 経由で)
  • 値の取り出しと書き換え: m["a"].field = 1NG(map から取り出した struct はコピーになるため)。書き換えるなら一度取り出して書き戻す
  • キーの値が削除されたかどうか分からない: delete した後 m[key] するとゼロ値が返る。確実な判定は comma-ok

練習課題

package main
 
import (
	"fmt"
	"sort"
	"strings"
)
 
func main() {
	// 1. スライスの基本
	nums := []int{}
	for i := 1; i <= 5; i++ {
		nums = append(nums, i*i)
	}
	fmt.Println(nums) // [1 4 9 16 25]
 
	// 2. 部分スライスと共有の確認
	a := []int{1, 2, 3, 4, 5}
	b := a[1:4]
	b[0] = 999
	fmt.Println("a:", a) // [1 999 3 4 5]  ← 元も変わる
	fmt.Println("b:", b) // [999 3 4]
 
	// 独立したコピーを作る
	c := make([]int, len(a[1:4]))
	copy(c, a[1:4])
	c[0] = -1
	fmt.Println("a:", a) // c の編集は影響しない
	fmt.Println("c:", c)
 
	// 3. 単語の出現回数
	text := "go is great and go is fun"
	counts := make(map[string]int)
	for _, w := range strings.Fields(text) {
		counts[w]++
	}
 
	// キーをソートして出力
	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Printf("%s: %d\n", k, counts[k])
	}
 
	// 4. comma-ok idiom
	m := map[string]int{"alice": 30}
	if v, ok := m["bob"]; ok {
		fmt.Println("bob:", v)
	} else {
		fmt.Println("bob は未登録")
	}
}
go run .

締め: 振り返り(10分)

1. セッション録画を終了

exit

2. 今日の発見(このノートに追記)

- スライスの内部構造を知って腹落ちしたこと:
- 共有の罠を実際に踏んでみた感想:
- map で便利だった使い方:
- 明日やりたいこと:

アンチパターン集 - やらかし事例

スライス/マップの定番事故

1. append の戻り値を受け取らない

nums := []int{1, 2, 3}
append(nums, 4)  // ← 結果が捨てられる、nums は変わらない

nums = append(nums, 4) が正解。append は in-place ではない

2. s[1:4] をコピーと勘違い

a := []int{1, 2, 3, 4, 5}
b := a[1:4]
b[0] = 999
fmt.Println(a)  // [1 999 3 4 5] ← 元も変わってる

JS の array.slice() の感覚で書くと事故る。独立したコピーが必要なら append([]int{}, src...)copy(dst, src)

3. nil map に書き込みパニック

var counts map[string]int
counts["a"]++  // panic: assignment to entry in nil map

必ず make(map[string]int) か リテラルで初期化。

4. map の値の field を直接書き換え

type T struct{ X int }
m := map[string]T{"a": {X: 1}}
m["a"].X = 5  // コンパイルエラー

map から取り出した struct はコピーなので書き換え不可。v := m["a"]; v.X = 5; m["a"] = v か、map[string]*T でポインタ化。

5. map の順序に依存したテスト

for k, v := range m {
    fmt.Fprintf(out, "%s=%d\n", k, v)
}
// 期待出力と毎回違う順 → テストが flaky に

出力順を比較したいなら キーをソート してから。

対比表で違いを明確化

配列 vs スライス

観点配列 [N]Tスライス []T
長さ型の一部動的
値渡しコピーされる共有される(内部配列共有)
リテラル[3]int{1,2,3}[]int{1,2,3}
用途暗号鍵などサイズ固定普段使い

new vs make

観点new(T)make(T, ...)
戻り値*T(ポインタ)T 自体
対象任意の型スライス・マップ・チャネル限定
初期化ゼロ値内部構造も初期化
用途構造体のポインタ確保スライス/マップ/チャネル作成

map vs sync.Map

観点map[K]Vsync.Map
並行アクセス非対応(クラッシュ)対応
型安全はいいいえ(interface{}
性能単スレッドは速い単スレッドは遅い
用途90%のケース並行 read/write 必須時のみ

自己評価チェックリスト

手を動かせた

  • make([]int, 3, 10) で len/cap 違いを観察
  • append の共有挙動を意図的に再現してみた
  • copy(dst, src) で明示コピー
  • range の値書き換えが反映されない例を見た
  • map で集計(word counter)を書いた
  • comma-ok idiom を使った

説明できる

  • スライスの ptr/len/cap 3点セットを絵で描ける
  • append が新しい配列をアロケートする条件
  • map の順序が保証されない理由
  • nil スライスと空スライスの違い

やらかし回避

  • append の戻り値忘れ罠を理解
  • スライス共有事故の回避策(明示コピー)
  • nil map への書き込みパニックを認識
  • map の field 直接書き換え不可を認識

詰まった時のチートシート

やりたいこと書き方
スライスのリテラル[]int{1, 2, 3}
空のスライス[]int{} または make([]int, 0)
長さ・容量指定make([]int, 3, 10)
要素追加s = append(s, v)
連結s = append(s, other...)
部分取得s[1:4]
コピーdst := make([]int, len(src)); copy(dst, src)
個数len(s)
容量cap(s)
反復for i, v := range s { }
マップのリテラルmap[string]int{"a": 1}
空のマップmake(map[string]int)
値取得v := m[k]
存在チェックv, ok := m[k]
削除delete(m, k)
反復for k, v := range m { }

「実務OK」基準

  • append を躊躇なく使える: 戻り値受け取りも忘れない
  • スライスの共有問題を意識して copy できる: バグの予防接種完了
  • map の comma-ok idiom が脊髄反射: 存在チェックで悩まない
  • 集計・重複排除を map で書ける: 実務で頻出パターン
  • 「map の順序は保証されない」を新人に教えられる

次のレッスン

1-6. 構造体とメソッド へ。

struct、メソッド、ポインタレシーバ vs 値レシーバ、構造体埋め込み(embedding)、JSON タグを扱う。Go にクラスは無いので、struct + メソッドで OOP 的な書き方を組み立てる。

つながりの予告

  • 本章の map[string]int{} は次章の User{Name: "..."} 構造体リテラルと同じ構文
  • スライス共有の罠は、次章で ポインタレシーバ vs 値レシーバ の選び方に繋がる
  • []User のような 構造体のスライス が DB クエリ結果として頻出