Retour au blog

Le mot-clé map en Go : maps nil, ordre d'itération et concurrence

Le fonctionnement du mot-clé map en Go : littéraux et make, la panique sur une map nil, comma-ok, l'ordre d'itération aléatoire, les clés comparables, l'accès concurrent, les ensembles et le package maps.

Le mot-clé map en Go : maps nil, ordre d'itération et concurrence

Le mot-clé map déclare la table de hachage intégrée de Go. Le type map[K]V associe des clés de type K à des valeurs de type V, avec une recherche, une insertion et une suppression en temps constant en moyenne. Les maps font partie du langage, vous n'avez donc ni package à importer ni classe HashMap à instancier. Vous disposez d'une syntaxe littérale, des fonctions intégrées make, delete, clear et len, et de la prise en charge de for range (Go spec).

En bref

  • map[string]int{"/healthz": 1} ou make(map[string]int) crée une map. var m map[string]int vous donne une map nil.
  • Lire une map nil renvoie la valeur zéro. Y écrire panique avec assignment to entry in nil map.
  • Une clé absente renvoie la valeur zéro. Utilisez v, ok := m[k] quand vous devez savoir si la clé était présente.
  • L'ordre d'itération n'est pas spécifié et il est aléatoire. Triez les clés avec slices.Sorted(maps.Keys(m)) quand l'ordre compte.
  • Les clés doivent être comparables. Les strings, les nombres, les structs et les tableaux conviennent. Les slices, les maps et les fonctions, non.
  • Une valeur de map est un petit descripteur. La passer à une fonction permet à cette fonction de modifier la map de l'appelant.
  • m[k].Field = x ne compile pas quand la valeur est un struct. Copiez-la, modifiez-la et réenregistrez-la, ou stockez des pointeurs.
  • Les maps ne sont pas sûres pour les écritures concurrentes. Protégez-les avec un sync.Mutex ou utilisez sync.Map dans les cas pour lesquels il a été conçu.
  • map[string]struct{} est l'ensemble (set) de Go. Le package maps (Go 1.21+) ajoute Clone, Equal, DeleteFunc, Keys et Collect.

Comment déclarer et créer une map en Go ?

Un type map s'écrit map[KeyType]ValueType. Vous créez une map avec un littéral composite quand vous connaissez déjà quelques entrées, ou avec make quand vous partez d'une map vide :

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"]++ fonctionne sur une clé qui n'existe pas encore. La lecture renvoie 0, l'incrément donne 1, et l'écriture l'enregistre. Un compteur ne demande aucune préparation.

make accepte une indication de taille facultative, comme dans make(map[string]int, len(requests)). Le runtime réserve de la place pour environ ce nombre d'entrées, si bien que la map n'a pas besoin de grandir pendant que vous la remplissez. Cette indication n'est pas une limite. La map continue de grandir au-delà. len(m) renvoie le nombre d'entrées, et cap ne fonctionne pas sur les maps.

var vs make en Go compare les deux façons d'obtenir une map vide, et explique pourquoi var seul ne suffit pas.

Pourquoi écrire dans une map nil provoque-t-il une panique ?

La valeur zéro d'un type map est nil. Une map nil n'a aucune table de hachage derrière elle, il n'y a donc nulle part où placer une nouvelle entrée. Le cas typique est un champ map dans un struct que personne n'a initialisé :

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

La lecture fonctionne. Une map nil se comporte comme une map vide pour les recherches, len, range et delete, donc la première ligne s'affiche normalement. Seule l'écriture panique. La correction consiste à créer la map avant la première écriture, généralement dans un constructeur :

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

Un décodeur comme json.Unmarshal alloue la map pour vous lorsqu'il rencontre un objet JSON, donc une cible map[string]any peut valoir nil au départ. Votre propre code doit appeler make ou utiliser un littéral.

Comment vérifier si une clé existe dans une map Go ?

Indexer une map avec une clé absente renvoie la valeur zéro du type des valeurs. Pour un compteur, c'est exactement ce qu'il faut, puisqu'un chemin que personne n'a demandé a 0 hits. Pour d'autres valeurs, la valeur zéro masque une vraie différence. Une map de feature flags renvoie false pour un flag désactivé, mais aussi pour un flag jamais défini.

