$ sudo teach IT
МОДУЛЬ 3 · УРОК 3.2

Map (словари / хэш-таблицы)

Храним данные парами «ключ — значение»: создаём, читаем, удаляем и перебираем

В прошлом уроке мы изучили массивы и срезы — структуры, в которых элементы хранятся по порядковому номеру (индексу). Это удобно, но далеко не всегда. Представьте телефонную книгу: вы ищете номер не по позиции «третий контакт», а по имени — «Алексей». Или словарь: вы открываете его не на странице 47, а на слове «map». Именно для таких задач существует структура данных map — она же словарь, она же хэш-таблица. Map хранит пары «ключ → значение» и позволяет мгновенно находить значение по ключу. Сегодня мы разберём map от создания до реальной практики — напишем счётчик слов в тексте.

📖 Что такое map и зачем он нужен

Map — это коллекция, где каждый элемент состоит из двух частей: ключа и значения. Ключ — это «имя», по которому вы ищете данные. Значение — это сами данные. Работает это как реальный словарь: вы знаете слово (ключ) и хотите узнать его перевод (значение).

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

Телефонная книга: имя → номер телефона

Словарь: слово → перевод

Настройки: название параметра → его значение

Корзина магазина: товар → количество

В отличие от среза, где данные лежат по числовому индексу (0, 1, 2...), в map ключом может быть строка, число или любой сравниваемый тип. Это даёт огромную гибкость.

💡 Подсказка: map в Go — это аналог dict в Python, HashMap в Java или объектов-словарей в JavaScript. Если вы встречали эти структуры в других языках, map в Go покажется вам знакомым.

🛠 Создание map

В Go есть два основных способа создать map. Первый и самый распространённый — с помощью функции make:

package main

import "fmt"

func main() {
    // Создаём пустой map: ключи — строки, значения — строки
    phonebook := make(map[string]string)

    fmt.Println(phonebook) // map[]
}

Разберём синтаксис. make(map[string]string) — мы вызываем функцию make и передаём ей тип: map[string]string. В квадратных скобках указан тип ключа (string), а после скобок — тип значения (string). Получается map, где и ключи, и значения — строки.

Типы ключа и значения могут быть разными. Вот map, где ключ — строка, а значение — число:

// Ключ — строка (название товара), значение — int (цена)
prices := make(map[string]int)

Второй способ — создать map сразу с данными, используя литерал:

package main

import "fmt"

func main() {
    // Создаём map с начальными данными
    capitals := map[string]string{
        "Россия":     "Москва",
        "Казахстан":  "Астана",
        "Узбекистан": "Ташкент",
    }

    fmt.Println(capitals)
    // map[Казахстан:Астана Россия:Москва Узбекистан:Ташкент]
}

Обратите внимание: после последнего элемента ("Ташкент") стоит запятая. В Go это обязательно, если закрывающая скобка } стоит на новой строке. Без этой запятой код не скомпилируется.

💡 Подсказка: никогда не объявляйте map через var m map[string]int без инициализации. Такой map будет nil, и попытка записи в него вызовет панику (аварийное завершение программы). Всегда используйте make или литерал.

Вот наглядная демонстрация опасности nil-map:

var m map[string]int // m == nil
// m["ключ"] = 42    // ПАНИКА! assignment to entry in nil map

m = make(map[string]int) // Теперь m инициализирован
m["ключ"] = 42           // Всё работает

➕ Добавление и изменение элементов

Добавить элемент в map очень просто — присваиваем значение по ключу:

package main

import "fmt"

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

    // Добавляем записи
    phonebook["Алексей"] = "+7-900-111-22-33"
    phonebook["Мария"] = "+7-900-444-55-66"
    phonebook["Дмитрий"] = "+7-900-777-88-99"

    fmt.Println(phonebook)
    // map[Алексей:+7-900-111-22-33 Дмитрий:+7-900-777-88-99 Мария:+7-900-444-55-66]
}

Синтаксис phonebook["Алексей"] = "+7-900-111-22-33" работает по принципу: если ключа "Алексей" ещё нет — он создаётся. Если такой ключ уже есть — его значение перезаписывается. Одна и та же запись и для добавления, и для обновления:

package main

import "fmt"

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

    scores["Алексей"] = 85  // Добавляем
    fmt.Println(scores)     // map[Алексей:85]

    scores["Алексей"] = 92  // Перезаписываем
    fmt.Println(scores)     // map[Алексей:92]
}

Никаких специальных методов вроде add() или put() — просто присваивание по ключу. Просто и понятно.

📖 Чтение значений из map

