ブログに戻る

Go の map キーワード:nil の map、反復順序、並行処理

Go の map キーワードの仕組みを解説します。リテラルと make の違い、nil の map の panic、comma-ok、ランダムな反復順序、比較可能なキー、並行アクセス、set、maps パッケージを扱います。

Go の map キーワード:nil の map、反復順序、並行処理

map キーワードは、Go に組み込まれたハッシュテーブルを宣言します。map[K]V 型は K 型のキーを V 型の値に対応付け、検索、挿入、削除を平均して定数時間で行います。map は言語に組み込まれているので、パッケージをインポートしたり HashMap クラスをインスタンス化したりする必要はありません。リテラル構文、組み込み関数の make、delete、clear、len、そして for range のサポートが最初から用意されています(Go spec)。

要約

  • map[string]int{"/healthz": 1} または make(map[string]int) で map を作れます。var m map[string]int と書くと nil の map になります。
  • nil の map を読むとゼロ値が返ります。書き込むと assignment to entry in nil map で panic します。
  • 存在しないキーはゼロ値を返します。キーがあったかどうかを知りたいときは v, ok := m[k] を使います。
  • 反復順序は規定されておらず、ランダム化されています。順序が重要なときは slices.Sorted(maps.Keys(m)) でキーをソートします。
  • キーは比較可能でなければなりません。文字列、数値、struct、配列は使えます。slice、map、関数は使えません。
  • map の値は小さな記述子です。関数に渡すと、その関数は呼び出し側の map を書き換えられます。
  • 値が struct の場合、m[k].Field = x はコンパイルできません。コピーして書き換えてから格納し直すか、ポインタを格納します。
  • map は並行書き込みに対して安全ではありません。sync.Mutex で保護するか、sync.Map が想定しているケースではそれを使います。
  • map[string]struct{} が Go の set です。maps パッケージ(Go 1.21 以降)は Clone、Equal、DeleteFunc、Keys、Collect を提供します。

Go で map を宣言して作成するには?

map 型は map[KeyType]ValueType と書きます。最初から入れておくエントリーがわかっているときは複合リテラルで、空の状態から始めるときは make で作成します。

example.gogo
package main

import "fmt"

func main() {
	statusText := map[int]string{
		200: "OK",
		404: "Not Found",
		503: "Service Unavailable",
	}

	hits := make(map[string]int)
	hits["/api/orders"]++
	hits["/api/orders"]++
	hits["/healthz"]++

	fmt.Println(statusText[404], len(statusText))
	fmt.Println(hits["/api/orders"], hits["/healthz"], len(hits))
}
example.texttext
Not Found 3
2 1 2

hits["/api/orders"]++ は、まだ存在しないキーに対しても動きます。読み取りで 0 が返り、インクリメントで 1 になり、それが書き込まれます。カウンターに準備は要りません。

make には make(map[string]int, len(requests)) のように、省略可能なサイズのヒントを渡せます。ランタイムはおよそその数のエントリー分の領域を確保するので、値を詰めている間に map が拡張されずに済みます。ヒントは上限ではなく、map はそれを超えて拡張されます。len(m) はエントリー数を返します。cap は map には使えません。

空の map を得る 2 つの方法の違いと、map では var だけでは不十分な理由は Go の var と make の違いで比較しています。

nil の map に書き込むと panic するのはなぜか?

map 型のゼロ値は nil です。nil の map の裏にはハッシュテーブルがないので、新しいエントリーを置く場所がありません。これはたいてい、誰も初期化していない struct の map フィールドとして現れます。

example.gogo
package main

import "fmt"

type Router struct {
	routes map[string]string
}

func main() {
	var r Router
	fmt.Println(r.routes["/health"] == "", len(r.routes))
	r.routes["/health"] = "healthHandler"
}
example.texttext
true 0
panic: assignment to entry in nil map

読み取りは動きます。nil の map は、検索、len、range、delete に対しては空の map と同じように振る舞うので、1 行目は普通に出力されます。panic するのは書き込みだけです。最初の書き込みより前に map を作成すれば直ります。普通はコンストラクターの中で作ります。

example.gogo
func NewRouter() *Router {
	return &Router{routes: make(map[string]string)}
}

json.Unmarshal のようなデコーダーは、JSON オブジェクトを見つけると map を確保してくれるので、map[string]any のデコード先は nil のままで構いません。自分のコードでは make を呼ぶか、リテラルを使う必要があります。

Go の map にキーが存在するか確認するには?

存在しないキーで map をインデックスすると、値の型のゼロ値が返ります。カウンターならこれで問題ありません。誰もリクエストしていないパスのヒット数は 0 です。しかしほかの値では、ゼロ値が本当の違いを隠してしまいます。機能フラグの map は、オフのフラグにも、一度も定義されていないフラグにも false を返します。

comma-ok の形では、キーが存在したかどうかを表す真偽値が 2 つ目の戻り値として返ります。

example.gogo
package main

import "fmt"

func main() {
	flags := map[string]bool{
		"new_checkout": true,
		"dark_mode":    false,
	}

	for _, name := range []string{"new_checkout", "dark_mode", "beta_search"} {
		enabled, ok := flags[name]
		if !ok {
			fmt.Printf("%s: unknown flag\n", name)
			continue
		}
		fmt.Printf("%s: %v\n", name, enabled)
	}
}
example.texttext
new_checkout: true
dark_mode: false
beta_search: unknown flag

ok が false になるのは beta_search だけです。これがないと、フラグ名のタイプミスで機能が黙ってオフになってしまいます。件数や合計のようにゼロ値がそのまま正しい答えになるなら、普通の形で足ります。「存在しない」と「ゼロ」で意味が変わるなら comma-ok を使います。

map のキーを削除し、map を空にするには?

delete(m, k) はキーを 1 つ削除します。存在しないキーを削除しても何も起きず、エラーにはなりません。Go 1.21 で追加された clear(m) は、すべてのエントリーを削除し、map の領域は確保したままにします(Go 1.21 リリースノート)。

example.gogo
cache := map[int]string{42: "[email protected]", 7: "[email protected]"}

delete(cache, 42)
delete(cache, 999)
fmt.Println(len(cache), cache)

clear(cache)
fmt.Println(len(cache), cache == nil)
example.texttext
1 map[7:[email protected]]
0 false

clear の後、map は空ですが引き続き使えます。そして、その map を参照しているすべての変数から空の map が見えます。代わりに cache = map[int]string{} と代入すると、新しい map を得るのはその変数だけです。

同じ map を range で回しながらキーを削除することは許されています。仕様では、反復中に削除されたエントリーは、そのループの後半で返されないと定められています(Go spec)。反復中にキーを追加することも許されていますが、新しいエントリーが同じループに現れるかどうかは決まっていません。

Go で map の反復順序がランダムなのはなぜか?

仕様では、map の反復順序は規定されておらず、反復のたびに同じになるとは保証されないと定められています。実際のランタイムは、range のたびにランダムな開始位置を選びます。hits に対する同じループを 3 回実行すると、次のように出力されました。

example.texttext
/api/orders 41 /healthz 120 /api/users 17
/api/orders 41 /healthz 120 /api/users 17
/api/users 17 /api/orders 41 /healthz 120

このランダム化は意図的なものです。初期の Go では、小さな map の順序が安定しているように見え、その順序に依存するプログラムが現れました。実装が変わると、そうしたプログラムは壊れました。順序をランダム化することで、このバグは本番環境ではなくテストで表に出るようになります(Go blog, Go maps in action)。

ログの 1 行、レポート、テストなどで安定した順序が必要なときは、キーをソートします。Go 1.23 からは maps.Keys がイテレーターを返し、slices.Sorted がそれを 1 回の呼び出しで集めてソートします。

example.gogo
package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	hits := map[string]int{"/api/orders": 41, "/healthz": 120, "/api/users": 17}

	for _, path := range slices.Sorted(maps.Keys(hits)) {
		fmt.Printf("%-12s %d\n", path, hits[path])
	}
}
example.texttext
/api/orders  41
/api/users   17
/healthz     120

map 全体を fmt.Println で出力すると、キーはもともとソートされた順序で表示されます。先ほどの fmt.Println(cache) の出力が安定していたのはこのためです。encoding/json もマーシャル時に map のキーをソートします。ランダム化されるのは range だけです。

Go の map のキーにはどんな型を使えるのか?

キーの型は比較可能でなければなりません。つまり、その型に == と != が定義されている必要があります。文字列、整数、浮動小数点数、真偽値、ポインタ、チャネル、interface は比較可能です。すべてのフィールドや要素が比較可能な struct と配列も同様です。slice、map、関数は比較可能ではなく、コンパイラが拒否します。

example.gogo
seen := map[[]string]bool{}
example.texttext
./main.go:4:14: invalid map key type []string

struct のキーは、キーが複数の要素から成るときに便利です。次の例では、各エンドポイントのレイテンシーをメソッドとパスの組み合わせで管理しています。

example.gogo
type Endpoint struct {
	Method string
	Path   string
}

latencyMs := map[Endpoint]int{
	{"GET", "/api/orders"}:  38,
	{"POST", "/api/orders"}: 112,
}
fmt.Println(latencyMs[Endpoint{"POST", "/api/orders"}])

このコードは 112 と出力します。struct はフィールドごとに比較されるので、メソッドとパスが同じ 2 つの Endpoint の値は同じエントリーを見つけます。"POST /api/orders" のようなキー文字列を組み立て、後でまた分割する手間はかかりません。ID のリストをキーにしたいときは、slice を固定長の配列に変換するか、1 つの文字列に連結します。

map[any]int のような interface のキーはコンパイルできますが、実行時に slice を挿入すると runtime error: hash of unhashable type []string で panic します。浮動小数点数のキーは使えますが、おすすめしません。NaN は自分自身と等しくないので、m[math.NaN()] = 1 を実行するたびに、二度と検索できない新しいエントリーが追加されます。

Go の map は参照渡しなのか?

Go はすべてを値渡しにし、map も例外ではありません。map 変数の値はハッシュテーブルを指す小さな記述子なので、それをコピーするとポインタがコピーされるだけで、エントリーはコピーされません。map を受け取った関数は、呼び出し側のエントリーを書き換えられます。一方、引数に新しい map を代入する関数は、自分のコピーを変えるだけです。

example.gogo
func recordStatus(counts map[int]int, status int) {
	counts[status]++
}

func resetCounts(counts map[int]int) {
	counts = map[int]int{}
}

func main() {
	counts := map[int]int{}
	recordStatus(counts, 200)
	recordStatus(counts, 500)
	resetCounts(counts)
	fmt.Println(counts)
}
example.texttext
map[200:1 500:1]

recordStatus は共有されたテーブルを通して書き込んだので、2 つのカウントが両方残っています。resetCounts は自分のローカル変数を置き換えただけで、呼び出し側には影響しませんでした。呼び出し側の map を空にするには、関数の中で clear(counts) を呼びます。go vet はこれを報告しないので、2 つ目の関数はエラーなくコンパイルされ、実際には何もしません。

struct のフィールドや戻り値でも、同じように共有が起きます。内部の map をそのまま返すゲッターがあると、呼び出し側がその内部状態を書き換えられます。呼び出し側に独自のコピーを渡したいときは maps.Clone(m) を返します。

map の中の struct のフィールドに代入できないのはなぜか?

map の値はアドレス指定可能ではありません。テーブルが拡張されるとランタイムがエントリーを移動することがあるので、Go は map の中に格納された値への参照を渡しません。そのため、次のコードはコンパイルできません。

example.gogo
type Session struct {
	UserID   int
	Requests int
}

sessions := map[string]Session{"s_91": {UserID: 42}}
sessions["s_91"].Requests++
example.texttext
./main.go:10:2: cannot assign to struct field sessions["s_91"].Requests in map

直し方は 2 つあります。値をコピーして取り出し、書き換えてから格納し直す方法と、ポインタを格納する方法です。後者では map はアドレスを保持し、struct はテーブルの外に置かれます。

example.gogo
s := sessions["s_91"]
s.Requests++
sessions["s_91"] = s

byToken := map[string]*Session{"s_91": {UserID: 42}}
byToken["s_91"].Requests++

どちらの方法でも Requests は 1 になります。コピーして格納し直す形では、map がデータの唯一の所有者であり続けます。ポインタの形は更新が短く書け、大きな struct のコピーも避けられますが、存在しないキーでは nil ポインタが返るので、byToken["s_404"].Requests++ は panic します。先に comma-ok で確認してください。&sessions["s_91"] と書けないのも同じルールによるものです。

Go の map は並行処理で安全に使えるのか?

使えません。並行した読み取りは問題ありませんが、ほかの読み取りや書き込みと並行した書き込みはデータ競合になります。ランタイムはこうした競合の一部を検出し、recover では捕捉できない fatal error でプログラム全体を停止させます。共有のカウンター map をインクリメントする 8 つの goroutine は、あっという間にクラッシュします。

example.gogo
hits := map[string]int{}
var wg sync.WaitGroup
for range 8 {
	wg.Go(func() {
		for range 10000 {
			hits["/api/orders"]++
		}
	})
}
wg.Wait()
example.texttext
fatal error: concurrent map writes

読み取りと書き込みが競合すると fatal error: concurrent map read and map write になります。HTTP サーバーでは各リクエストがそれぞれの goroutine で実行されるので、2 つのリクエストが共有の map に触れた時点でこれが起きます。ランタイムの検出で見逃される競合を捕まえるには、go test -race でテストを実行します。

よくある直し方は、map と sync.Mutex を 1 つの型にまとめ、ロックを取るメソッドを通してのみ map に触れるようにすることです。

example.gogo
type HitCounter struct {
	mu   sync.Mutex
	hits map[string]int
}

func NewHitCounter() *HitCounter {
	return &HitCounter{hits: make(map[string]int)}
}

func (c *HitCounter) Inc(path string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.hits[path]++
}

func (c *HitCounter) Get(path string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.hits[path]
}

同じ 8 つの goroutine が今度は最後まで終わり、c.Get("/api/orders") は 80000 を返します。読み取りが書き込みよりはるかに多く、各読み取りがそれなりの処理を行う場合は、sync.RWMutex を使い、Get では RLock を呼びます。

sync.Map は標準ライブラリにある並行 map です。ドキュメントによると、最適化されているのは 2 つのケースです。1 つは、一度書き込まれて何度も読まれるキー(増えるだけのキャッシュなど)で、もう 1 つは、各 goroutine が互いに重ならないキーの集合を扱う場合です(sync.Map のドキュメント)。それ以外のケースでは、通常の map と mutex の組み合わせのほうがたいていシンプルで速度も変わらず、キーと値の型も any にならずに済みます。

Go で set を作るには?

Go には set 型がありません。慣用的な方法は、値に空の struct を使う map です。struct{} は 0 バイトなので、map はキーだけを保持します。次の例では、受け取ったリクエストを ID で重複排除しています。

example.gogo
requestIDs := []string{"req_7f", "req_a1", "req_7f", "req_c3", "req_a1"}

seen := make(map[string]struct{}, len(requestIDs))
for _, id := range requestIDs {
	if _, dup := seen[id]; dup {
		fmt.Println("duplicate, skipping", id)
		continue
	}
	seen[id] = struct{}{}
}
fmt.Println(len(seen), "unique requests")
example.texttext
duplicate, skipping req_7f
duplicate, skipping req_a1
3 unique requests

map[string]bool でも同じことができ、if seen[id] と書けて comma-ok が要らないぶん、少し読みやすくなります。ただし、エントリーごとに 1 バイト余分にかかり、false で格納されたキーという 3 つ目の状態も生まれます。コードベースごとにどちらか一方に統一しましょう。struct{} と、チャネルでのシグナルとしてのもう 1 つの使い方は、Go の struct キーワードで解説しています。

maps パッケージは何をするのか?

maps パッケージはジェネリックなヘルパーとともに Go 1.21 で登場し、Go 1.23 でイテレーター関数が追加されました(pkg.go.dev/maps)。よく出てくるのは次の関数です。

example.gogo
defaults := map[string]string{"region": "eu-west-1", "log_level": "info"}

cfg := maps.Clone(defaults)
cfg["log_level"] = "debug"
cfg["debug_token"] = "tok_123"

fmt.Println(maps.Equal(cfg, defaults))

maps.DeleteFunc(cfg, func(k, v string) bool {
	return strings.HasPrefix(k, "debug_")
})
fmt.Println(slices.Sorted(maps.Keys(cfg)))
fmt.Println(defaults["log_level"])

roles := maps.Collect(slices.All([]string{"admin", "editor", "viewer"}))
fmt.Println(roles[1])
example.texttext
false
[log_level region]
info
editor
  • maps.Clone は浅いコピーを作ります。cfg を変更しても defaults はそのままでした。値そのものはディープコピーされないので、slice を値に持つ map では slice が共有されたままになります。
  • maps.Equal はキーと値を == で比較します。map は nil としか比較できないので、2 つの map を == で直接比較することはできません。
  • maps.DeleteFunc は、述語に一致するすべてのエントリーを削除します。
  • maps.Keys、maps.Values、maps.All はイテレーターを返します。slices.Sorted、slices.Collect、または for range ループに渡して使います。
  • maps.Collect は、任意のキーと値のイテレーターから map を作ります。この例では、slice をインデックスから値への map に変換しています。maps.Copy(dst, src) は、ある map を別の map にマージします。

Go の map はどう実装されているのか?

Go 1.24 から、組み込みの map は Swiss Table という設計を採用しています。これは、複数のスロットをまとめて調べるオープンアドレス法のハッシュテーブルです。リリースノートによると、map 操作の CPU オーバーヘッドが減りました(Go 1.24 リリースノート)。言語自体は変わっていないので、以前のバケット方式の map を前提に書いたコードもこれまでどおり動きます。

map が縮小しないのは今も同じです。エントリーを削除するとスロットは再利用できるようになりますが、メモリは保持されたままです。かつて 100 万件のセッションを保持していたキャッシュは、それらを削除した後もそのメモリを持ち続けます。clear でも解放されません。長期間使う map のサイズが一時的に膨らんでから減った場合は、残ったエントリーを maps.Clone や新しく make した map にコピーし、古い map はガベージコレクションに回収させます。

LevelUpGo で学ぶ

LevelUpGo では、ブラウザ上で本物の Go コードを実行する演習を通じて Go を学べます。Composite Types では、slice や struct と並んで map を扱い、comma-ok による検索、カウント、削除、安定した順序での反復を学びます。Concurrency Fundamentals では、goroutine、WaitGroup、そして map を goroutine 間で共有するときに必要になる mutex を扱います。残り 24 個の予約語については、Go のキーワード一覧:全 25 個の予約語を解説をご覧ください。

よくある質問

Go で map はキーワードですか?

はい。map は Go の 25 個の予約語の 1 つなので、変数名や関数名に map は使えません。map[string]int のような map 型、map リテラル、make の引数に登場します。

Go の map は順序付きですか?

いいえ。仕様では反復順序は規定されておらず、ランタイムは range のたびに順序をランダム化します。キーを順番に処理するには、slices.Sorted(maps.Keys(m)) でソートします。fmt.Println と json.Marshal は、もともと map のキーをソートして出力します。

Go の map はスレッドセーフですか?

いいえ。並行した読み取りは安全ですが、ほかの読み取りや書き込みと同時に書き込みが行われると、fatal error: concurrent map writes でプログラムがクラッシュすることがあります。sync.Mutex または sync.RWMutex で map を保護するか、一度だけ書き込むキャッシュや互いに重ならないキーの集合には sync.Map を使ってください。

Go で map の長さを取得するには?

len(m) を呼びます。エントリー数を返し、nil の map にも使えます。その場合は 0 を返します。map は cap をサポートしていません。

Go に set 型はありますか?

ありません。キーだけを保持する map[T]struct{} を使い、_, ok := set[k] で要素が含まれるかを確認します。comma-ok より if set[k] のほうが好みなら、map[T]bool も使えます。

存在しないキーを読むとどうなりますか?

0、""、false、nil といった値の型のゼロ値が返り、エラーにはなりません。存在しないキーと格納されたゼロ値を区別したいときは、2 値の形 v, ok := m[k] を使ってください。

出典

シニアエンジニアのように Go を書く

ブラウザで学べるインタラクティブなレッスン。最初のレッスンは無料です。

無料レッスンを試すまたは無料アカウントを作成