La forme comma-ok renvoie un second booléen qui indique si la clé était présente :

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 ne vaut false que pour beta_search. Sans lui, une faute de frappe dans le nom d'un flag désactiverait la fonctionnalité sans rien dire. Utilisez la forme simple quand la valeur zéro est une réponse correcte, comme pour les comptages et les sommes. Utilisez comma-ok quand « absent » ne veut pas dire la même chose que « zéro ».

Comment supprimer des clés et vider une map ?

delete(m, k) supprime une clé. Supprimer une clé absente ne fait rien et ne renvoie pas d'erreur. clear(m), ajouté dans Go 1.21, supprime toutes les entrées et garde la map allouée (notes de version de 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

Après clear, la map est vide mais reste utilisable, et chaque variable qui y fait référence voit la map vide. Affecter cache = map[int]string{} à la place ne donne une nouvelle map qu'à cette seule variable.

Supprimer des clés pendant un range sur la même map est autorisé. La spec indique qu'une entrée supprimée pendant l'itération ne sera pas produite plus tard dans la boucle (Go spec). Ajouter des clés pendant l'itération est aussi autorisé, mais une nouvelle entrée peut apparaître ou non dans la même boucle.

Pourquoi l'ordre d'itération d'une map est-il aléatoire en Go ?

La spec indique que l'ordre d'itération sur une map n'est pas spécifié et qu'il peut changer d'une itération à l'autre. Le runtime va plus loin et choisit un point de départ aléatoire à chaque range. Trois exécutions de la même boucle sur hits ont affiché :

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

Cet aléa est voulu. Les premières versions de Go avaient un ordre qui semblait stable pour les petites maps, et des programmes ont commencé à en dépendre. Quand l'implémentation a changé, ces programmes ont cassé. Rendre l'ordre aléatoire fait apparaître le bug dans vos tests plutôt qu'en production (Go blog, Go maps in action).

Quand vous avez besoin d'un ordre stable, pour une ligne de log, un rapport ou un test, triez les clés. Depuis Go 1.23, maps.Keys renvoie un itérateur, et slices.Sorted le collecte et le trie en un seul appel :

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

fmt.Println sur une map entière affiche déjà les clés triées, c'est pourquoi la sortie de fmt.Println(cache) plus haut est stable. encoding/json trie aussi les clés de map lors de la sérialisation. Seul range est aléatoire.

Quels types peuvent servir de clés de map en Go ?

Un type de clé doit être comparable, c'est-à-dire que == et != sont définis pour lui. Les strings, les entiers, les flottants, les booléens, les pointeurs, les channels et les interfaces sont comparables. C'est aussi le cas des structs et des tableaux dont tous les champs ou éléments sont comparables. Les slices, les maps et les fonctions ne le sont pas, et le compilateur les rejette :

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

Les clés struct sont utiles quand la clé a plusieurs parties. Ici, la latence de chaque endpoint est indexée à la fois par méthode et par chemin :

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"}])

Ce code affiche 112. Le struct est comparé champ par champ, donc deux valeurs Endpoint avec la même méthode et le même chemin retrouvent la même entrée. Cela évite l'ancienne astuce qui consiste à construire une clé string comme "POST /api/orders" pour la redécouper plus tard. Pour indexer par une liste d'identifiants, convertissez la slice en tableau de taille fixe ou joignez-la en une string.

Les clés interface comme map[any]int compilent, mais insérer une slice à l'exécution panique avec runtime error: hash of unhashable type []string. Les clés flottantes fonctionnent mais sont une mauvaise idée. NaN n'est pas égal à lui-même, donc chaque m[math.NaN()] = 1 ajoute une nouvelle entrée que vous ne pourrez plus jamais retrouver.

Les maps sont-elles passées par référence en Go ?

Go passe tout par valeur, maps comprises. La valeur d'une variable map est un petit descripteur qui pointe vers la table de hachage, donc la copier copie le pointeur et non les entrées. Une fonction qui reçoit une map peut modifier les entrées de l'appelant. Une fonction qui affecte une nouvelle map à son paramètre ne modifie que sa propre copie :

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 a écrit dans la table partagée, les deux comptages sont donc présents. resetCounts a remplacé sa variable locale et n'a eu aucun effet sur l'appelant. Pour vider la map de l'appelant, appelez clear(counts) dans la fonction. go vet ne signale pas ce cas, donc la seconde fonction compile et ne fait rien, sans le moindre avertissement.

Le même partage s'applique aux champs de struct et aux valeurs de retour. Un getter qui renvoie une map interne permet aux appelants de modifier votre état. Renvoyez maps.Clone(m) quand les appelants doivent obtenir leur propre copie.

Pourquoi ne peut-on pas affecter un champ de struct dans une map ?

Une valeur de map n'est pas adressable. Le runtime peut déplacer les entrées quand la table grandit, donc Go refuse de vous donner une référence vers une valeur stockée à l'intérieur. Ce code ne compile pas :

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

Il existe deux corrections. Copiez la valeur, modifiez-la et réenregistrez-la. Ou stockez des pointeurs, pour que la map contienne des adresses et que le struct vive hors de la table :

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

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

Les deux laissent Requests à 1. La forme copier-réenregistrer fait de la map l'unique propriétaire des données. La forme pointeur est plus courte à mettre à jour et évite de copier de gros structs, mais une clé absente vous donne un pointeur nil, donc byToken["s_404"].Requests++ panique. Vérifiez d'abord avec comma-ok. C'est la même règle qui vous empêche d'écrire &sessions["s_91"].

Les maps Go sont-elles sûres en accès concurrent ?

Non. Les lectures concurrentes ne posent pas de problème, mais une écriture concurrente avec toute autre lecture ou écriture est une data race. Le runtime détecte une partie de ces races et arrête tout le programme avec une erreur fatale que recover ne peut pas intercepter. Huit goroutines qui incrémentent une map de compteurs partagée plantent tout de suite :

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

Une lecture en concurrence avec une écriture donne fatal error: concurrent map read and map write. Dans un serveur HTTP, cela se produit dès que deux requêtes touchent une map partagée, puisque chaque requête s'exécute dans sa propre goroutine. Lancez vos tests avec go test -race pour repérer les races que la vérification du runtime laisse passer.

La correction habituelle consiste à placer la map et un sync.Mutex dans un même type, et à n'accéder à la map qu'à travers des méthodes qui verrouillent :

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]
}

Les mêmes huit goroutines se terminent désormais, et c.Get("/api/orders") renvoie 80000. Utilisez sync.RWMutex avec RLock dans Get quand les lectures sont bien plus nombreuses que les écritures et que chaque lecture prend un peu de temps.

sync.Map est une map concurrente de la bibliothèque standard. Sa documentation indique qu'elle est optimisée pour deux cas : des clés écrites une fois et lues de nombreuses fois, comme un cache qui ne fait que grandir, et des goroutines qui travaillent sur des ensembles de clés disjoints (documentation de sync.Map). En dehors de ces cas, une map ordinaire avec un mutex est généralement plus simple et tout aussi rapide, et elle conserve vos types de clé et de valeur au lieu de any.

Comment créer un ensemble (set) en Go ?

Go n'a pas de type ensemble. L'idiome est une map dont la valeur est un struct vide. struct{} occupe zéro octet, donc la map ne stocke que les clés. Ce code dédoublonne des requêtes entrantes par identifiant :

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 fonctionne aussi et se lit un peu plus facilement, puisque if seen[id] n'a pas besoin de comma-ok. Cela coûte un octet par entrée et autorise un troisième état, une clé stockée avec false. Choisissez l'un des deux pour toute la base de code. Le mot-clé struct en Go présente struct{} et son autre usage comme signal sur les channels.

À quoi sert le package maps ?

Le package maps est arrivé dans Go 1.21 avec des fonctions génériques, et Go 1.23 y a ajouté des fonctions d'itération (pkg.go.dev/maps). Voici celles qui reviennent le plus souvent :

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 fait une copie superficielle. Modifier cfg a laissé defaults intact. Les valeurs elles-mêmes ne sont pas copiées en profondeur, donc une map de slices partage toujours les slices.
  • maps.Equal compare les clés et les valeurs avec ==. Vous ne pouvez pas comparer deux maps directement avec ==, car une map ne peut être comparée qu'à nil.
  • maps.DeleteFunc supprime toutes les entrées qui satisfont un prédicat.
  • maps.Keys, maps.Values et maps.All renvoient des itérateurs. Passez-les à slices.Sorted, slices.Collect ou à une boucle for range.
  • maps.Collect construit une map à partir de n'importe quel itérateur clé-valeur. Ici, il transforme une slice en map indice-valeur. maps.Copy(dst, src) fusionne une map dans une autre.

Comment les maps Go sont-elles implémentées ?

Depuis Go 1.24, la map intégrée utilise une conception Swiss Table, une table de hachage à adressage ouvert qui examine des groupes d'emplacements d'un coup. Les notes de version font état d'un coût CPU plus faible pour les opérations sur les maps (notes de version de Go 1.24). Le langage n'a pas changé, donc le code écrit pour les anciennes maps à buckets fonctionne comme avant.

Les maps ne rétrécissent toujours pas. Supprimer des entrées libère les emplacements pour qu'ils soient réutilisés, mais conserve la mémoire. Un cache qui a contenu un million de sessions garde cette mémoire après leur suppression. clear ne la libère pas non plus. Si une map à longue durée de vie connaît un pic puis redescend, copiez les entrées restantes dans une nouvelle map avec maps.Clone ou un nouveau make, et laissez le garbage collector récupérer l'ancienne.

La place de LevelUpGo

LevelUpGo enseigne Go à travers des exercices qui exécutent du vrai code Go dans le navigateur. Composite Types couvre les maps aux côtés des slices et des structs, notamment les recherches comma-ok, le comptage, la suppression et l'itération dans un ordre stable. Concurrency Fundamentals couvre les goroutines, les WaitGroups et les mutex dont vous avez besoin dès qu'une map est partagée entre elles. Pour les 24 autres mots réservés, consultez Les mots-clés Go : les 25 expliqués.

Questions fréquentes

map est-il un mot-clé en Go ?

Oui. map fait partie des 25 mots-clés réservés de Go, vous ne pouvez donc pas l'utiliser comme nom de variable ou de fonction. Il apparaît dans les types map comme map[string]int, dans les littéraux de map et comme argument de make.

Une map Go est-elle ordonnée ?

Non. La spec laisse l'ordre d'itération non spécifié, et le runtime le rend aléatoire à chaque range. Pour traiter les clés dans l'ordre, triez-les avec slices.Sorted(maps.Keys(m)). fmt.Println et json.Marshal affichent déjà les clés de map triées.

Les maps Go sont-elles thread-safe ?

Non. Les lectures concurrentes sont sûres, mais toute écriture en parallèle d'une autre lecture ou écriture peut faire planter le programme avec fatal error: concurrent map writes. Protégez la map avec un sync.Mutex ou un sync.RWMutex, ou utilisez sync.Map pour les caches écrits une seule fois et les ensembles de clés disjoints.

Comment obtenir la longueur d'une map en Go ?

Appelez len(m). Cette fonction renvoie le nombre d'entrées et fonctionne sur une map nil, où elle renvoie 0. Les maps ne prennent pas en charge cap.

Go a-t-il un type ensemble ?

Non. Utilisez map[T]struct{}, qui ne stocke que les clés, et testez l'appartenance avec _, ok := set[k]. map[T]bool fonctionne aussi si vous préférez if set[k] à comma-ok.

Que se passe-t-il quand on lit une clé qui n'existe pas ?

Vous obtenez la valeur zéro du type des valeurs, comme 0, "", false ou nil, et aucune erreur. Utilisez la forme à deux valeurs v, ok := m[k] quand vous devez distinguer une clé absente d'une valeur zéro stockée.

Sources

Écrivez du Go comme un ingénieur senior

Des leçons interactives dans votre navigateur. Les premières sont gratuites.

Essayer une leçon gratuiteOu créer un compte gratuit