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.log1. 配列 ([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. len と cap、スライス操作 [:]
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 arrfor (let [i, v] of arr.entries())=for i, v := range arrfor (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]int(nil マップ、書き込み不可)- 初期化:
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つ目の戻り値
okがtrueなら存在、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.Mapかsync.Mutex経由で)- 値の取り出しと書き換え:
m["a"].field = 1は NG(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. セッション録画を終了
exit2. 今日の発見(このノートに追記)
- スライスの内部構造を知って腹落ちしたこと:
- 共有の罠を実際に踏んでみた感想:
- 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}用途 暗号鍵などサイズ固定 普段使い
newvsmake
観点 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 の順序は保証されない」を新人に教えられる
次のレッスン
struct、メソッド、ポインタレシーバ vs 値レシーバ、構造体埋め込み(embedding)、JSON タグを扱う。Go にクラスは無いので、struct + メソッドで OOP 的な書き方を組み立てる。
つながりの予告
- 本章の
map[string]int{}は次章のUser{Name: "..."}構造体リテラルと同じ構文 - スライス共有の罠は、次章で ポインタレシーバ vs 値レシーバ の選び方に繋がる
[]Userのような 構造体のスライス が DB クエリ結果として頻出