Maps: criação, iteração e boas práticas

Maps: criação, iteração e boas práticas

Maps em Go: make e literais, o map nil que aceita leitura e não aceita escrita, o comma ok, delete durante o range, ordem de iteração e como impor uma, contagem, agrupamento e set, o campo de struct que não se altera no lugar e o fatal error que nenhum recover intercepta.
Golang

• • 14 min de leitura

Se slices são a espinha dorsal das sequências em Go, maps são a espinha dorsal das associações. Um map armazena pares de chave e valor, permitindo recuperar qualquer valor em tempo constante — O(1) — a partir de sua chave. Essa característica os torna indispensáveis para contagem, agrupamento, indexação, cache e configuração.

Go implementa maps como tabelas hash. Você não precisa entender os detalhes internos para usá-los bem, mas saber que são tabelas hash explica dois comportamentos que todo programador Go precisa conhecer: a ordem de iteração é aleatória, e maps não são seguros para uso concorrente sem sincronização.

Criando maps

Com literal de map:

package main

import "fmt"

func main() {
    capitais := map[string]string{
        "Brasil":    "Brasília",
        "Argentina": "Buenos Aires",
        "Chile":     "Santiago",
        "Peru":      "Lima",
    }

    fmt.Println(capitais["Brasil"])     // Brasília
    fmt.Println(capitais["Argentina"])  // Buenos Aires
}

A sintaxe map[TipoChave]TipoValor define o tipo do map. Chaves e valores podem ser de qualquer tipo, com uma restrição importante: o tipo da chave precisa ser comparável — ou seja, deve suportar os operadores == e !=. Tipos como int, string, bool, structs com campos comparáveis e ponteiros são válidos como chaves. Slices, maps e funções não são comparáveis e não podem ser usados como chaves.

Com make:

estoque := make(map[string]int)
estoque["teclado"] = 15
estoque["mouse"] = 32
estoque["monitor"] = 7

fmt.Println(estoque) // map[monitor:7 mouse:32 teclado:15]

make cria um map vazio e pronto para uso. A diferença entre make(map[string]int) e declarar var m map[string]int é crítica: um map declarado com var mas não inicializado tem valor nil, e tentar escrever nele causa pânico em tempo de execução:

var m map[string]int
m["chave"] = 1  // PANIC: assignment to entry in nil map

Sempre inicialize maps com make ou com um literal antes de escrever neles.

Lendo valores e verificando existência

Ler uma chave inexistente em um map não causa erro — Go retorna o valor zero do tipo do valor:

estoque := map[string]int{
    "teclado": 15,
}

quantidade := estoque["mouse"]
fmt.Println(quantidade) // 0 — chave não existe, retorna zero

Isso pode ser ambíguo: o valor 0 pode significar "chave não existe" ou "o valor associado à chave é realmente zero". Para distinguir os dois casos, Go oferece a forma de dois retornos — o idioma mais importante ao trabalhar com maps:

quantidade, existe := estoque["mouse"]
if existe {
    fmt.Println("Estoque de mouse:", quantidade)
} else {
    fmt.Println("Produto não encontrado no estoque")
}

A variável existe é um bool que indica se a chave estava presente no map, independentemente do valor. Esse padrão é chamado de comma ok e aparece em outros contextos do Go, como leitura de channels e asserções de tipo.

Combinando com a inicialização no if:

if qtd, ok := estoque["teclado"]; ok {
    fmt.Println("Teclado em estoque:", qtd)
}

Removendo entradas

A função embutida delete remove uma chave de um map. Se a chave não existir, a operação é silenciosamente ignorada — não há erro:

estoque := map[string]int{
    "teclado": 15,
    "mouse":   32,
}

delete(estoque, "mouse")
delete(estoque, "monitor") // chave inexistente — sem efeito

fmt.Println(estoque) // map[teclado:15]

Iterando sobre maps

A iteração usa for range. Como mencionado, a ordem é aleatória e não garantida em nenhuma versão do Go:

populacao := map[string]int{
    "São Paulo":      12_300_000,
    "Rio de Janeiro":  6_700_000,
    "Brasília":        3_000_000,
    "Salvador":        2_900_000,
}

for cidade, habitantes := range populacao {
    fmt.Printf("%-20s %d habitantes\n", cidade, habitantes)
}

Quando apenas as chaves são necessárias:

for cidade := range populacao {
    fmt.Println(cidade)
}

Iteração em ordem definida. Quando a ordem importa, extraia as chaves para um slice, ordene-o e itere pelo slice:

import "sort"

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

for _, k := range chaves {
    fmt.Printf("%-20s %d\n", k, populacao[k])
}

Desde o Go 1.23, as três etapas cabem numa linha: for _, k := range slices.Sorted(maps.Keys(populacao)), com os pacotes maps e slices da biblioteca padrão.

Padrões comuns com maps

Contagem de frequência. Um dos usos mais clássicos de maps é contar ocorrências:

package main

import (
    "fmt"
    "strings"
)

func main() {
    texto := "go é simples go é eficiente go é concorrente"
    palavras := strings.Fields(texto)

    frequencia := make(map[string]int)
    for _, p := range palavras {
        frequencia[p]++
    }

    for palavra, count := range frequencia {
        fmt.Printf("%-15s %d\n", palavra, count)
    }
}

O padrão frequencia[p]++ funciona porque o valor zero de int é 0 — ao incrementar uma chave inexistente, Go a inicializa com 0 e então incrementa para 1.

Agrupamento. Maps cujo valor é um slice permitem agrupar elementos:

package main

import "fmt"

func main() {
    pessoas := []struct {
        Nome       string
        Departamento string
    }{
        {"Ana", "Engenharia"},
        {"Bruno", "Marketing"},
        {"Carla", "Engenharia"},
        {"Diego", "Marketing"},
        {"Elena", "Engenharia"},
    }

    porDepartamento := make(map[string][]string)
    for _, p := range pessoas {
        porDepartamento[p.Departamento] = append(
            porDepartamento[p.Departamento], p.Nome,
        )
    }

    for dept, nomes := range porDepartamento {
        fmt.Printf("%s: %v\n", dept, nomes)
    }
}

Set simulado. Go não possui um tipo Set nativo. O padrão idiomático usa map[T]struct{}, onde struct{} ocupa zero bytes de memória:

visitados := make(map[string]struct{})

urls := []string{"go.dev", "github.com", "go.dev", "pkg.go.dev"}

for _, url := range urls {
    if _, visto := visitados[url]; visto {
        fmt.Println("Duplicado ignorado:", url)
        continue
    }
    visitados[url] = struct{}{}
    fmt.Println("Processando:", url)
}

Maps de maps

Para estruturas hierárquicas, maps podem ter outros maps como valor:

config := map[string]map[string]string{
    "banco": {
        "host":  "localhost",
        "porta": "5432",
        "nome":  "appdb",
    },
    "cache": {
        "host": "localhost",
        "porta": "6379",
    },
}

fmt.Println(config["banco"]["host"])  // localhost
fmt.Println(config["cache"]["porta"]) // 6379

Ler de um map aninhado que não existe não causa pânico: config["cache"]["porta"], com "cache" ausente, pega o valor zero do map externo — um map interno nil — e a leitura nele devolve "". O pânico aparece na escrita: config["cache"]["porta"] = "6379" tenta gravar num map nil e para com assignment to entry in nil map. Antes de escrever, crie o map interno se ele ainda não existir:

if _, ok := config["cache"]; !ok {
    config["cache"] = make(map[string]string)
}
config["cache"]["porta"] = "6379"

Tamanho e maps vazios

A função len retorna o número de pares chave-valor:

m := map[string]int{"a": 1, "b": 2, "c": 3}
fmt.Println(len(m)) // 3

Para verificar se um map está vazio:

if len(m) == 0 {
    fmt.Println("map vazio")
}

Concorrência e maps

Maps em Go não são seguros para uso concorrente. Se múltiplas goroutines acessam o mesmo map simultaneamente e ao menos uma delas está escrevendo, o programa está errado, e o runtime costuma detectar o conflito e encerrar tudo com fatal error: concurrent map writes. Não é um pânico comum: um recover não o intercepta, e o processo morre mesmo com a proteção no lugar. Quando o runtime não percebe o conflito, o resultado são dados corrompidos em silêncio — o go test -race é a ferramenta para achar esses casos antes da produção.

Para uso concorrente, existem duas abordagens principais. A primeira é proteger o map com um sync.RWMutex:

import "sync"

type MapaConcorrente struct {
    mu   sync.RWMutex
    dados map[string]int
}

func (m *MapaConcorrente) Set(chave string, valor int) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.dados[chave] = valor
}

func (m *MapaConcorrente) Get(chave string) (int, bool) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    v, ok := m.dados[chave]
    return v, ok
}

A segunda é usar o sync.Map da biblioteca padrão, otimizado para casos onde chaves são escritas uma vez e lidas muitas vezes:

import "sync"

var m sync.Map

m.Store("chave", 42)

valor, ok := m.Load("chave")
if ok {
    fmt.Println(valor) // 42
}

m.Delete("chave")

O tema de concorrência será aprofundado futuramente. Por ora, a regra simples é: se mais de uma goroutine usa o mesmo map, proteja o acesso.

Map é a estrutura de busca do Go: rápida, sem ordem e com um valor zero que simplifica contagens e agrupamentos. As regras que evitam surpresas são poucas e concretas — ler de um map nil é seguro e escrever nele não é, o comma ok separa a chave ausente do valor zero, o valor guardado não pode ser alterado no lugar e, com mais de uma goroutine, o acesso precisa de sincronização, porque o erro que o runtime dispara nesse caso derruba o processo inteiro.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um cadastro guarda produtos em map[string]Produto, em que Produto é uma struct com o campo Estoque. Para dar baixa, alguém escreve produtos["teclado"].Estoque-- e o código não compila. Por que o Go proíbe algo aparentemente tão natural, e quais são as duas formas de fazer a baixa?

Ver resposta

✓ Resposta: O compilador recusa com cannot assign to struct field m["a"].N in map, porque o valor guardado num map não é endereçável. A tabela hash pode mudar os elementos de lugar quando cresce, então um ponteiro para dentro dela ficaria inválido, e o Go prefere proibir a operação a permitir um ponteiro que muda de alvo por baixo dos panos. A leitura produtos["teclado"] devolve uma cópia da struct, e alterar um campo de uma cópia temporária não faria sentido. A primeira forma de dar baixa é o ciclo de copiar, alterar e gravar de volta: p := produtos["teclado"]; p.Estoque--; produtos["teclado"] = p. A segunda é guardar ponteiros, com map[string]*Produto, e aí produtos["teclado"].Estoque-- funciona, porque a alteração vai para a struct apontada, que mora fora do map. A escolha segue o critério do artigo de ponteiros: a segunda forma é mais cômoda, mas cria uma entidade compartilhada, e qualquer um que tenha o ponteiro enxerga e altera a mesma struct.

Exercício 2

Um teste imprime um map com fmt.Println e compara a saída com uma string esperada. Ele passa sempre. Um colega usa a mesma ideia num gerador de relatório e monta as linhas com for k, v := range m; o relatório sai em ordem diferente a cada execução. Por que o fmt é estável e o range não?

Ver resposta

✓ Resposta: Porque o fmt ordena as chaves antes de imprimir: map[string]int{"c": 3, "a": 1, "b": 2} sai sempre como map[a:1 b:2 c:3], comportamento adotado justamente para tornar a saída reprodutível. O range, por sua vez, não promete ordem nenhuma, e o runtime sorteia o ponto de partida a cada laço para que ninguém dependa de uma ordem acidental. O teste do primeiro colega é estável, mas por um motivo que ele talvez desconheça. O relatório precisa de ordem explícita: coletar as chaves, ordenar e percorrer a lista ordenada, o que desde o Go 1.23 se escreve como for _, k := range slices.Sorted(maps.Keys(m)). Se a ordem desejada não for a das chaves, como no caso do maior valor primeiro, ordene uma fatia de pares com slices.SortFunc. Map é para busca por chave; quando a ordem é parte do resultado, ela precisa vir de uma fatia.

Exercício 3

Um cache em memória é um map[string][]byte global, atualizado por vários handlers HTTP. Para "garantir que nada derrube o servidor", alguém põe um defer com recover em cada handler. Sob carga, o processo morre mesmo assim, com fatal error: concurrent map writes. Por que o recover não ajudou, e qual é a correção?

Ver resposta

✓ Resposta: Porque o que aconteceu não foi um pânico, e sim um erro fatal do runtime. Quando ele detecta duas goroutines escrevendo no mesmo map ao mesmo tempo, encerra o processo inteiro, e recover não tem efeito sobre isso. Num teste com oito goroutines gravando cem mil chaves cada uma num map comum, com um recover registrado, a execução termina com fatal error: concurrent map writes e o recover nunca chega a rodar. A decisão é deliberada: um map usado sem sincronização pode estar corrompido, e continuar rodando com ele seria pior que parar. A correção é sincronizar o acesso, como o artigo mostra, com um sync.RWMutex em volta do map ou com sync.Map quando o padrão for de muitas leituras e poucas escritas. Para achar o problema antes da produção, go test -race aponta a disputa já na primeira vez que ela acontece nos testes, com as duas pilhas envolvidas, em vez de depender de o runtime pegar o conflito em flagrante.

Exercício 4

Uma rotina de limpeza percorre um map de sessões com for k, v := range sessoes e chama delete(sessoes, k) para as expiradas. Um revisor bloqueia o PR: "não se remove de uma coleção enquanto se itera sobre ela". Em Go, ele tem razão?

Ver resposta

✓ Resposta: Não. Em Go, apagar entradas durante o range é permitido e está definido na especificação: uma entrada removida que ainda não tinha sido alcançada simplesmente não aparece no laço, e as outras continuam sendo percorridas normalmente. Num teste com mil sessões, apagar dentro do range as de valor par deixa exatamente 500 no map, sem pânico e sem entrada pulada. A regra que o revisor tem em mente vale para outras linguagens, como o ConcurrentModificationException do Java ou o erro que o Python levanta ao mudar o tamanho de um dicionário durante a iteração. O que o Go não garante é o efeito de acrescentar chaves durante o range: a chave nova pode ou não aparecer no mesmo laço. Remover é seguro; acrescentar deve ser feito num map separado ou depois do laço. E nada disso vale com concorrência: se outra goroutine mexe no map, é o problema do exercício anterior.

Exercício 5

Um componente guarda uma referência ao map de configuração, cfg := app.config. Na recarga, o código principal "zera" a configuração antes de preencher de novo. Com clear(app.config), o componente passa a ver o map vazio; com app.config = make(map[string]string), continua vendo os valores antigos. Explique e diga qual das duas a recarga deveria usar.

Ver resposta

✓ Resposta: Uma variável de map guarda uma referência para a tabela, e cfg := app.config faz as duas variáveis apontarem para a mesma. O clear, embutido desde o Go 1.21, esvazia essa tabela compartilhada: num teste com b := a e depois clear(a), len(b) vale 0. Já app.config = make(...) não toca na tabela antiga, só faz app.config apontar para outra, nova; com d := c seguido de c = make(...), len(c) vale 0 e len(d) continua 1. Qual usar depende do que o componente deve ver. Se ele deve acompanhar a recarga, o clear seguido do preenchimento faz isso, mas há uma janela em que ele enxerga a configuração vazia ou incompleta, e, com goroutines envolvidas, isso exige o mutex do exercício anterior. A forma mais robusta costuma ser a terceira: montar o map novo por inteiro e só então trocá-lo de uma vez, e fazer os componentes buscarem a configuração atual a cada uso em vez de guardarem uma referência própria.

Comentários

Mais em Golang

Domain-Driven Design aplicado a projetos Go
Domain-Driven Design aplicado a projetos Go

Domain-Driven Design em Go: linguagem compartilhada, entidades com eventos…

Armadilhas da concorrência: race conditions e como detectá-las
Armadilhas da concorrência: race conditions e como detectá-las

Armadilhas da concorrência em Go: corridas de dados e o detector -race…

GORM: ORM para Go, migrações e relacionamentos
GORM: ORM para Go, migrações e relacionamentos

GORM na prática: modelos, relacionamentos, scopes, transações com FOR UPDATE…