Чтобы прочитать значение, обращаемся к map по ключу — точно так же, как к срезу по индексу:

package main

import "fmt"

func main() {
    capitals := map[string]string{
        "Россия":     "Москва",
        "Казахстан":  "Астана",
        "Узбекистан": "Ташкент",
    }

    fmt.Println(capitals["Россия"])     // Москва
    fmt.Println(capitals["Казахстан"])  // Астана
}

А что произойдёт, если мы обратимся по ключу, которого нет? В отличие от среза (где выход за границы вызывает панику), map просто вернёт нулевое значение для своего типа:

fmt.Println(capitals["Германия"]) // "" (пустая строка — нулевое значение для string)

Для map[string]int отсутствующий ключ вернёт 0, для map[string]bool — false и так далее. Это удобно, но может привести к проблеме: как отличить «ключ существует, и его значение равно нулю» от «ключа вообще нет»? Именно для этого существует специальная проверка, которую мы разберём в следующей секции.

🔍 Проверка существования ключа: value, ok

Это один из самых важных приёмов работы с map в Go. При обращении к map можно получить два значения:

value, ok := m[key]

Здесь value — это значение по ключу (или нулевое, если ключа нет), а ok — булево значение: true, если ключ существует, и false, если его нет. Давайте посмотрим на полном примере:

package main

import "fmt"

func main() {
    scores := map[string]int{
        "Алексей": 92,
        "Мария":   0,
    }

    // Проверяем существующий ключ
    value, ok := scores["Алексей"]
    fmt.Println(value, ok) // 92 true

    // Проверяем ключ со значением 0
    value, ok = scores["Мария"]
    fmt.Println(value, ok) // 0 true — ключ ЕСТЬ, значение равно 0

    // Проверяем несуществующий ключ
    value, ok = scores["Дмитрий"]
    fmt.Println(value, ok) // 0 false — ключа НЕТ
}

Видите разницу? У Марии оценка 0 и ok == true — ключ существует. У Дмитрия тоже 0, но ok == false — такого ключа нет. Без переменной ok мы бы не смогли их различить.

На практике эту конструкцию чаще всего комбинируют с if:

package main

import "fmt"

func main() {
    capitals := map[string]string{
        "Россия":    "Москва",
        "Казахстан": "Астана",
    }

    country := "Россия"

    if capital, ok := capitals[country]; ok {
        fmt.Printf("Столица %s — %s\n", country, capital)
    } else {
        fmt.Printf("Страна %s не найдена\n", country)
    }
    // Вывод: Столица Россия — Москва
}

Здесь мы использовали if с инициализацией: объявили capital и ok прямо внутри условия, а после точки с запятой проверили ok. Это самый идиоматичный (правильный с точки зрения стиля Go) способ проверки ключа.

💡 Подсказка: если вам не нужно само значение, а только проверить наличие ключа, используйте пустой идентификатор _ вместо первой переменной: _, ok := m[key].

🗑 Удаление элементов: функция delete

Для удаления элемента из map используется встроенная функция delete. Она принимает два аргумента: сам map и ключ, который нужно удалить:

package main

import "fmt"

func main() {
    fruits := map[string]int{
        "яблоки":   5,
        "бананы":   3,
        "апельсины": 8,
    }

    fmt.Println("До удаления:", fruits)
    // До удаления: map[апельсины:8 бананы:3 яблоки:5]

    delete(fruits, "бананы")

    fmt.Println("После удаления:", fruits)
    // После удаления: map[апельсины:8 яблоки:5]
}

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

delete(fruits, "манго") // Ключа "манго" нет — ничего не происходит, ошибки нет

Это удобно: не нужно предварительно проверять, существует ли ключ перед удалением.

📏 Длина map: функция len

Как и для срезов, функция len возвращает количество элементов в map:

package main

import "fmt"

func main() {
    colors := map[string]string{
        "red":   "красный",
        "green": "зелёный",
        "blue":  "синий",
    }

    fmt.Println(len(colors)) // 3

    delete(colors, "red")
    fmt.Println(len(colors)) // 2
}

🔄 Итерация по map: перебираем все элементы

Чтобы обойти все пары «ключ — значение» в map, используется цикл for range — тот же, что и для срезов:

package main

import "fmt"

func main() {
    population := map[string]int{
        "Москва":   13_000_000,
        "Ташкент":  3_000_000,
        "Астана":   1_400_000,
    }

    for city, people := range population {
        fmt.Printf("%s: %d жителей\n", city, people)
    }
}

На каждой итерации city получает ключ, а people — значение. Но есть важная особенность:

💡 Подсказка: порядок обхода map в Go не гарантирован. При каждом запуске программы элементы могут идти в разном порядке. Это сделано специально. Если вам нужен конкретный порядок — сначала соберите ключи в срез, отсортируйте его, а затем обходите map по отсортированным ключам.

Если вам нужны только ключи, значение можно заменить на _:

// Только ключи
for city := range population {
    fmt.Println(city)
}

А если нужны только значения, пропускаем ключ:

// Только значения
for _, people := range population {
    fmt.Println(people)
}

Давайте напишем более содержательный пример — посчитаем общую стоимость корзины товаров:

package main

import "fmt"

func main() {
    cart := map[string]int{
        "Хлеб":   55,
        "Молоко":  89,
        "Сыр":    320,
        "Яйца":   115,
    }

    total := 0
    for product, price := range cart {
        fmt.Printf("  %s — %d руб.\n", product, price)
        total += price
    }
    fmt.Printf("Итого: %d руб.\n", total)
}

🧩 Map со срезами в качестве значений

Значением в map может быть что угодно, включая срез. Это позволяет строить более сложные структуры. Например, у каждого студента несколько оценок:

package main

import "fmt"

func main() {
    // Ключ — имя студента, значение — срез оценок
    grades := map[string][]int{
        "Алексей": {5, 4, 5, 5, 4},
        "Мария":   {5, 5, 5, 4, 5},
        "Дмитрий": {3, 4, 4, 3, 5},
    }

    // Вычисляем средний балл для каждого студента
    for name, marks := range grades {
        sum := 0
        for _, mark := range marks {
            sum += mark
        }
        avg := float64(sum) / float64(len(marks))
        fmt.Printf("%s: средний балл %.1f\n", name, avg)
    }
}

Тип map[string][]int читается так: map, где ключ — строка, а значение — срез целых чисел. Вложенный цикл for _, mark := range marks перебирает оценки каждого студента.

Также можно добавлять элементы к срезу внутри map:

grades["Алексей"] = append(grades["Алексей"], 5) // Добавили ещё одну оценку

📊 Обход map в отсортированном порядке

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

package main

import (
    "fmt"
    "sort"
)

func main() {
    population := map[string]int{
        "Москва":   13_000_000,
        "Астана":   1_400_000,
        "Ташкент":  3_000_000,
        "Минск":    2_000_000,
    }

    // 1. Собираем ключи в срез
    cities := make([]string, 0, len(population))
    for city := range population {
        cities = append(cities, city)
    }

    // 2. Сортируем ключи
    sort.Strings(cities)

    // 3. Обходим map в алфавитном порядке
    for _, city := range cities {
        fmt.Printf("%s: %d\n", city, population[city])
    }
    // Астана: 1400000
    // Минск: 2000000
    // Москва: 13000000
    // Ташкент: 3000000
}

Пакет sort мы подробнее рассмотрим позже, но уже сейчас вы видите, как sort.Strings сортирует срез строк в алфавитном порядке. Теперь обход происходит предсказуемо: от А до Я.

🏋️ Практика: счётчик слов в тексте

Теперь давайте применим всё, что узнали, в реальной задаче. Напишем программу, которая считает, сколько раз каждое слово встречается в тексте. Это классическая задача, которая идеально решается с помощью map.

Идея простая: берём текст, разбиваем его на слова, и для каждого слова увеличиваем счётчик в map на единицу. Ключ — слово, значение — количество его появлений.

package main

import (
    "fmt"
    "strings"
)

func main() {
    text := "кот сидел на коврике а коврик лежал на полу кот любил этот коврик"

    // Разбиваем текст на слова
    words := strings.Fields(text)

    // Создаём map для подсчёта
    counter := make(map[string]int)

    // Считаем каждое слово
    for _, word := range words {
        counter[word]++
    }

    // Выводим результат
    for word, count := range counter {
        fmt.Printf("%-10s — %d раз(а)\n", word, count)
    }
}

Разберём ключевые моменты:

strings.Fields(text) — функция из пакета strings, которая разбивает строку на слова по пробелам. Возвращает срез строк: ["кот", "сидел", "на", "коврике", ...].

counter[word]++ — самая элегантная часть. Если ключа word ещё нет в map, Go автоматически создаёт его с нулевым значением (0 для int), и затем увеличивает на 1. Не нужно проверять, есть ли слово в map!

%-10s — форматирование в Printf: выводит строку, выровненную влево, с минимальной шириной 10 символов. Это делает вывод аккуратным столбиком.

Результат будет примерно таким (порядок может отличаться):

кот        — 2 раз(а)
сидел      — 1 раз(а)
на         — 2 раз(а)
коврике    — 1 раз(а)
а          — 1 раз(а)
коврик     — 2 раз(а)
лежал      — 1 раз(а)
полу       — 1 раз(а)
любил      — 1 раз(а)
этот       — 1 раз(а)

Обратите внимание: «коврике» и «коврик» считаются как разные слова, потому что у них разная форма. Чтобы их объединить, нужно приводить слова к начальной форме — но это уже задача для продвинутого уровня.

Давайте улучшим нашу программу: приведём все слова к нижнему регистру (чтобы «Кот» и «кот» считались одним словом) и отсортируем вывод:

package main

import (
    "fmt"
    "sort"
    "strings"
)

func main() {
    text := "Go это язык программирования Go быстрый Go простой и Go надёжный"

    words := strings.Fields(text)
    counter := make(map[string]int)

    for _, word := range words {
        lower := strings.ToLower(word) // Приводим к нижнему регистру
        counter[lower]++
    }

    // Собираем ключи и сортируем
    keys := make([]string, 0, len(counter))
    for word := range counter {
        keys = append(keys, word)
    }
    sort.Strings(keys)

    // Выводим в алфавитном порядке
    fmt.Println("=== Частота слов ===")
    for _, word := range keys {
        fmt.Printf("%-20s %d\n", word, counter[word])
    }
    fmt.Printf("\nВсего уникальных слов: %d\n", len(counter))
}

Теперь «Go» в начале предложения и «go» в середине будут считаться одним словом. Программа стала надёжнее.

⚠️ Частые ошибки новичков

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

Ошибка 1: запись в nil-map

var m map[string]int
m["ключ"] = 42 // ПАНИКА: assignment to entry in nil map

Решение: всегда инициализируйте map через make или литерал.

Ошибка 2: полагаться на порядок элементов

// НЕ делайте так! Порядок будет разным при каждом запуске
m := map[int]string{1: "a", 2: "b", 3: "c"}
for k, v := range m {
    fmt.Println(k, v) // Порядок непредсказуем!
}

Решение: если нужен порядок, используйте отсортированные ключи (как мы показали выше).

Ошибка 3: сравнение map через ==

a := map[string]int{"x": 1}
b := map[string]int{"x": 1}
// fmt.Println(a == b) // Ошибка компиляции! Map нельзя сравнивать через ==

Map можно сравнить только с nil. Для сравнения двух map нужно вручную пройтись по всем ключам и сравнить значения.

📝 Шпаргалка по map

Собрали все операции с map в одном месте для быстрого повторения:

// Создание
m := make(map[string]int)            // Пустой map через make
m := map[string]int{"a": 1, "b": 2} // Литерал с данными

// Добавление / изменение
m["ключ"] = 42

// Чтение
value := m["ключ"]

// Проверка существования ключа
value, ok := m["ключ"]
if ok {
    // Ключ существует
}

// Идиоматичная проверка
if value, ok := m["ключ"]; ok {
    fmt.Println(value)
}

// Удаление
delete(m, "ключ")

// Длина
fmt.Println(len(m))

// Обход всех элементов
for key, value := range m {
    fmt.Println(key, value)
}

// Только ключи
for key := range m {
    fmt.Println(key)
}

// Только значения
for _, value := range m {
    fmt.Println(value)
}

📋 Итоги урока

  • Map хранит данные парами «ключ → значение» и позволяет мгновенно находить значение по ключу
  • Создавать map нужно через make(map[K]V) или литерал — никогда не пишите в nil-map
  • Добавление и обновление — одна и та же операция: m[key] = value
  • Конструкция value, ok := m[key] позволяет отличить «ключ есть, значение нулевое» от «ключа нет»
  • Функция delete(m, key) удаляет элемент; удаление несуществующего ключа безопасно
  • Порядок обхода map через for range не гарантирован — для сортировки используйте срез ключей
  • Map идеально подходит для подсчёта частоты: counter[word]++ работает даже для нового ключа
  • Мы написали счётчик слов — классическую задачу, которая элегантно решается через map

В следующем уроке мы займёмся строками. В Go строки — это не просто текст, а последовательность байтов с поддержкой Unicode. Вы узнаете, как работают руны, чем len для строк отличается от того, что вы ожидаете, и научитесь разбивать, склеивать, искать и заменять части текста с помощью пакета strings. Строки — один из самых используемых типов данных, и после урока 3.3 вы будете чувствовать себя с ними уверенно.