Blog

Maps en Go: maps nil, orden de iteración y comma-ok

Un map de Go guarda valores bajo claves y los encuentra con un hash. Aprende por qué una clave que falta da el valor cero, por qué escribir en un map nil provoca un panic y por qué el orden de iteración cambia en cada ejecución.

Un map es la forma en que Go busca algo por nombre. Le das una clave, te devuelve el valor guardado bajo esa clave, y lo hace rápido sin importar cuántas entradas tenga.

Los maps son fáciles de usar, pero tienen unas cuantas reglas que sorprenden: una clave que falta no es un error, un map nil se puede leer pero no escribir, y el orden en que recibes las cosas nunca es el orden en que las pusiste. Este post cubre todas, además del paquete maps. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Crear, leer, escribir y borrar

Un tipo map se escribe map[K]V, donde K es el tipo de la clave y V es el tipo del valor. La forma más rápida de crear uno es con un literal:

package main

import "fmt"

func main() {
	stock := map[string]int{
		"apples": 5,
		"bread":  2,
	}
	stock["cheese"] = 7 // add a key
	stock["apples"] = 4 // replace a value
	delete(stock, "bread")
	delete(stock, "figs") // not there, so nothing happens

	fmt.Println(stock["apples"], stock["cheese"], len(stock))
	fmt.Println(stock)
}

Imprime:

4 7 2
map[apples:4 cheese:7]

m[key] = value agrega la clave si es nueva y reemplaza el valor si no lo es. No hay un «insertar» y un «actualizar» por separado. delete elimina una clave, y borrar una clave que no está no causa ningún problema. len te dice cuántas claves tiene el map.

La última línea parece ordenada, pero no saques conclusiones de eso. fmt ordena las claves de un map antes de imprimirlo, así que la salida es estable. El map en sí no guarda ningún orden, como muestra una sección más abajo.

También puedes crear un map con make y darle una pista de tamaño:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	a := map[string]int{}
	b := make(map[string]int)
	c := make(map[string]int, 1000)
	fmt.Println(len(a), len(b), len(c))

	for i := range 1000 {
		c[strconv.Itoa(i)] = i
	}
	fmt.Println(len(c), c["999"])
}

Imprime:

0 0 0
1000 999

a y b son lo mismo: un map vacío, listo para usar. c también está vacío. El 1000 no es una longitud. Es una pista de que vienen unas 1000 entradas, para que el runtime reserve suficiente espacio desde el principio en lugar de hacer crecer el map varias veces mientras lo llenas.

A diferencia de un slice, un map no tiene una capacidad que puedas consultar. cap no acepta un map, y un map crece solo a medida que agregas claves. Nunca escribes m = add(m, ...) como escribes s = append(s, x).

Una clave que falta te da el valor cero

Leer una clave que no está en un map de Go no falla. Recibes el valor cero del tipo del valor, y ahí empiezan la mayoría de los bugs con maps:

package main

import "fmt"

func main() {
	stock := map[string]int{"apples": 5, "bread": 0}
	fmt.Println(stock["bread"], stock["figs"])

	n, ok := stock["bread"]
	fmt.Println(n, ok)

	n, ok = stock["figs"]
	fmt.Println(n, ok)

	fmt.Println(len(stock))
}

Imprime:

0 0
0 true
0 false
2

stock["bread"] y stock["figs"] imprimen 0 los dos. Uno significa «se nos acabó el pan». El otro significa «nunca hemos vendido higos». Una lectura simple no puede distinguirlos.

La forma de dos valores sí puede. n, ok := stock["figs"] pone ok en true si la clave está en el map y en false si no está. Ese es el idiom comma-ok, y lo usas siempre que el valor cero pueda ser un valor real.

La última línea también importa. Leer stock["figs"] no agregó "figs" al map. La longitud sigue siendo 2. Una lectura nunca cambia un map.

Cuando el valor cero no puede ser una respuesta real, no necesitas ok. Un map[string]bool usado como conjunto es el caso típico: una clave que falta se lee como false, que es justo la respuesta que quieres.

Explicado como si tuvieras diez años

Imagina una pared de casilleros. Cada casillero tiene una etiqueta en la puerta, como «apples», y algo adentro, como el número 5. Eso es un map. La etiqueta es la clave, y lo que hay adentro es el valor.

Cuando pides el casillero «apples», recibes lo que hay adentro. Cuando pides un casillero con la etiqueta «figs» y no existe, nadie te grita. En su lugar te dan una caja vacía. Una caja vacía para números tiene 0. Una caja vacía para palabras no tiene nada.

Si quieres saber si el casillero de verdad estaba ahí, haces una segunda pregunta: «¿y ese casillero existía?». Eso es el ok.

La versión precisa

Un map no revisa cada entrada para encontrar una clave. Pasa la clave por una función hash, que convierte la clave en un número. Ese número elige una parte pequeña del map donde buscar, y solo las entradas de esa parte se comparan con tu clave.

Esta es la idea, dibujada con cuatro buckets. Los números de bucket son ilustrativos, no valores hash reales:

clave "cai" "eve" hash bucket 2 3 buckets 0 1 2 3 ana: 31 ben: 4 cai: 9 dee: 2 m: 4 claves en 4 buckets v, ok := m["cai"] v, ok := m["cai"] // 9, true v, ok := m["eve"] v, ok := m["eve"] // 0, false cada par clave/valor vive en un bucket hash "cai": digamos que cae en el bucket 2 solo en el bucket 2: cai coincide, devuelve 9, true hash "eve": digamos que cae en el bucket 3 ninguna clave del bucket 3 es eve: valor cero, false

Una búsqueda calcula el hash de la clave, usa ese hash para elegir un bucket y compara claves solo dentro de ese bucket. Una clave que coincide devuelve su valor y true. Una clave que no está devuelve el valor cero y false. Los números de bucket son ilustrativos, y la estructura real de un map en Go es más elaborada que cuatro buckets, pero la idea es la misma: el hash lleva a un lugar pequeño, y solo se busca ahí.

Estos son los mismos pasos en palabras, por si la animación no se reproduce:

  1. El map tiene cuatro pares. Cada par está en un bucket, elegido por el hash de su clave.
  2. m["cai"] pasa "cai" por la función hash. Digamos que el hash cae en el bucket 2.
  3. El map busca solo en el bucket 2. Compara claves, encuentra cai y devuelve 9 y true.
  4. m["eve"] calcula el hash de "eve". Digamos que cae en el bucket 3.
  5. El map busca solo en el bucket 3. Ninguna de las claves que hay ahí es eve, así que devuelve el valor cero, 0, y false. No se agrega nada al map.

El runtime real es más elaborado que el dibujo. Desde Go 1.24, los maps son Swiss tables. Las entradas viven en grupos de ocho posiciones, y cada grupo tiene una palabra de control con un byte por posición. Ese byte guarda algunos bits del hash de la clave, así que el runtime puede descartar la mayoría de las posiciones sin comparar ninguna clave. Los maps grandes se dividen en varias tablas que crecen de forma independiente. Nada de eso cambia lo que ves desde tu código: el hash decide dónde buscar, y una búsqueda fallida cuesta casi tan poco como una exitosa.

Cada map también recibe su propia semilla de hash aleatoria. La misma clave cae en lugares distintos en dos maps distintos, y en dos ejecuciones distintas de tu programa. Por eso los números de arriba solo pueden ser ilustrativos, y es parte de la razón por la que el orden de iteración no es fijo.

Dónde falla la analogía: una caja vacía de verdad sería un casillero nuevo donde podrías guardar cosas. Go no crea nada cuando lees una clave que falta. Solo te devuelve un valor cero, y el map queda exactamente como estaba.

Maps nil: leer funciona, escribir provoca un panic

El valor cero de un tipo map es nil, y un map nil se comporta como un map vacío en todo, menos al guardar una clave:

package main

import "fmt"

func main() {
	var prices map[string]float64
	fmt.Println(prices == nil, len(prices), prices["tea"])

	for k := range prices {
		fmt.Println("never runs", k)
	}
	delete(prices, "tea")

	prices["tea"] = 2.5
	fmt.Println("never reached")
}

Imprime la primera línea y se detiene:

true 0 0
panic: assignment to entry in nil map

Leer de un map nil, recorrerlo con range, pedir su longitud y borrar de él funcionan. Todas son preguntas con una respuesta obvia cuando no hay nada. Escribir es distinto. Una escritura necesita un lugar donde poner la entrada, y un map nil no tiene ninguna tabla.

¿Por qué Go no reserva una por ti? Porque una variable map guarda un puntero a la tabla del map. Si la escritura reservara una tabla nueva, solo la variable usada para escribir apuntaría a ella. Cualquier copia de ese map nil, como la que quien llama pasó a una función, seguiría siendo nil, y la entrada parecería desaparecer. Un panic es más ruidoso y más honesto.

La solución es crear el map antes de escribir en él, con un literal o con make:

package main

import "fmt"

func main() {
	var prices map[string]float64
	if prices == nil {
		prices = make(map[string]float64)
	}
	prices["tea"] = 2.5
	fmt.Println(prices)
}

Imprime:

map[tea:2.5]

En código real, el map nil casi siempre se esconde dentro de otra cosa, como un campo de un struct que nadie inicializó. var m map[K]V está bien cuando solo lees. Cuando vas a escribir, empieza con m := map[K]V{} o con make.

El orden de iteración no es un orden

Recorrer un map de Go con range visita cada clave exactamente una vez, pero sin un orden fijo, y dos bucles sobre el mismo map en la misma ejecución pueden no coincidir:

package main

import "fmt"

func main() {
	m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
	for k := range m {
		fmt.Println("loop 1:", k)
	}
	for k := range m {
		fmt.Println("loop 2:", k)
	}
}

Una ejecución imprimió:

loop 1: d
loop 1: e
loop 1: a
loop 1: b
loop 1: c
loop 2: a
loop 2: b
loop 2: c
loop 2: d
loop 2: e

El map no cambió entre los bucles. Cada range eligió su propio punto de partida aleatorio.

Al ejecutar esto en Go 1.26 apareció algo que vale la pena saber. Con un map así de pequeño, los órdenes muchas veces parecen el orden de inserción, rotado: a b c d e, luego d e a b c, luego c d e a b. Es un accidente de cómo una Swiss table pequeña guarda sus entradas, y es justo el tipo de patrón del que la gente empieza a depender. El lenguaje no promete nada al respecto, y la próxima versión puede cambiarlo.

Cambiar el map mientras lo recorres tiene sus propias reglas. Si borras una clave a la que todavía no llegaste, no la vas a ver, y borrar la clave en la que estás es seguro. Una clave agregada durante el bucle puede visitarse o no, así que no cuentes con ninguna de las dos cosas. Borrar sobre la marcha es el caso común y seguro:

package main

import "fmt"

func main() {
	stock := map[string]int{"apples": 5, "bread": 0, "cheese": 7, "dates": 0}
	for item, n := range stock {
		if n == 0 {
			delete(stock, item)
		}
	}
	fmt.Println(stock)
}

Imprime:

map[apples:5 cheese:7]

Obtener un orden estable

Cuando la salida necesita un orden, lo eliges tú, casi siempre ordenando las claves. maps.Keys devuelve un iterador sobre las claves, y slices.Sorted las junta en un slice ordenado. También puedes ordenar por valor:

package main

import (
	"cmp"
	"fmt"
	"maps"
	"slices"
	"strings"
)

