Урок курса

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

Golang с нуля

В предыдущих уроках мы работали со срезами: добавляли элементы, копировали базовый массив, удаляли по индексу. Срез хорош, когда важен порядок и доступ по позиции. Но если нужно быстро найти значение по имени — по слову, идентификатору, коду — срез заставляет перебирать всё подряд. Map решает именно эту задачу: O(1) доступ по произвольному ключу.

Тип map[K]V, литерал и make: инициализация и nil-паника

Тип map[string]int читается буквально: отображение из string в int. Ключом может быть любой сравниваемый тип — строка, число, булево, указатель; срез или другая map ключом быть не могут.

Создать map можно двумя способами. Первый — литерал с начальными данными:

scores := map[string]int{
    "alice": 42,
    "bob":   17,
}

Второй — make, когда начальных данных нет:

scores := make(map[string]int)

Оба варианта дают готовую к работе map. Разница лишь в том, есть ли начальные пары.

А вот что будет, если просто объявить переменную без инициализации:

var scores map[string]int
scores["alice"] = 42 // panic: assignment to entry in nil map

Нулевое значение map — nil. Читать из nil-map можно (вернётся нулевое значение типа), но запись вызывает panic немедленно. Это принципиально отличается от nil-среза, в который нельзя записать через индекс, но можно передать в append.

Практическое правило: перед любой записью map должна быть инициализирована — либо литералом, либо через make. Выбор между ними прост: если начальные пары известны заранее — литерал; если map наполняется в процессе работы программы — make.

Чтение, запись, идиома value-ok и delete

Запись и обновление — один синтаксис:

m := make(map[string]int)
m["hits"] = 1   // новая пара
m["hits"] = 2   // перезапись; теперь m["hits"] == 2

Чтение по ключу:

v := m["hits"] // v == 2
v  = m["miss"] // ключа нет — v == 0

Вот где возникает проблема. Допустим, вы храните количество ошибок и ещё не фиксировали ни одной. m["errors"] вернёт 0 — но это нулевое значение типа, а не подтверждение, что ключ вообще существует. Отличить одно от другого помогает двухзначная форма:

v, ok := m["errors"]
if ok {
    fmt.Println("ключ есть, значение:", v)
} else {
    fmt.Println("ключа нет")
}

Если ключ присутствует, ok == true и v — реальное значение. Если ключа нет, ok == false и v == 0. Когда важно только наличие ключа, переменную значения отбрасывают: _, ok := m[key].

Ситуация, где это критично: ключ "errors" был явно добавлен со значением 0 (например, как сброс счётчика). Однозначная форма v := m["errors"] даст 0 в обоих случаях — и когда ключа нет, и когда он есть с нулём. Только v, ok позволяет их различить.

Удаление ключа:

delete(m, "hits")

delete — встроенная функция, не метод. Если ключа в map нет, вызов просто ничего не делает — паники не будет. После удаления v, ok := m["hits"] вернёт ok == false.

Обход map через range и неопределённость порядка итерации

Синтаксис range для map выглядит так же, как для среза, который вы уже использовали:

m := map[string]int{"alice": 3, "bob": 1, "carol": 7}

for k, v := range m {
    fmt.Println(k, v)
}

Но есть принципиальное отличие от среза: спецификация Go не задаёт порядок обхода map и не гарантирует, что два последовательных обхода дадут одинаковую последовательность пар. Два прогона могут дать как одинаковый, так и разный порядок — полагаться на него нельзя в любом случае.

Если нужен только ключ:

for k := range m {
    fmt.Println(k)
}

Если нужно только значение:

for _, v := range m {
    fmt.Println(v)
}

Пустой идентификатор _ работает здесь так же, как и в range по срезу — отбрасывает переменную, которая не нужна.

Практическое следствие: тест, который проверяет порядок строк в выводе, построенном по range по map, ненадёжен и потенциально нестабилен — даже если на конкретной машине он проходит несколько раз подряд. Корректный тест проверяет наличие нужных ключей и значений в map, а не порядок их перечисления.

Подсчёт частот токенов в map без инициализации отдельных ключей

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

tokens := []string{"go", "map", "go", "range", "go", "map"}

freq := make(map[string]int)
for _, t := range tokens {
    freq[t]++
}

// freq: {"go": 3, "map": 2, "range": 1}

Ключевой момент — строка freq[t]++. Когда токен встречается впервые, ключа t в map ещё нет. freq[t] возвращает нулевое значение int, то есть 0. Затем ++ увеличивает его до 1, и пара сохраняется. При следующем вхождении того же токена ключ уже существует, и счётчик увеличивается с реального значения.

Никакой предварительной проверки if _, ok := freq[t]; !ok { freq[t] = 0 } не нужно — нулевое значение типа делает её избыточной. Это намеренное свойство map в Go, и паттерн freq[t]++ — стандартный способ агрегации.

После заполнения map можно обойти результат через range:

for word, count := range freq {
    fmt.Printf("%s: %d\n", word, count)
}

Порядок вывода будет произвольным — что именно ожидать от range по map, вы уже знаете.

Попробуйте решить

Что вернёт выражение v, ok := m["missing"] для map m := map[string]int{"a": 1}, где ключ "missing" отсутствует?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку