Map в Go: создание, проверка ключа и удаление

• 12 мин чтения

Map в Go называют словарём или ассоциативным массивом. Она хранит пары «ключ → значение». В разговорной речи также встречается название «мапа». Через неё удобно находить пользователя по ID, считать повторения и группировать записи.

Разберём создание map, проверку наличия ключа, удаление и обход. Затем посмотрим на ошибки, которые компилятор пропускает: запись в nil, неожиданное изменение общей map и доступ из нескольких горутин.

Основные операции с map

Тип записывается как map[K]V, где K задаёт тип ключа, а V задаёт тип значения. Например, map[string]int связывает строку с целым числом.

Полная программа с основными операциями:

package main

import "fmt"

func main() {
	counts := make(map[string]int)

	counts["go"] = 1
	counts["go"]++
	counts["postgresql"] = 1

	value, ok := counts["go"]
	fmt.Println(value, ok)   // 2 true
	fmt.Println(len(counts)) // 2

	delete(counts, "postgresql")
	fmt.Println(len(counts)) // 1
}

Присваивание по существующему ключу заменяет его значение. Двух записей с одинаковым ключом в одной map быть не может. Поэтому len возвращает число уникальных ключей, а не количество присваиваний.

Далее в коротких фрагментах опущены main и импорты. Для вывода нужен пакет fmt, для функций maps и slices нужны одноимённые пакеты.

Краткая шпаргалка:

ОперацияКодРезультат
Создать пустую mapm := make(map[string]int)Map готова к записи
Добавить или заменить значениеm["go"] = 10По ключу "go" хранится 10
Прочитатьvalue := m["go"]Значение либо нулевое значение типа
Проверить наличиеvalue, ok := m["go"]ok показывает, есть ли ключ
Удалитьdelete(m, "go")Ключ удалён, если существовал
Узнать размерlen(m)Количество пар
Удалить все парыclear(m)Map становится пустой

clear доступен начиная с Go 1.21.

Как создать и инициализировать map в Go

Если начальные данные известны, используйте литерал:

ports := map[string]int{
    "http":  80,
    "https": 443,
}

ports["postgresql"] = 5432

Для заполнения в цикле обычно удобнее make. Когда примерное число записей известно заранее, его можно передать вторым аргументом:

users := make(map[int]string, 1000)
users[42] = "Анна"

Число 1000 служит подсказкой для начального выделения памяти. Оно не добавляет элементы и не ограничивает размер. Сразу после make длина равна нулю, а при необходимости map вырастет. Встроенную функцию cap к map применять нельзя.

Чем nil отличается от пустой map

Объявление через var не создаёт готовую к записи map:

var counts map[string]int

fmt.Println(counts == nil) // true
fmt.Println(len(counts))   // 0
fmt.Println(counts["go"])  // 0

delete(counts, "go") // Допустимо.
clear(counts)        // Допустимо.

Чтение из nil возвращает нулевое значение типа элемента. Удаление и очистка ничего не делают. Цикл range не выполнит ни одной итерации.

Ошибка assignment to entry in nil map

Запись в неинициализированную map вызывает панику:

var counts map[string]int
counts["go"] = 1 // panic: assignment to entry in nil map

Перед записью нужна инициализация:

counts := make(map[string]int)
counts["go"] = 1

Литерал map[string]int{} тоже создаёт пустую map, готовую к записи. У неё и у nil одинаковая длина, но разное поведение при добавлении элементов.

Действиеvar m map[string]intm := map[string]int{}
m == niltruefalse
len(m)00
m["x"]00
m["x"] = 1ПаникаДобавление элемента

Подробнее о нулевых значениях типов есть в статье Zero value в Go.

Как проверить наличие ключа в map: value, ok

Одного значения недостаточно, чтобы понять, существует ли запись:

stock := map[string]int{
    "book": 0,
}

fmt.Println(stock["book"]) // 0
fmt.Println(stock["pen"])  // 0

Книга есть в каталоге, но закончилась. Ручки в каталоге нет. Обычное чтение возвращает 0 в обоих случаях.

Для различения используйте форму с двумя результатами:

quantity, ok := stock["book"]
fmt.Println(quantity, ok) // 0 true

quantity, ok = stock["pen"]
fmt.Println(quantity, ok) // 0 false

ok относится к наличию ключа. Он остаётся true, даже если значение равно 0, false, пустой строке или nil.

У встроенной map нет метода contains. Если нужно только проверить наличие ключа, отбросьте значение:

if _, ok := stock["book"]; ok {
    fmt.Println("Товар есть в каталоге")
}

У нулевого значения есть и полезная сторона. Счётчик не требует отдельной ветки для первого появления слова:

words := []string{"go", "sql", "go"}
counts := make(map[string]int)

for _, word := range words {
    counts[word]++
}

fmt.Println(counts["go"]) // 2

При первом обращении чтение даёт 0, затем увеличение записывает 1. Сама map при этом должна быть инициализирована.

Как удалить элемент из map и очистить её

delete удаляет пару по ключу. Отсутствующий ключ не считается ошибкой:

sessions := map[string]int{
    "alice": 10,
    "bob":   20,
}

delete(sessions, "alice")
delete(sessions, "unknown")

fmt.Println(len(sessions)) // 1

Функция ничего не возвращает. Если нужно узнать, была ли запись, прочитайте её с ok перед удалением. При конкурентном доступе чтение и удаление должны находиться под одной блокировкой.

clear удаляет все пары, сохраняя возможность записи в эту map:

clear(sessions)
fmt.Println(len(sessions)) // 0

sessions["carol"] = 30

Очистка не даёт гарантии немедленного возврата памяти операционной системе. Если большая map больше не нужна, можно убрать ссылку на неё: sessions = nil. Если нужна новая пустая map, используйте sessions = make(map[string]int). Старая станет доступна для сборки мусора, когда на неё не останется ссылок.

Присваивание новой map и clear различаются при наличии других переменных, которые ссылаются на ту же map. Это пригодится в разделе про копирование.

Обход map через range и сортировка ключей

Цикл получает ключ и значение каждой записи:

ports := map[string]int{
    "http":  80,
    "https": 443,
}

for name, port := range ports {
    fmt.Println(name, port)
}

Если нужны только ключи, достаточно for name := range ports. Если только значения, используйте for _, port := range ports.

Порядок обхода не определён. Он не обязан совпадать ни с порядком вставки, ни с предыдущим проходом. Если тест проверяет последовательность строк из range, он может нестабильно падать.

Как получить все ключи и значения map

Чтобы преобразовать ключи или значения map в слайс, используйте maps.Keys или maps.Values вместе с slices.Collect. Пример для Go 1.23 и новее:

ports := map[string]int{
    "http":  80,
    "https": 443,
}

keys := slices.Collect(maps.Keys(ports))
values := slices.Collect(maps.Values(ports))

fmt.Println(len(keys))   // 2
fmt.Println(len(values)) // 2

Обе функции пакета maps возвращают итераторы. slices.Collect собирает результат в слайс. Порядок элементов не определён. Нельзя сопоставлять keys[i] и values[i]: два независимых обхода могут выдать записи в разном порядке. Если нужны пары, получайте ключ и значение в одном цикле range.

Как отсортировать map по ключам

Для упорядоченного вывода сначала соберите и отсортируйте ключи. Полная программа для Go 1.23 и новее:

package main

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

func main() {
	ports := map[string]int{
		"https": 443,
		"http":  80,
	}

	keys := slices.Sorted(maps.Keys(ports))
	for _, name := range keys {
		fmt.Println(name, ports[name])
	}
}

Здесь maps.Keys возвращает итератор, а slices.Sorted собирает его значения в отсортированный слайс. В результате http всегда выводится перед https. Для сортировки по значениям удобнее собрать слайс структур с ключом и значением, затем отсортировать его.

Можно ли удалять во время range

В пределах одной горутины удалять элементы во время обхода разрешено:

counts := map[string]int{
    "go":  2,
    "sql": 0,
}

for key, count := range counts {
    if count == 0 {
        delete(counts, key)
    }
}

По спецификации Go, если удалить ещё не посещённую запись, цикл её уже не выдаст. Новая запись, добавленная во время обхода, может попасть в обход, а может быть пропущена. Поэтому не стоит строить алгоритм на обязательном посещении добавленных элементов.

Это правило относится к изменениям внутри самого цикла. Оно не разрешает одновременную запись из другой горутины.

Какие типы могут быть ключами

Ключи должны поддерживать операции сравнения == и !=. Строки и целые числа подходят, как и указатели. Массивы и структуры тоже подходят, если их элементы или поля сравнимы.

Например, можно искать данные сразу по двум идентификаторам:

type Key struct {
    TenantID int
    UserID   int
}

names := make(map[Key]string)
names[Key{TenantID: 10, UserID: 42}] = "Анна"

Слайсы, map и функции не подходят для ключей. Объявление map[[]byte]int не скомпилируется. Для байтового содержимого можно выбрать строку, если нужна проверка по содержимому, или массив фиксированного размера, если размер известен.

Интерфейсный ключ требует осторожности. Объявление map[any]int допустимо, но фактическое значение ключа тоже должно быть сравнимым:

values := make(map[any]int)
values["go"] = 1
values[[]int{1, 2}] = 2 // Паника: слайс нельзя использовать как ключ.

Поэтому конкретный тип ключа обычно удобнее: больше ошибок обнаруживается при компиляции. Выбор между вложенной map и структурой в качестве ключа отдельно разобран в статье Составной ключ вместо вложенных map.

Почему поле структуры в map нельзя изменить напрямую

Такой код не скомпилируется:

type User struct {
    Visits int
}

users := map[int]User{
    42: {Visits: 1},
}

users[42].Visits++ // Ошибка компиляции.

Элемент map не является адресуемым значением. Нельзя взять &users[42] или изменить его поле таким присваиванием. Прочитайте структуру в переменную, измените и запишите обратно:

user := users[42]
user.Visits++
users[42] = user

Если отсутствие пользователя считается ошибкой, чтение нужно дополнить проверкой ok. Иначе код получит нулевую структуру и добавит новую запись.

Другой вариант хранения: map[int]*User. Тогда чтение возвращает указатель, через который можно менять объект:

users := map[int]*User{
    42: &User{Visits: 1},
}

if user, ok := users[42]; ok && user != nil {
    user.Visits++
}

У этого варианта другая семантика. Несколько записей или других переменных могут ссылаться на одного пользователя. Изменение объекта будет видно через все такие ссылки. Сам указатель в map также может быть nil.

Присваивание map и передача в функцию не копируют записи

После обычного присваивания обе переменные обращаются к одной map:

original := map[string]int{"go": 1}
other := original

other["go"] = 10
fmt.Println(original["go"]) // 10

То же происходит при передаче в функцию. В Go аргументы передаются по значению, но значение map ссылается на общее хранилище записей:

func increment(counts map[string]int, key string) {
    counts[key]++
}

Вызов increment(original, "go") изменит запись в исходной map. Указатель *map[string]int для этого не нужен.

Однако присваивание параметру другой map не заменит переменную вызывающего кода:

func reset(counts map[string]int) {
    counts = make(map[string]int)
}

Вызов reset(original) оставит исходные записи на месте. Если функция должна удалить их из общей map, используйте clear(counts). Если должна создать замену, верните новую map и присвойте результат снаружи.

Как скопировать map через maps.Clone

Для независимого набора записей есть maps.Clone:

original := map[string]int{"go": 1}
copied := maps.Clone(original)

copied["go"] = 10
fmt.Println(original["go"]) // 1

Пакет maps появился в стандартной библиотеке Go 1.21. Его функция Clone делает поверхностную копию: значения копируются обычным присваиванием. Это не глубокое копирование объектов.

Если значениями являются слайсы, их массивы могут остаться общими:

original := map[string][]int{
    "ids": {1, 2},
}
copied := maps.Clone(original)

copied["ids"][0] = 99
fmt.Println(original["ids"][0]) // 99

Для map[string][]int можно отдельно скопировать каждый слайс через slices.Clone:

original := map[string][]int{
    "ids": {1, 2},
}
copied := make(map[string][]int, len(original))
for key, value := range original {
    copied[key] = slices.Clone(value)
}

copied["ids"][0] = 99
fmt.Println(original["ids"][0]) // 1

Теперь изменения элементов скопированного слайса не затрагивают исходный. Для вложенных map потребуется копирование вложенных map. Глубина копирования зависит от устройства ваших данных.

Как сравнивать map

Оператор == разрешает сравнить map только с nil. Выражение first == second для двух map не скомпилируется.

Для сравнимых значений используйте maps.Equal:

first := map[string]int{"go": 2, "sql": 1}
second := map[string]int{"sql": 1, "go": 2}

fmt.Println(maps.Equal(first, second)) // true

Порядок вставки роли не играет: функция проверяет одинаковые пары ключей и значений. Пустая и nil map для maps.Equal равны.

Если значения нельзя сравнить через ==, задайте правило в maps.EqualFunc. Например, для слайсов чисел:

first := map[string][]int{"ids": {1, 2}}
second := map[string][]int{"ids": {1, 2}}

equal := maps.EqualFunc(first, second, slices.Equal[[]int])
fmt.Println(equal) // true

Доступ из нескольких горутин

Несколько горутин могут читать одну map, если никто её не изменяет. Саму заполненную map нужно передать читателям с корректной синхронизацией, например создать её до запуска горутин.

Ошибка concurrent map writes: как защитить map

Когда есть запись, доступ нужно согласовать. Это относится и к записи по разным ключам. Ошибка может проявиться как fatal error: concurrent map writes или fatal error: concurrent map read and map write. Отсутствие такого сообщения не доказывает корректность программы.

Начать можно с обычной map и sync.Mutex:

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu     sync.Mutex
	counts map[string]int
}

func (c *Counter) Add(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()

	if c.counts == nil {
		c.counts = make(map[string]int)
	}
	c.counts[key]++
}

func (c *Counter) Get(key string) int {
	c.mu.Lock()
	defer c.mu.Unlock()

	return c.counts[key]
}

func main() {
	var counter Counter
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add("go")
		}()
	}

	wg.Wait()
	fmt.Println(counter.Get("go")) // 100
}

Блокировка охватывает всю операцию увеличения счётчика: чтение, прибавление и запись. Если защитить чтение и запись по отдельности, горутины смогут потерять обновления. Одна прочитает старое число до того, как другая запишет новое.

Все обращения к counts должны соблюдать одну схему блокировки. Проверка len, обход и копирование тоже требуют защиты, когда map может изменяться параллельно. Возвращать саму внутреннюю map из метода опасно: вызывающий код сможет обойти блокировку.

Методы используют указатель *Counter, чтобы не копировать мьютекс. Эту ошибку подробнее разбираю в статье Копирование mutex в Go.

Для поиска гонок запускайте тесты с детектором:

$ go test -race ./...

Детектор проверяет выполнявшиеся пути кода. Тесты должны запускать конкурентные обращения, иначе детектор их не проверит.

sync.Map тоже поддерживает конкурентный доступ, но не делает составные действия автоматически атомарными. Например, отдельные Load и Store не превращаются в атомарное увеличение счётчика. Документация sync.Map рекомендует большинству программ обычную map с отдельной синхронизацией. Выбор между Mutex, RWMutex и sync.Map зависит от операций и нагрузки; для базового счётчика достаточно показанного решения.

Вложенные map нужно создавать отдельно

Инициализация внешней map не создаёт внутренние:

groups := make(map[string]map[int]string)
groups["admins"][42] = "Анна" // Паника: внутренняя map равна nil.

Перед первой записью создайте map для группы:

groups := make(map[string]map[int]string)

if groups["admins"] == nil {
    groups["admins"] = make(map[int]string)
}
groups["admins"][42] = "Анна"

При чтении вложенных map всё иначе. Выражение groups["unknown"][42] вернёт пустую строку: первое чтение даст nil, второе получит нулевое значение из этой nil map. Если нужно отличить отсутствующую группу от отсутствующего пользователя, проверяйте ok на каждом уровне.

Для группировки в слайсы отдельная инициализация каждого значения обычно не нужна:

groups := make(map[string][]string)
groups["admins"] = append(groups["admins"], "Анна")

Здесь отсутствующий ключ возвращает nil-слайс, а append умеет добавлять элементы в такой слайс. Результат нужно записать обратно в map.

Когда нужна map[string]any

Тип map[string]any позволяет хранить значения разных типов по строковым ключам:

attributes := map[string]any{
    "name":   "Анна",
    "visits": 3,
}

name, ok := attributes["name"].(string)
fmt.Println(name, ok) // Анна true

any обозначает тот же тип, что и interface{}. При чтении результат имеет тип any; чтобы работать с ним как со строкой, пример использует утверждение типа с проверкой ok. Для объектов с заранее известными полями обычно удобнее структура.

Такая map бывает полезна при разборе произвольного JSON. Преобразование map в JSON через json.Marshal и обратный разбор через json.Unmarshal показаны в статье JSON в Go: encoding/json, теги структур и omitempty.

Итог

Для обычного справочника начните с make(map[K]V) или литерала. При чтении выбирайте между одним результатом и value, ok: это зависит от того, нужно ли отличать отсутствие ключа от нулевого значения.

Перед исправлением странного поведения проверьте, инициализирована ли map, кто ещё на неё ссылается и есть ли конкурентная запись. Присваивание не создаёт независимую копию, range не гарантирует порядок, а блокировка должна защищать операцию целиком. Эти правила объясняют большинство ошибок из примеров выше.

Документация


Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: