Go基礎 #5 コレクション — array, slice, map

読了 6分

#4 関数、多値返却、error型 で関数とエラーを見ました。今回は — データを束ねる道具たち。Goの3つのコレクション型。

3つのコレクションを一目で #

長さ値の変更一般的な使用度
array固定OKほとんど使わない
slice可変OK圧倒的によく使う
map可変OKキー値に非常によく使う

Array — 固定長 #

array 基本
var nums [3]int            // [0 0 0]
nums[0] = 10
nums[1] = 20
fmt.Println(nums)           // [10 20 0]

primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(len(primes))    // 5

長さが型の一部です。[3]int[5]int別の型です。

長さが違うと別の型
var a [3]int
var b [5]int
// a = b   ✗ 違う型

この制約のためarrayは一般的なデータ構造としては不便です。ほぼすべての場面でスライスを使います。 arrayに直接出会うのは普通次の場面です。

  • ハッシュ結果のような固定サイズ ([32]byte for SHA-256)
  • 非常に狭いパフォーマンス最適化

Slice — 可変長の真の主役 #

スライス基本
nums := []int{1, 2, 3, 4, 5}
fmt.Println(len(nums))     // 5

nums = append(nums, 6)      // 末尾に追加
fmt.Println(nums)           // [1 2 3 4 5 6]

[]int (角括弧の中に長さがない)がスライスの型。長さは実行時に決まり、自由に変わります。

作る — make #

make で作る
s1 := make([]int, 5)      // 長さ 5, すべて zero — [0 0 0 0 0]
s2 := make([]int, 0, 10)  // 長さ 0, 容量 10

3番目の引数は容量(capacity)。スライスの動作原理の核心です — もう少し後で説明します。

インデックスとスライシング #

切り出し
s := []int{10, 20, 30, 40, 50}

s[0]       // 10
s[1:3]     // [20 30] — [start, end)
s[:3]      // [10 20 30]
s[2:]      // [30 40 50]
s[:]       // 全体

Python/JSと似ています。開始は含み、終了は含まない

Sliceの動作原理 — 長さ vs 容量 #

これがGoのスライスを理解する核心です。

スライスは内部的に3つの部分で構成されます。

  1. ポインタ — 実際のデータ(配列)を指す
  2. 長さ (len) — 現在入っている要素数
  3. 容量 (cap) — データ空間のサイズ
len vs cap
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))   // 3 5

s = append(s, 100)
fmt.Println(len(s), cap(s))   // 4 5

s = append(s, 200)
fmt.Println(len(s), cap(s))   // 5 5

s = append(s, 300)
fmt.Println(len(s), cap(s))   // 6 10  ← cap 自動増加 (普通 2 倍)

容量が足りなければ — append新しい配列を確保してコピーします。普通2倍ずつ大きくなります。頻繁にappendする場面では、あらかじめcapを取っておくと効率的です。

cap を事前確保
result := make([]int, 0, 1000)   // 1000 までは再割り当てなし
for i := 0; i < 1000; i++ {
	result = append(result, i*i)
}

Sliceの罠 — 同じ配列を共有 #

スライスはポインタ + 長さ + 容量なので、2つのスライスが同じ配列を指すことがあります。

共有
a := []int{1, 2, 3, 4, 5}
b := a[1:4]              // [2 3 4]

b[0] = 999
fmt.Println(a)           // [1 999 3 4 5] ← a も変わる
fmt.Println(b)           // [999 3 4]

ba の一部に対するviewです。一方で変更したことが他方にも反映されます。

これがしばしば事故を起こします。本当のコピーが必要なら — copy または append のトリック。

本当のコピー
a := []int{1, 2, 3}

// copy
b := make([]int, len(a))
copy(b, a)

// append
c := append([]int{}, a...)

b[0] = 999
fmt.Println(a)   // [1 2 3] (影響なし)

よく使うスライスパターン #

よく使うパターン
s := []int{1, 2, 3, 4, 5}

// 末尾に追加
s = append(s, 6)
s = append(s, 7, 8, 9)             // 複数個

// 他のスライスと結合
s2 := []int{10, 20}
s = append(s, s2...)               // ... で展開

// 長さ
n := len(s)

// 空かどうか
isEmpty := len(s) == 0

// インデックス i の要素を削除 (順序維持)
i := 2
s = append(s[:i], s[i+1:]...)

// 順序無視で削除 (最後と swap 後に切り捨て)
s[i] = s[len(s)-1]
s = s[:len(s)-1]

nil スライス — 空スライスとほぼ同じ #

nil スライス
var s []int     // nil
fmt.Println(len(s))    // 0
fmt.Println(s == nil)  // true

s = append(s, 1)        // OK — append は nil を受け取っても良い

宣言だけで初期化していないとnil。しかし — appendlen などほぼすべての動作がnilスライスでも安全です。特に気にしなくて大丈夫です。