func main() {
	votes := map[string]int{"tea": 4, "coffee": 7, "juice": 4, "water": 1}

	fmt.Println(slices.Sorted(maps.Keys(votes)))

	drinks := slices.Collect(maps.Keys(votes))
	slices.SortFunc(drinks, func(a, b string) int {
		return cmp.Or(
			cmp.Compare(votes[b], votes[a]), // most votes first
			strings.Compare(a, b),           // then by name
		)
	})
	fmt.Println(drinks)
}

Imprime:

[coffee juice tea water]
[coffee juice tea water]

La primera línea está en orden alfabético. El segundo ordenamiento pone primero los que tienen más votos. tea y juice tienen 4 los dos, así que el desempate por nombre decide entre ellos. Sin un desempate, dos ejecuciones podrían ponerlos en órdenes distintos, porque para empezar las claves llegaron en un orden aleatorio.

cmp.Or devuelve el primer argumento que no es cero, y eso es lo que convierte «ordena por esto, y luego por aquello» en una sola expresión.

Pasar un map a una función

Un map que pasas a una función de Go no se copia. La función recibe una copia de la variable map, y esa copia apunta a la misma tabla, así que cada cambio se ve desde afuera:

package main

import "fmt"

func restock(m map[string]int, item string, n int) {
	m[item] += n
}

func replace(m map[string]int) {
	m = map[string]int{"surprise": 1}
	fmt.Println("inside replace:", m)
}

func main() {
	stock := map[string]int{"apples": 5}
	restock(stock, "apples", 3)
	restock(stock, "bread", 2)
	replace(stock)
	fmt.Println(stock)
}

Imprime:

inside replace: map[surprise:1]
map[apples:8 bread:2]

restock cambió un valor que ya existía y agregó una clave nueva, y quien llama ve las dos cosas. Compáralo con los slices. Una función que hace append a un slice que recibió no puede cambiar la longitud del slice de quien llama, porque la longitud vive en el encabezado del slice que la función copió. Un map guarda su tamaño dentro de la tabla compartida, así que las claves nuevas también se ven.

replace es el límite. Asignar un map completamente nuevo a m solo cambia la variable de la función. Quien llama sigue apuntando a la tabla vieja. Una función que quiere entregar un map distinto lo devuelve.

m[item] += n también funciona con una clave que todavía no existe. La lectura da 0, y la escritura guarda 0 más n.

No puedes cambiar un campo de un struct dentro de un map

Un valor guardado en un map de Go no es direccionable, así que no puedes asignar a una parte de él en su lugar:

package main

import "fmt"

type player struct {
	name  string
	score int
}

func main() {
	players := map[string]player{"ana": {name: "Ana"}}
	players["ana"].score = 10
	fmt.Println(players)
}

La compilación falla con:

./main.go:12:2: cannot assign to struct field players["ana"].score in map

La razón es el crecimiento que viste en la sección de búsqueda. Cuando un map crece, el runtime mueve las entradas a lugares nuevos. Si Go te dejara tener un puntero a un valor dentro del map, ese puntero podría terminar apuntando a una posición que el map ya abandonó. Por eso el lenguaje no te deja tomar la dirección de un valor de un map, y asignar a uno de sus campos necesitaría justamente eso. &players["ana"] se rechaza por la misma razón.

Hay dos soluciones. Lee el valor, cambia la copia y vuelve a guardarla, o guarda punteros en el map:

package main

import "fmt"

type player struct {
	name  string
	score int
}

func main() {
	players := map[string]player{"ana": {name: "Ana"}}
	p := players["ana"]
	p.score = 10
	players["ana"] = p

	byPointer := map[string]*player{"ben": {name: "Ben"}}
	byPointer["ben"].score = 7

	fmt.Println(players["ana"].score, byPointer["ben"].score)
}

Imprime:

10 7

La versión de copiar y guardar deja al map como único dueño de sus valores. La versión con punteros es más corta cuando actualizas seguido, pero tiene una trampa: el valor cero de un puntero es nil, así que byPointer["zed"].score = 1 con una clave que falta provoca un panic. La parte sobre structs, métodos y punteros explica cuándo elegir cada una.

Qué puede ser una clave

Una clave de un map de Go tiene que ser comparable, es decir, == tiene que funcionar con ella, porque el map necesita comprobar si dos claves son la misma. Los slices, los maps y las funciones no se pueden comparar con ==, así que no pueden ser claves:

package main

import "fmt"

func main() {
	seen := map[[]string]bool{}
	fmt.Println(seen)
}

La compilación falla con:

./main.go:6:14: invalid map key type []string

Los strings, los números, los booleanos, los punteros y los canales funcionan. También los arrays y los structs, siempre que cada elemento o campo sea comparable. Una clave struct es la forma limpia de indexar un map por más de una cosa:

package main

import "fmt"

type cell struct {
	row, col int
}

func main() {
	board := map[cell]string{}
	board[cell{0, 0}] = "X"
	board[cell{1, 2}] = "O"

	fmt.Println(board[cell{1, 2}], len(board))
	_, taken := board[cell{2, 2}]
	fmt.Println("2,2 taken:", taken)

	trips := map[[2]string]int{}
	trips[[2]string{"home", "work"}]++
	trips[[2]string{"home", "work"}]++
	trips[[2]string{"work", "gym"}]++
	fmt.Println(trips[[2]string{"home", "work"}], len(trips))
}

Imprime:

O 2
2,2 taken: false
2 2

cell{1, 2} construido en dos lugares distintos es la misma clave, porque dos structs son iguales cuando todos sus campos son iguales. No necesitas pegar la fila y la columna en un string como "1,2".

Hay un tipo de clave que pasa el compilador y falla después. Con una clave de tipo interfaz, como any, el compilador no puede saber qué vas a guardar, así que un slice se cuela hasta que el programa se ejecuta:

package main

import "fmt"

func main() {
	seen := map[any]bool{}
	seen[42] = true
	seen["42"] = true
	fmt.Println(len(seen))

	seen[[]int{4, 2}] = true
}

Imprime la primera línea y se detiene:

2
panic: runtime error: hash of unhashable type []int

42 y "42" son claves distintas, porque sus tipos son distintos. El slice no se puede pasar por el hash de ninguna forma, y esa comprobación solo puede hacerse en tiempo de ejecución.

Ejemplo práctico: contar palabras

Contar cuántas veces aparece cada palabra es el trabajo clásico de un map, y el valor cero lo convierte en una sola línea dentro del bucle:

package main

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

func main() {
	text := "the cat sat on the mat and the cat slept"

	counts := map[string]int{}
	for _, word := range strings.Fields(text) {
		counts[word]++
	}

	for _, word := range slices.Sorted(maps.Keys(counts)) {
		fmt.Println(word, counts[word])
	}
}

Imprime:

and 1
cat 2
mat 1
on 1
sat 1
slept 1
the 3

counts[word]++ lee la cuenta actual, que es 0 la primera vez que aparece una palabra, le suma uno y la guarda. No hace falta comprobar «si la clave existe».

Ejemplo práctico: agrupar en slices

Agrupar valores bajo una clave es el otro trabajo de todos los días, y un map[string][]string lo resuelve con append:

package main

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

func main() {
	fruit := []string{"apple", "banana", "avocado", "cherry", "blueberry", "apricot"}

	byLetter := map[string][]string{}
	for _, f := range fruit {
		letter := f[:1]
		byLetter[letter] = append(byLetter[letter], f)
	}

	for _, letter := range slices.Sorted(maps.Keys(byLetter)) {
		fmt.Println(letter, byLetter[letter])
	}
}

Imprime:

a [apple avocado apricot]
b [banana blueberry]
c [cherry]

La primera vez que aparece una letra, byLetter[letter] es un slice nil. append sobre un slice nil funciona, así que el grupo se crea solo. Tienes que guardar el resultado con byLetter[letter] = ..., por la misma razón por la que escribes s = append(s, x): append puede devolver un slice sobre un array nuevo.

Dentro de cada grupo, las frutas mantienen el orden de entrada, porque un slice conserva su orden. Solo había que ordenar las claves.

El paquete maps y clear

El paquete maps de la biblioteca estándar, agregado en Go 1.21, cubre las tareas para las que de otro modo escribirías bucles:

package main

import (
	"fmt"
	"maps"
)

func main() {
	prices := map[string]int{"tea": 3, "coffee": 4, "cake": 5}

	backup := maps.Clone(prices)
	prices["tea"] = 99
	fmt.Println(backup["tea"], maps.Equal(prices, backup))

	maps.DeleteFunc(prices, func(item string, price int) bool {
		return price > 4
	})
	fmt.Println(prices)

	clear(prices)
	fmt.Println(prices, len(prices), prices == nil)
}

Imprime:

3 false
map[coffee:4]
map[] 0 false

maps.Clone crea un map nuevo con las mismas claves y valores, así que cambiar prices dejó backup intacto. Eso sí, el clon es superficial. Si los valores son slices o punteros, los dos maps comparten aquello a lo que apuntan.

maps.Equal indica si dos maps tienen las mismas claves con los mismos valores. Lo necesitas porque == no funciona entre dos maps. Lo único con lo que se compara un map es nil.

maps.DeleteFunc elimina cada entrada para la que la función devuelve true, que es el bucle de la sección de iteración en una sola llamada. El pastel costaba 5, y el té ahora cuesta 99, así que los dos se fueron.

clear, una función integrada desde Go 1.21, elimina todas las entradas. El map queda vacío pero se puede seguir usando, y no es nil.

clear también puede hacer algo que delete no puede. Un NaN de punto flotante nunca es igual a nada, ni siquiera a sí mismo, así que una clave NaN puede entrar pero nunca se puede volver a encontrar:

package main

import (
	"fmt"
	"math"
)

func main() {
	m := map[float64]string{}
	m[math.NaN()] = "first"
	m[math.NaN()] = "second"
	fmt.Println(len(m))

	delete(m, math.NaN())
	fmt.Println(len(m))

	clear(m)
	fmt.Println(len(m))
}

Imprime:

2
2
0

Dos escrituras en «la misma» clave NaN crearon dos entradas, y delete no pudo encontrar ninguna. clear no busca claves, así que las elimina de todos modos. Rara vez vas a indexar un map por floats, pero cuando lo hagas, esta es la razón.

Maps y goroutines

Un map simple de Go no es seguro para usarse desde varias goroutines a la vez, y cuando dos goroutines escriben en él al mismo tiempo, el runtime puede detener todo el programa con fatal error: concurrent map writes. La parte sobre sync y el detector de carreras muestra cómo evitarlo.

Qué recordar

  • m[k] = v agrega o reemplaza, delete(m, k) elimina y len(m) cuenta. Leer una clave que falta devuelve el valor cero y no la agrega.
  • Usa v, ok := m[k] siempre que el valor cero pueda ser un valor real.
  • Un map nil se puede leer, recorrer con range y borrar, pero escribir en él provoca un panic. Créalo antes con un literal o con make.
  • El orden de iteración nunca está garantizado, aunque un map pequeño parezca mantener uno. Ordena las claves con slices.Sorted(maps.Keys(m)) cuando el orden importe.
  • Un map que pasas a una función comparte su tabla, así que las claves nuevas y los valores cambiados se ven desde afuera.
  • Los valores de un map no son direccionables. Copia, cambia y vuelve a guardar, o guarda punteros. Las claves tienen que ser comparables, y un struct es una buena clave de varias partes.
  • maps.Clone, maps.Equal, maps.DeleteFunc y clear reemplazan la mayoría de los bucles que escribirías a mano sobre un map.

Un map encuentra un valor según dónde cae el hash de su clave, así que no puede darte un orden, solo una respuesta.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.