Map — キー値 #

map 基本
ages := map[string]int{
	"カーティス": 30,
	"アリス": 25,
}

ages["ボブ"] = 35           // 追加
fmt.Println(ages["カーティス"])   // 30
delete(ages, "アリス")     // 削除
fmt.Println(len(ages))     // 2

map[キー型]値型 で型を書きます。

作る — make #

空のmap
ages := make(map[string]int)
ages["カーティス"] = 30

// またはリテラル
empty := map[string]int{}

キー存在確認 — comma-ok #

最もよく使うパターン。

キー確認
ages := map[string]int{"カーティス": 30}

// 単純アクセス — なければ zero value
fmt.Println(ages["存在しないキー"])    // 0 (混乱の可能性)

// comma-ok パターン — 存在を確認
if age, ok := ages["カーティス"]; ok {
	fmt.Println("あり:", age)
} else {
	fmt.Println("なし")
}

map[キー] は2つの値を返すことができます — 値と存在の有無。値自体がzero valueなら単純アクセスでは区別できません。comma-okが標準です。

巡回 — 順序なし #

map 巡回
for name, age := range ages {
	fmt.Println(name, age)
}

Goのmap巡回は毎回違う順序です。意図的にrandomizeされます(コードが順序に依存しないよう強制)。ソートされた順序が必要なら、キーをスライスに集めてソートします。

ソートされた巡回
import "sort"

keys := make([]string, 0, len(ages))
for k := range ages {
	keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
	fmt.Println(k, ages[k])
}

nil map — 罠に注意 #

var m map[string]int だけ宣言するとnil mapで — 読み取りはOKだが書き込みはpanicします。

nil map の罠
var m map[string]int
fmt.Println(m["キー"])     // 0 (読み取り OK)
fmt.Println(len(m))      // 0 (OK)

m["キー"] = 1               // ✗ panic: assignment to entry in nil map

書き込み前に必ず make またはリテラルで初期化します。スライスのnil動作とは違う点です。

よく使うmapパターン #

map パターン
counts := make(map[string]int)

words := []string{"a", "b", "a", "c", "b", "a"}
for _, w := range words {
	counts[w]++              // 初回は zero (0) から +1
}
fmt.Println(counts)          // map[a:3 b:2 c:1]

キーがないとき自動でzero valueになる点がカウンティングに自然です。

Setを作る — map[T]struct #

Goにはset型がないので — 空のstructを値にしたmapで作ります。

空structでset
seen := make(map[string]struct{})

seen["a"] = struct{}{}
seen["b"] = struct{}{}

if _, ok := seen["a"]; ok {
	fmt.Println("すでにあり")
}

空のstructはメモリを0バイト占めます(なのでsetの用途で標準)。bool を値にしても動作は同じですが、メモリ面で空のstructの方が正確です。

コレクションを関数に渡すとき #

スライスは参照セマンティクス #

スライス仮引数
func modify(s []int) {
	s[0] = 999          // 呼び出し側にも見える
}

nums := []int{1, 2, 3}
modify(nums)
fmt.Println(nums)        // [999 2 3]

スライス自体(ヘッダ)は値でコピーされますが、中のポインタが同じ配列を指します。データの変更は呼び出し側に見えます。

ただし append の結果は違うことがあります — appendが新しい配列を確保すると、元のスライスヘッダは変わりません。

append の罠
func appendBad(s []int) {
	s = append(s, 100)    // 新しい配列の可能性 — 呼び出し側に見えない
}

nums := []int{1, 2, 3}
appendBad(nums)
fmt.Println(nums)         // [1 2 3] — 追加されない

// 推奨 — 結果を返す
func appendGood(s []int) []int {
	return append(s, 100)
}
nums = appendGood(nums)

mapは参照セマンティクス #

map 仮引数
func modify(m map[string]int) {
	m["new"] = 100         // 呼び出し側にも見える
}

mapはスライスよりさらにシンプルに参照セマンティクスです。関数内の変更が常に見えます。

まとめ #

今回の記事で整理した内容:

  • array — 固定長、ほとんど使わない
  • slice — 可変、ほぼすべてのコレクション用途
  • スライスはポインタ+長さ+容量。capが足りなければ新しい配列を確保
  • 同じ配列共有の罠 — copy または append([]T{}, ...) でコピー
  • nilスライスは安全、nil mapは書き込み時にpanic
  • map — map[キー]値、comma-okで存在確認
  • map巡回は毎回違う順序
  • setは map[T]struct{} で作る
  • スライス/mapは参照セマンティクス — 関数内の変更が呼び出し側に見える

次の記事(#6 構造体とメソッド)ではGoのユーザー定義型 — structとそれに紐づくメソッド、そしてポインタレシーバまで整理します。

X