voidlearn
 

1.3Мапы

Разберём контракт map и отделим гарантии языка от устройства конкретной версии рантайма.

Что такое мапа и какой у неё контракт

Под map[K]V лежит хеш-таблица. Контракт языка: амортизированные O(1) на вставку, поиск и удаление; ключи должны быть comparable; порядок обхода не определён; мапа не потокобезопасна. Переменная типа map[K]V хранит один указатель (8 байт) на структуру runtime.hmap в куче. Копируешь мапу, копируешь только этот указатель, а nil-мапа и есть нулевой указатель.

m := map[string]int{"a": 1}
m2 := m                 // скопировали 8 байт указателя
m2["b"] = 2
fmt.Println(m)          // map[a:1 b:2] - одна и та же таблица
fmt.Println(unsafe.Sizeof(m))   // 8

Почти все «странности» мапы (запрет на &m[k], случайный порядок обхода, падение всей программы при конкурентной записи, память, которая не возвращается после удаления ключей) растут из устройства хеш-таблицы: это прямые следствия, а не капризы дизайнеров. Разберёшь устройство, и эти вопросы перестанут быть списком для зубрёжки.

Классическое устройство: hmap и бакеты (Go до 1.24)

Исторически (а в первых версиях со Swiss-мапами ещё и с GOEXPERIMENT=noswissmap) мапа Go устроена как массив бакетов с цепочками overflow-бакетов. Бакет вмещает ровно 8 пар ключ-значение.

hmap - то, на что указывает переменная map[K]V count int flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets ptr oldbuckets ptr nevacuate uintptr extra *mapextra count - len(m) B - 2^B бакетов hash0 - seed, свой у каждой мапы flags - в том числе hashWriting buckets: массив 2^B bucket 0 bucket 1 bucket 2 bucket 3 низкие B бит хеша выбирают бакет bmap - один бакет, 8 слотов tophash [8]uint8 - старший байт хеша 2e 91 0 a7 3c 0 0 0 keys [8]K - восемь ключей подряд values [8]V - восемь значений подряд overflow *bmap девятый ключ уедет в overflow-бакет Ключи и значения лежат отдельными блоками - чтобы не тратить padding между K и V. Поиск: hash(key, hash0) → низкие B бит дают бакет → сравниваем tophash всех 8 слотов (быстрый фильтр по 1 байту) → только для совпавших сравниваем сам ключ полностью → нет совпадения, идём в overflow-бакет по цепочке.
hmap и bmap. tophash кэширует старший байт хеша: неподходящие слоты отбрасываются, ключи никто не трогает, в разбросанную по кэш-линиям память лезть не приходится.

Сравнивать длинные строки-ключи дорого. Поэтому рядом с восемью слотами бакета Go держит массив из восьми байт: tophash хранит старший байт хеша каждого лежащего в слоте ключа, запомненный заранее. Поиск сначала пробегает эти восемь байт (одна кэш-линия, никаких разыменований) и сравнивает ключ целиком только там, где байт совпал. Значение 0 в tophash служебное, оно помечает пустой слот.

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

Если бы бакет хранил [8]struct{K; V}, то между K и V появился бы padding для выравнивания. Для map[int64]int8 это дало бы 8 байт padding на каждую пару, то есть бакет вырос бы почти вдвое. Раздельные массивы [8]K и [8]V дают padding максимум один раз на бакет. Мелочь, но именно такие мелочи спрашивают, когда хотят проверить, читал ли ты рантайм.

Хеш-функция, seed и коллизии - подробности

Go выбирает хеш-функцию по типу ключа на этапе компиляции: для строк и байтовых срезов берётся memhash (на amd64 это вариант на инструкциях AES-NI, очень быстрый), числа прогоняются через простое смешивание, у структур комбинируются хеши полей.

Каждая мапа при создании получает свой случайный seed (hash0). Это защита от hash flooding: без seed злоумышленник, знающий алгоритм, подобрал бы ключи с одинаковыми хешами и превратил все операции в O(n). Классическая DoS-атака на веб-сервер, который кладёт заголовки запроса в мапу.

Коллизии Go разрешает методом цепочек (не по одному элементу, а бакетами по 8): сначала заполняются 8 слотов бакета, при переполнении выделяется overflow-бакет, дополнительная корзина на те же 8 слотов, она прицепляется к основной как вагон к вагону. Поиск идёт по цепочке, пока ключ не найдётся или вагоны не кончатся.

В Swiss Tables (Go 1.24+) подход другой, открытая адресация: лишних вагонов нет, при занятом месте элемент ложится в другую группу того же массива по заранее известному правилу («пробинг»). Разбросанных по куче overflow-бакетов и указателей между ними больше нет, значит и промахов кэша меньше.

Рост и эвакуация

Мапа растёт, когда срабатывает одно из двух условий:

  • Превышен load factor. Порог в Go: count > 6.5 * 2^B, то есть в среднем больше 6.5 элементов на бакет при вместимости 8. Тогда B растёт на единицу, а бакетов становится вдвое больше.
  • Слишком много overflow-бакетов при нормальной заполненности. Так бывает после массовых удалений. Тогда включается sameSizeGrow: массив бакетов того же размера, но элементы перекладываются плотно, и мусорные overflow-цепочки исчезают.

Переселение не происходит целиком за одну операцию. Разом оно убило бы время отклика на больших мапах: одна невезучая вставка встала бы на сотни миллисекунд. Вместо этого старый массив остаётся в oldbuckets, а каждая вставка/удаление эвакуирует один-два бакета. Пока эвакуация идёт, чтение проверяет оба массива.

Рост B=2 → B=3: oldbuckets жив, элементы переезжают порциями oldbuckets (4 шт.) 0 - переехал 1 2 3 nevacuate=1 buckets (8 шт.) 0 (X) 1 2 3 4 (Y) 5 6 7 бит B старого хеша = 0 → X бит B = 1 → Y (i + 2^oldB) Каждая запись или удаление эвакуирует один-два старых бакета. Чтение при этом ищет и в oldbuckets, и в buckets. Пока идёт рост, память занята обоими массивами. Триггеры роста: count > 6.5 * 2^B (удвоение) - либо слишком много overflow-бакетов при нормальном count (sameSizeGrow: тот же размер, но плотная перекладка - так лечится «дырявая» мапа после массовых удалений).
Инкрементальный рост. Старый бакет i расщепляется ровно на два новых, i и i + 2^oldB: добавился ещё один бит хеша. Из-за этого вставка в мапу и получается амортизированной O(1), а не гарантированной.

Go 1.24: Swiss Tables

В Go 1.24 мапу переписали целиком: вместо бакетов с overflow-цепочками пришёл Swiss Table, дизайн из absl::flat_hash_map (Google, 2017). Идея одна: отделить метаданные от данных, чтобы за одно обращение к памяти проверять сразу восемь слотов.

Swiss Table: хеш делится на две части h1 - старшие 57 бит выбирают группу (и последовательность проб) h2 - младшие 7 бит это control-байт слота control word группы: 8 байт, читается ОДНИМ машинным словом 0x2e 0x91 EMPTY 0x2e 0xa7 DELETED EMPTY EMPTY слоты группы: ключи и значения k0 v0 k1 v1 k3 v3 k4 v4 Ищем ключ с h2 = 0x2e: одна операция сравнивает все 8 control-байт разом и даёт битовую маску {0, 3}. Полное сравнение ключей делаем только для этих двух слотов - остальные шесть даже не читаем из памяти. EMPTY в группе означает «дальше искать бессмысленно» - поиск отсутствующего ключа заканчивается сразу. DELETED - надгробие: слот свободен для вставки, но поиск через него продолжается (иначе порвалась бы цепочка проб). Нет overflow-бакетов и нет указателей между ними: группы лежат подряд в одном массиве - меньше промахов кэша.
Swiss Table. Трюк в том, что 8 однобайтовых меток укладываются в одно 64-битное слово (на amd64 - в SSE-регистр), и группа отфильтровывается буквально парой инструкций без ветвлений.
Старая мапа (≤ Go 1.23) Swiss Tables (Go 1.24+)
Разрешение коллизий цепочки overflow-бакетов открытая адресация, квадратичный пробинг по группам
Метаданные tophash [8]uint8 внутри бакета control word: 8 байт отдельно от данных
Проверка группы цикл по 8 слотам одно слово / SIMD (одна инструкция процессора обрабатывает сразу несколько значений), битовая маска совпадений
Большие мапы один массив бакетов + инкрементальная эвакуация разбита на независимые таблицы (directory), растёт по одной
Выигрыш - чтение больших мап до ~30–60 % быстрее, вставка ~30 %, память примерно та же
Что важно сказать про 1.24 на собесе

Изменилась только реализация: язык, семантика и гарантии те же. Порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, &m[k] по-прежнему запрещён. Разница видна в производительности и в том, что конкретные последовательности итерации поменялись (тесты, которые на них завязались, сломались, хотя некорректны они были и раньше). Откатиться можно было через GOEXPERIMENT=noswissmap, пока опция жила: в Go 1.27 её уже нет. Заодно в 1.24 переписали sync.Map на HashTrieMap.

Почему нельзя взять адрес значения: &m[key]

Потому что адрес нестабилен. Мапа растёт, начинается эвакуация, и пары ключ-значение физически переезжают в другой массив бакетов. Разреши язык &m[k], и после первой же вставки указатель смотрел бы в старую память: тихие баги ровно того сорта, которого Go старается не иметь. У слайса такой проблемы нет, там переезжает весь массив сразу, а старый остаётся валидным до сборки.

m := map[string]int{"a": 1}
// p := &m["a"]        // ошибка компиляции: invalid operation: cannot take address of m["a"]
// m["a"]++            // а это можно: компилятор разворачивает в чтение + запись
// m["a"] += 5         // тоже можно

type S struct {
    N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2       // ошибка: cannot assign to struct field ms["a"].N in map
v := ms["a"]; v.N = 2; ms["a"] = v   // читаем, меняем, кладём обратно

// Или, что обычно правильнее, хранить указатели:
mp := map[string]*S{"a": {1}}
mp["a"].N = 2                        // работает: адресуем то, на что указывает значение
Частая формулировка вопроса

«Почему m[k].Field = v не компилируется, а m[k]++ компилируется?» Потому что значение в мапе не адресуемо: инкремент компилятор разворачивает в «прочитать значение, прибавить, записать обратно», а присваивание полю потребовало бы адреса. Лечится просто: прочитал в переменную, поменял, записал назад. Либо держи map[K]*V.

Почему порядок итерации случайный

Это намеренная защита от неявной зависимости. Порядок обхода и сам по себе зависит от размера таблицы, порядка вставок и seed; он менялся бы от версии к версии и от запуска к запуску. А окажись он стабильным «на практике», разработчики стали бы на него полагаться, и любое улучшение рантайма ломало бы прод. Поэтому команда Go пошла дальше и сделала рандомизацию явной: mapiterinit выбирает случайный стартовый бакет и случайное смещение внутри него на каждый range.

m := map[int]string{1: "a", 2: "b", 3: "c"}
for k := range m { fmt.Print(k, " ") }   // разный порядок при каждом запуске
// восемь запусков подряд дали: "1 2 3", "3 1 2", "1 2 3", "1 2 3",
// "1 2 3", "1 2 3", "2 3 1", "2 3 1" - на трёх ключах совпадения частые,
// но опираться на порядок нельзя

// Детерминированный обход: сортируем ключи
keys := make([]int, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }

// Go 1.23+: то же короче
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }
// оба цикла печатают одно и то же, всегда:
// 1 a
// 2 b
// 3 c
Единственное исключение

Компилятор оптимизирует конструкцию вида for k, v := range m внутри fmt.Println(m): fmt печатает мапу отсортированной по ключам (с Go 1.12), чтобы вывод воспроизводился в тестах и логах. Это поведение fmt, а не мапы: сам range остаётся случайным.

nil-мапа и мутации во время итерации

var m map[string]int         // nil: указатель на hmap равен nil

fmt.Println(m == nil)        // true
fmt.Println(len(m))          // 0
fmt.Println(m["x"])          // 0 - чтение из nil-мапы легально
v, ok := m["x"]              // 0, false
for range m {}               // ноль итераций, без паники
delete(m, "x")               // no-op, без паники

m["x"] = 1                   // panic: assignment to entry in nil map

Асимметрия объясняется просто: чтение честно вернёт нулевое значение, не трогая память, а запись обязана эту память создать. Указатель nil означает, что создавать негде. Инициализировать мапу сам рантайм не может: переменная передана по значению, и новый указатель некуда записать.

Мутация во время range легальна, но с оговорёнными в спецификации последствиями: удалённый и ещё не пройденный ключ гарантированно не будет посещён; добавленный во время обхода ключ может быть посещён, а может и нет, спецификация этого не определяет.

// Безопасно удалять во время обхода
for k, v := range m {
    if v == 0 { delete(m, k) }   // ок, спецификация это разрешает
}

// Опасно добавлять во время обхода: обход может зациклиться или выдать не всё
for k := range m {
    m[k+1000] = 1                // попадёт ли новый ключ в обход, не определено
}

Конкурентный доступ: почему это fatal error, а не panic

Мапа не потокобезопасна, и рантайм за этим следит. В заголовке мапы есть флаг записи: с Go 1.24 это поле writing, в классической мапе был бит hashWriting в hmap.flags. Запись и удаление ставят его в начале работы и снимают в конце. Любая другая операция, увидев этот флаг, немедленно валит процесс:

fatal error: concurrent map writes
fatal error: concurrent map read and map write

Это не паника, а throw из рантайма. Разницу спрашивают почти всегда: recover() такое не ловит, defer не выполняются, процесс умирает целиком с дампом всех горутин. Логика простая: структуру данных уже могло порвать, дальше выполнять небезопасно, лучше упасть громко и сразу.

Детектор конкурентной записи: флаг writing в заголовке мапы G1: m[k]=v G2: m[k2]=v2 Map.writing mapassign: пишет в бакет set writing clear mapassign видит флаг = 1 throw("concurrent map writes") - процесс мёртв writing ^= 1 → 0 после выхода Детектор best-effort: он ловит пересечение окон записи, а не любую гонку. Две записи, разошедшиеся по времени, он не заметит - но данные всё равно испорчены. Настоящий инструмент - go test -race / go build -race.
Флаг writing. Он стоит только на время самой операции записи, поэтому падение выходит вероятностным. Если тесты не поймали fatal error, это ещё не доказательство, что гонки нет.
Три вещи, которые тут путают
  • «Это паника, поймаю recover'ом». Нет, fatal error из рантайма не перехватывается ничем.
  • «Параллельное чтение тоже падает». Нет. Много читателей без единого писателя работают законно и безопасно.
  • «Если не падает, значит, потокобезопасно». Нет. Детектор срабатывает только при пересечении окон; без него можно получить порванный бакет и тихо неверные данные.

Чем защищаться: RWMutex, sync.Map, шардирование

Шардирование: одну большую структуру режут на N независимых кусков, у каждого свой замок, а ключ по хешу попадает ровно в один кусок. Две горутины с разными ключами чаще всего берут разные замки и не мешают друг другу.

// Вариант 1 - мьютекс рядом с мапой. Дефолт в 90 % случаев.
type Cache struct {
    mu sync.RWMutex
    m  map[string]int
}

func (c *Cache) Get(k string) (int, bool) {
    c.mu.RLock()
    v, ok := c.m[k]        // много читателей одновременно - ок
    c.mu.RUnlock()
    return v, ok
}

func (c *Cache) Set(k string, v int) {
    c.mu.Lock()
    c.m[k] = v
    c.mu.Unlock()
}

// Вариант 2 - sync.Map. Только под два паттерна.
var sm sync.Map
sm.Store("a", 1)
v, ok := sm.Load("a")
actual, loaded := sm.LoadOrStore("b", 2)   // атомарно: получить-или-положить
sm.Range(func(k, v any) bool { return true })

// Вариант 3 - шардирование. Когда RWMutex стал узким местом.
type Shard struct {
    mu sync.RWMutex
    m  map[string]int
}
type Sharded [256]*Shard

func (s *Sharded) shard(k string) *Shard {
    return s[fnv32(k)%256]   // разные ключи - разные мьютексы
}
RWMutex + map sync.Map Шардирование
Типизация полная, дженерик по построению any - приведения и боксинг полная
Когда выигрывает почти всегда (1) пишем один раз, читаем много;
(2) непересекающиеся ключи у разных горутин
высокая конкуренция, много ядер
Когда проигрывает десятки ядер бьются за один мьютекс смешанная нагрузка read/write - dirty-мапа постоянно пересоздаётся усложняет код, ломает атомарность операций «через все шарды»
len() дёшево нет вообще - только Range с подсчётом сумма по шардам, неатомарна
Аллокации нет лишних боксинг ключей и значений в any нет лишних
Что внутри sync.Map

До Go 1.24 это была пара мап. read, атомарный неизменяемый снимок, читается вообще без блокировки; dirty лежит под мьютексом, туда идут новые ключи. Когда промахов по read накопилось достаточно, dirty целиком становится новой read. Отсюда и профиль: чтение существующих ключей почти бесплатно, а вот поток новых ключей заставляет постоянно копировать всю мапу. В Go 1.24 реализацию заменили на HashTrieMap, конкурентное хеш-дерево без патологии с пересозданием dirty. Спросят на собесе «почему sync.Map медленная на записи», уточни, о какой версии речь.

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

Ключ обязан быть comparable, то есть к нему применим ==. Это все базовые типы, указатели, каналы, интерфейсы, массивы из comparable-элементов и структуры, у которых comparable все поля. Нельзя: слайсы, мапы, функции и любая структура, которая их содержит.

type Key struct {
    UserID int64
    Region string
}   // ок - оба поля comparable
m := map[Key]int{{1, "ru"}: 10}

type BadKey struct {
    Tags []string
}
// mb := map[BadKey]int{}   // ошибка компиляции: invalid map key type BadKey

// Массив можно, слайс нельзя, поэтому так:
m2 := map[[16]byte]string{}          // фиксированный размер = comparable
var id [16]byte
copy(id[:], someSlice)
m2[id] = "value"

// Или сериализуем ключ в строку (строка иммутабельна и comparable):
m3 := map[string]int{}
m3[strings.Join(tags, "\x00")] = 1
Две настоящие ловушки с ключами
  • Интерфейс как ключ. map[any]int компилируется, но положи туда значение с не-comparable динамическим типом, и получишь панику в рантайме: runtime error: hash of unhashable type []int. Компилятор здесь бессилен.
  • NaN как ключ. math.NaN() != math.NaN(), поэтому записанное по такому ключу значение невозможно ни прочитать, ни удалить. Каждая запись создаёт новый элемент, len растёт, память течёт. Классический вопрос-добивка про float-ключи.
m := map[float64]string{}
m[math.NaN()] = "a"
m[math.NaN()] = "b"
fmt.Println(len(m))          // 2 - две записи по "одному" ключу
fmt.Println(m[math.NaN()])   // "" - не найдено
delete(m, math.NaN())        // ничего не удалит
for k, v := range m { fmt.Println(k, v) }  // обе записи видны, но только через range
// NaN a
// NaN b
// порядок между ними случайный: 5 прогонов дали 4 раза "a, b" и 1 раз "b, a"

Память: мапа не сжимается

delete помечает слот пустым и обнуляет ключ и значение (чтобы GC мог собрать то, на что они указывали), но массив бакетов не уменьшается никогда. Мапа, в которую положили 10 млн ключей и потом всё удалили, продолжает держать место под 10 млн, пока жива сама переменная.

m := make(map[int][128]byte)
for i := 0; i < 10_000_000; i++ { m[i] = [128]byte{} }
for i := 0; i < 10_000_000; i++ { delete(m, i) }
fmt.Println(len(m))   // 0 - а RSS процесса всё ещё гигабайты

// Вариант 1: пересоздать мапу и дать старой умереть
m = make(map[int][128]byte)

// Вариант 2 (Go 1.21+): clear() работает как delete всех ключей,
// то есть память тоже не отдаёт и пересоздание не заменяет.
clear(m)

// Вариант 3: хранить указатели, тогда «толстые» значения соберёт GC,
// а в бакетах останутся только 8-байтовые указатели
mp := make(map[int]*[128]byte)
Формулировка, которую ждут

«Мапа растёт, но не сжимается. delete и clear освобождают значения для GC, но не возвращают массив бакетов. Если мапа работает долгоживущим кэшем с большим оборотом ключей, её надо либо периодически пересоздавать, либо менять на кэш с вытеснением, либо шардировать и пересоздавать шарды по очереди.» Заодно упомяни, что до Go 1.21 вместо clear(m) писали for k := range m { delete(m, k) }: компилятор узнавал этот паттерн и разворачивал в быстрый mapclear.

Множество (set) через мапу

Отдельного типа set в стандартной библиотеке нет, вместо него пишут map[T]struct{}: struct{} занимает 0 байт, поэтому в бакете под массив значений память вообще не выделяется.

// Идиоматичный set
seen := make(map[string]struct{}, 1024)
seen["a"] = struct{}{}
if _, ok := seen["a"]; ok { /* есть */ }
delete(seen, "a")

// Альтернатива map[string]bool - до Go 1.24 на байт больше на элемент,
// зато читается лучше и можно писать одно выражение вместо comma-ok:
seenB := map[string]bool{}
seenB["a"] = true
if seenB["a"] { /* есть */ }   // false и для "нет ключа", и для "ключ есть, но false"
map[T]struct{} map[T]bool
Память на элемент только ключ ключ + 1 байт (плюс возможный padding)
Проверка _, ok := s[k] s[k] - короче
Двусмысленность нет: ключ либо есть, либо нет есть: false ≠ «отсутствует»
Когда брать большие множества, hot path маленькие множества, читаемость важнее

Сложность и деградация

Операция Средняя Худшая Комментарий
Поиск m[k] O(1) O(n) худшая - при массовых коллизиях в один бакет
Вставка амортизированная O(1) O(n) отдельная вставка может запустить рост
Удаление O(1) O(n) память не возвращается
Обход range O(n) O(n + бакеты) после массовых удалений обход дороже: пустые бакеты всё равно просматриваются
len(m) O(1) O(1) это поле hmap.count

Мапа деградирует в четырёх ситуациях:

  • Плохой хеш / атака. Много ключей с одинаковыми низкими битами хеша → длинные overflow-цепочки → O(n) на операцию. От внешней атаки защищает случайный seed.
  • Разреженность после удалений. Бакеты остались, элементов мало, обход и кэш-локальность проседают. Частично лечится sameSizeGrow.
  • Большие ключи и значения. map[string][4096]byte копирует значение целиком при каждом чтении и записи; бакеты огромные, кэш не помогает. Лучше map[string]*T.
  • Рост без преаллокации. Залить 10 млн ключей без make(map[..].., 10_000_000) значит пройти ~23 удвоения и переложить все элементы заново. Скажешь размер заранее, и почти вся эта работа отпадёт.
// Преаллоцируем: рантайм сразу выберет нужный B
m := make(map[string]int, len(rows))   // одна аллокация вместо цепочки ростов
for _, r := range rows { m[r.Key] = r.Val }

comma-ok

У чтения из мапы две формы. Односложная всегда возвращает значение, нулевое, если ключа нет. Двухсложная (comma-ok) добавляет флаг наличия. Это единственный способ отличить «ключа нет» от «ключ есть и в нём нулевое значение».

m := map[string]int{"a": 0}

v := m["a"]            // 0
v2 := m["zzz"]         // 0 - не отличить от предыдущего
v3, ok := m["a"]       // 0, true
v4, ok2 := m["zzz"]    // 0, false

// Типичная ошибка в проде: флаги в map[string]bool
flags := map[string]bool{"debug": false}
if flags["debug"] { }          // false
if flags["typo_debug"] { }     // тоже false - опечатка не заметна
if v, ok := flags["debug"]; ok && v { }   // так честно

// Одна идиома языка, тот же синтаксис у трёх других конструкций:
v, ok := i.(MyType)    // type assertion
v, ok := <-ch          // чтение из канала: ok == false, если канал закрыт и пуст
_, ok := m[k]          // мапа
Правило

Используй comma-ok везде, где нулевое значение это валидные данные (счётчики, флаги, суммы). Односложную форму оставляй для случаев «нулевое значение и есть ответ по умолчанию»: counts[k]++, sum += m[k]. И помни, что m[k]++ корректно работает даже для отсутствующего ключа: он создастся с нулём и станет единицей.

Что выведет код?

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

type S struct {
    N int
}
ms := map[string]S{"x": {1}}
v := ms["x"]
v.N++
fmt.Println(ms["x"].N)
// (B)
m := map[int]int{1: 1, 2: 2, 3: 3}
for k := range m {
    delete(m, k)
}
fmt.Println(len(m))

var n map[string]int
fmt.Println(n == nil, len(n), n["q"])
n2 := map[string]int{}
fmt.Println(n2 == nil, len(n2))

(A) → map[a:[1 2]] 0, затем 1. append(m["a"], 1) работает, потому что чтение отсутствующего ключа даёт nil-слайс, а в него можно аппендить. А вот v := ms["x"] копирует структуру: инкремент ушёл в копию, в мапе осталась единица. Чтобы поменять, нужно ms["x"] = v или map[string]*S.

(B) → 0, затем true 0 0 и false 0. Удаление во время range легально, и спецификация гарантирует, что удалённый непосещённый ключ не будет посещён, поэтому мапа честно пустеет. Второй блок про то, что nil-мапа и пустая мапа неотличимы по len, но различимы по == nil, и в первую нельзя писать.

Вопросы

12
Суть: переменная map[K]V - указатель на hmap; внутри массив из 2^B бакетов по 8 слотов; коллизии - цепочки overflow-бакетов; рост - удвоение с инкрементальной эвакуацией.

Структура

hmap хранит count (это и есть len(m)), B (логарифм числа бакетов), hash0 (случайный seed), указатели buckets и oldbuckets, счётчик nevacuate и флаги. В бакете (bmap) лежат tophash [8]uint8, затем [8]K, затем [8]V, затем указатель на overflow-бакет. Ключи и значения идут отдельными массивами, чтобы не платить padding между K и V.

Поиск

  1. Считаем h = hash(key, hash0).
  2. Низкие B бит дают номер бакета.
  3. Старший байт хеша (tophash) сравниваем с восемью байтами в бакете. Дешёвый фильтр: сами ключи он не трогает.
  4. Только для совпавших tophash сравниваем ключ полностью.
  5. Не нашли, идём по цепочке overflow-бакетов.

Рост

Триггеров два. Load factor count > 6.5 * 2^B даёт удвоение (B++). А слишком много overflow-бакетов при нормальном count включает sameSizeGrow: перекладку в массив того же размера, чтобы схлопнуть разреженность после массовых удалений.

Эвакуация инкрементальная: старый массив остаётся в oldbuckets, каждая вставка или удаление переселяет один-два бакета. При удвоении старый бакет i расщепляется ровно на два, i и i + 2^oldB: в выборе бакета стал участвовать ещё один бит хеша. Пока рост не закончен, чтение ищет в обоих массивах, а память занята обоими.

Чем добить ответ

«Именно из-за инкрементальной эвакуации вставка стоит амортизированную O(1), а не гарантированную: отдельная вставка может оказаться дорогой. И поэтому же в момент роста мапа занимает почти вдвое больше памяти, живы оба массива.» Дальше уместно добавить, что в Go 1.24 всё это заменили на Swiss Tables.

Суть: бакеты с overflow-цепочками заменены на открытую адресацию с группами по 8 слотов и отдельным control word - метаданные фильтруются одним машинным словом.

Что именно поменялось

  • Хеш делится на h1 (старшие биты, они выбирают группу и последовательность проб) и h2 (младшие 7 бит, они ложатся в control-байт слота).
  • Control word собирает 8 однобайтовых меток группы в одно 64-битное слово. Вопрос «есть ли в группе слот с таким h2» решается парой инструкций без ветвлений (на amd64 через SSE-инструкции, на остальных архитектурах битовыми трюками над тем же словом), а на выходе битовая маска кандидатов.
  • Коллизии разрешаются открытой адресацией с квадратичным пробингом по группам, а не цепочками overflow-бакетов. Указателей между группами нет, значит и промахов кэша меньше.
  • Специальные значения control-байта: EMPTY (можно прекратить поиск) и DELETED, надгробие. Через него поиск идёт дальше, иначе порвалась бы цепочка проб.
  • Большая мапа разбита на directory из независимых таблиц; растёт по одной таблице, а не целиком, и латентность выходит ровнее.

Что НЕ поменялось

Ни семантика, ни гарантии. Порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, &m[k] по-прежнему запрещён, comma-ok тот же. Отличие видно только в производительности: чтение больших мап быстрее примерно на 30–60 %, вставка на треть, при сопоставимой памяти. Плюс сломались тесты, которые нелегально завязались на конкретный порядок итерации.

# вернуть старую реализацию (в Go 1.27 опции уже нет)
$ GOEXPERIMENT=noswissmap go build ./...
Заодно в 1.24

Переписали sync.Map: теперь под ним HashTrieMap вместо пары read/dirty. Появились generic type aliases. Добавили testing.B.Loop(), он закрывает проблему «компилятор выбросил тело бенчмарка». Если спрашивают «что нового в Go», Swiss Tables и есть самый заметный пункт 1.24.

Суть: хеш отображает ключ любого размера в фиксированное число; коллизия - совпадение номера бакета у разных ключей; Go разрешает их бакетами по 8 слотов с overflow-цепочкой (до 1.24) или открытой адресацией по группам (1.24+).

Свойства, которые от хеша нужны

  • Детерминизм. Один ключ всегда даёт одно число (в пределах одной мапы: seed у каждой свой).
  • Равномерность. Ключи размазываются по бакетам, иначе всё вырождается в список.
  • Скорость. Хеш считается на каждой операции. На amd64 Go берёт memhash на инструкциях AES-NI.

Функцию выбирает компилятор по типу ключа: строкам и байтовым срезам достаётся memhash, числам короткое смешивание, структурам комбинация хешей полей. У типов с указателями внутри хешируется содержимое, а не адрес.

Seed и hash flooding

Каждая мапа при создании получает случайный hash0. Без него атакующий, знающий алгоритм, подобрал бы тысячи ключей в один бакет и превратил бы O(1) в O(n). Классическая DoS-атака на сервис, который складывает заголовки или query-параметры запроса в мапу. Побочный эффект: хеш одного и того же ключа различается между мапами и между запусками, поэтому на порядок итерации нельзя полагаться в принципе.

Способы разрешения коллизий вообще

Способ Идея Где используется
Chaining в ячейке список элементов Java HashMap (список → дерево при 8+)
Bucket chaining цепочка блоков по N элементов Go ≤ 1.23: бакеты по 8
Открытая адресация ищем следующий свободный слот Go 1.24+ (Swiss), Python dict
Robin Hood / Cuckoo перекладываем элементы для выравнивания проб Rust hashbrown (тоже Swiss), спец-хранилища
Тонкость про удаление при открытой адресации

При открытой адресации нельзя просто пометить слот пустым: если ключ был вставлен «дальше по цепочке проб», поиск оборвётся на дырке и не найдёт его. Поэтому вводят надгробие (DELETED): слот свободен для вставки, но поиск через него идёт дальше. Когда надгробий набирается много, таблицу перестраивают. Хороший пункт из серии «глубже, чем спросят».

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

Эвакуация копирует пары ключ-значение в новый массив бакетов. Если бы язык разрешил сохранить &m[k], то после первой же вставки, запустившей рост, этот указатель смотрел бы в старую память: запись через него молча терялась бы, а чтение возвращало устаревшее значение. Go предпочитает запретить операцию на этапе компиляции.

У слайса такой проблемы нет: там при росте выделяется новый массив, но старый остаётся валидным, и указатель на старый элемент продолжает указывать на живую память (просто перестаёт быть связан со слайсом). Поэтому &s[0] легален.

Что можно, а что нельзя

m := map[string]int{"a": 1}
// p := &m["a"]     // ошибка: cannot take address of m["a"]
m["a"]++            // можно: разворачивается в чтение + сложение + запись
m["a"] += 5         // можно
m["new"]++          // можно: отсутствующий ключ = 0, станет 1

type S struct {
    N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2    // ошибка: cannot assign to struct field in map
v := ms["a"]; v.N = 2; ms["a"] = v      // читаем-меняем-пишем

mp := map[string]*S{"a": {1}}
mp["a"].N = 2                            // работает: адресуем то, на что указывает значение

sl := map[string][]int{"a": {1}}
sl["a"] = append(sl["a"], 2)             // слайс менять можно только через переприсваивание
sl["a"][0] = 9                           // а элемент можно напрямую: индексация идёт
                                         // по указателю внутри значения, а не по самому значению
На что ловят

«Почему m[k]++ компилируется, а m[k].Field = v нет?» Потому что инкремент компилятор разворачивает в три операции над копией, а присваивание полю потребовало бы адреса значения. Отсюда практическое правило: если в мапе лежат структуры, которые надо часто менять, храни map[K]*V. Но помни, что тогда GC придётся сканировать эти указатели, а сами структуры разъедутся по куче.

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

Порядок обхода и так определяется числом бакетов, seed'ом и историей вставок. Окажись он стабильным «на практике», код начал бы на него опираться, и любое улучшение рантайма (например, переход на Swiss Tables в 1.24) ломало бы прод. Команда Go сделала недетерминированность явной и обязательной: mapiterinit выбирает случайный стартовый бакет и случайное смещение внутри бакета на каждый range.

Мотив номер два: безопасность. Вместе со случайным hash0 это прячет внутреннее распределение ключей.

Как получить детерминированный обход

keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }

// Go 1.23+: на итераторах это одна строка:
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }

// для m := map[string]int{"b": 2, "a": 1, "c": 3} оба варианта
// печатают одно и то же при любом запуске:
// a 1
// b 2
// c 3
Два уточнения, которые ценят
  • fmt.Println(m) печатает мапу отсортированной по ключам начиная с Go 1.12. Это поведение пакета fmt, сделанное ради воспроизводимых тестов и логов, а не свойство мапы.
  • Одна мапа с одним и тем же содержимым даст разный порядок при двух range подряд в одном и том же запуске: рандомизируется каждая итерация, а не только процесс.
Суть: чтение, len, range и delete на nil-мапе легальны и безопасны; запись - panic: assignment to entry in nil map.
var m map[string]int   // nil: указатель на hmap равен nil

m == nil        // true
len(m)          // 0
m["x"]          // 0     - чтение легально
v, ok := m["x"] // 0, false
for range m {}  // ноль итераций
delete(m, "x")  // no-op, без паники

m["x"] = 1      // panic: assignment to entry in nil map

Почему асимметрия

Чтение честно вернёт нулевое значение, вообще не обращаясь к памяти: рантайм видит нулевой указатель и сразу отдаёт zero, false. Запись обязана эту память создать, а создавать негде. Аллоцировать hmap сам рантайм не может: мапа передана по значению, и новый указатель некуда записать так, чтобы его увидел вызывающий. Сравни со слайсом, там append возвращает новый заголовок, поэтому и работает с nil.

Где это стреляет в реальном коде

type Config struct {
    Labels map[string]string   // забыли инициализировать в конструкторе
}
c := &Config{}
c.Labels["env"] = "prod"       // panic

// JSON: если в документе поля нет - мапа останется nil
var c2 Config
json.Unmarshal([]byte(`{}`), &c2)
c2.Labels["env"] = "prod"      // panic

// Правильно:
func NewConfig() *Config { return &Config{Labels: map[string]string{}} }
// или ленивая инициализация в сеттере:
func (c *Config) Set(k, v string) {
    if c.Labels == nil { c.Labels = make(map[string]string) }
    c.Labels[k] = v
}
Правило дизайна

Если у структуры есть поле-мапа, инициализируй её в конструкторе или лениво в методе-сеттере. Никогда не рассчитывай, что вызывающий заполнит её сам, и тем более, что её заполнит json.Unmarshal.

Суть: рантайм выбросит fatal error: concurrent map writes - это throw, а не паника, его не ловит recover. Дефолтная защита - RWMutex; sync.Map - только под два конкретных паттерна.

Механика детектора

В заголовке мапы есть флаг записи: с Go 1.24 поле writing, раньше бит hashWriting в hmap.flags. Запись и удаление ставят его на входе и снимают на выходе. Любая операция, увидевшая флаг, вызывает fatal. Процесс падает целиком, defer не выполняются, recover бесполезен: состояние таблицы уже могло быть повреждено, и продолжать небезопасно.

Детектор best-effort: он ловит только пересечение окон операций. Гонку, где записи разошлись по времени, он не заметит, а данные всё равно испортятся. Настоящий инструмент тут go test -race. Параллельное чтение многими горутинами без единого писателя законно и безопасно.

Чем защищаться

  • sync.RWMutex + map, выбор по умолчанию. Полная типизация, предсказуемая стоимость, len работает. Проседает, только когда десятки ядер бьются за один мьютекс.
  • sync.Map оправдана в двух случаях, прямо названных в документации: (1) ключ пишется один раз и много раз читается; (2) горутины работают с непересекающимися наборами ключей. Минусы: any вместо типов, боксинг и лишние аллокации, нет len.
  • Шардирование: массив из N структур «мьютекс + мапа», ключ ложится в шард по хешу. Снимает конкуренцию за один замок, но ломает атомарность операций «по всей мапе».
  • Иммутабельный снимок на atomic.Pointer[map[K]V]: читатели берут указатель без блокировок, писатель копирует мапу целиком и подменяет указатель. Отлично для конфигов, которые обновляются раз в минуту.
// RLock не спасает от "проверил-и-записал"
c.mu.RLock()
_, ok := c.m[k]
c.mu.RUnlock()
if !ok {
    c.mu.Lock()
    c.m[k] = compute(k)   // между RUnlock и Lock кто-то мог уже положить
    c.mu.Unlock()
}
// Правильно: под Lock перепроверить (double-checked), либо singleflight
c.mu.Lock()
if _, ok := c.m[k]; !ok { c.m[k] = compute(k) }
c.mu.Unlock()
Про sync.Map по версиям

До Go 1.24 это была пара мап: атомарно читаемая read и защищённая мьютексом dirty; когда промахов накапливалось много, dirty целиком становилась новой read, отсюда и провал на потоке новых ключей. В Go 1.24 внутренность заменили на HashTrieMap, и эта патология ушла. Рассказываешь про read/dirty, обязательно скажи «до 1.24», иначе это выглядит как устаревшие знания.

Суть: ключ должен быть comparable. Структура - да, если comparable все её поля. Слайс, мапа, функция - нет, ошибка компиляции.

Список

Тип Ключ? Комментарий
числа, string, bool да основной случай
указатель, канал да сравнивается адрес, а не содержимое
массив [N]T да, если T comparable частый обход запрета на слайс
struct да, если все поля comparable составные ключи - идиоматично
интерфейс компилируется паника в рантайме, если динамический тип не comparable
слайс, мапа, функция нет invalid map key type на компиляции
float64 формально да но NaN ломает всё - см. ниже
type Key struct {
    UserID int64
    Region string
}
m := map[Key]int{{1, "ru"}: 10}
fmt.Println(m[Key{1, "ru"}])    // 10 - сравнение пополевое

// Слайс нельзя, но можно массив фиксированной длины:
var id [16]byte
copy(id[:], uuidBytes)
byID := map[[16]byte]string{id: "user"}

// Или сериализовать ключ в строку:
byTags := map[string]int{}
byTags[strings.Join(tags, "\x00")] = 1   // разделитель, который не встречается в данных

// Интерфейс-ключ: компилируется, но падает
var anyMap = map[any]int{}
anyMap[42] = 1         // ок: int comparable
anyMap[[]int{1}] = 1   // panic: runtime error: hash of unhashable type []int
NaN - вопрос-добивка про float-ключи

math.NaN() != math.NaN() по стандарту IEEE 754. Значит, записанное по NaN-ключу значение невозможно ни прочитать, ни удалить, а каждая запись создаёт новый элемент. len растёт, память течёт, увидеть эти пары можно только через range. Отсюда правило: не используй float как ключ мапы. Если очень нужно, нормализуй заранее (округли и переведи в целое или строку).

Как это связано с дженериками

Констрейнт comparable в дженериках означает ровно то же множество типов. Тонкость: до Go 1.20 any не удовлетворял comparable, потому что интерфейс может паниковать при сравнении. С Go 1.20 правило смягчили: теперь интерфейсы удовлетворяют comparable, а за возможную панику отвечает разработчик. Удобный пункт, чтобы связать две темы.

Суть: delete обнуляет слот, чтобы GC собрал значение, но массив бакетов никогда не уменьшается. Единственный способ вернуть память - пересоздать мапу.

Рантайм не умеет уменьшать B. Мапа, в которой было 10 млн ключей, после delete всех ключей продолжает держать бакеты под 10 млн: len(m) равен нулю, а RSS процесса не меняется. Работает только sameSizeGrow: он схлопывает overflow-цепочки, но основной массив не трогает.

// Не помогает вернуть память:
delete(m, k)          // обнуляет слот, бакеты остаются
clear(m)              // Go 1.21+: как "удалить все ключи", память тоже не отдаёт

// Помогает:
m = make(map[K]V)     // старая мапа становится мусором целиком

// Помогает частично: значения соберёт GC, в бакетах останутся указатели по 8 байт
mp := map[K]*V{}

Как это выглядит в проде

  • Кэш без вытеснения, который растёт до пика трафика и остаётся на этом уровне навсегда. Симптом: RSS вырос и не падает, len маленький.
  • Ключи-строки. Даже после delete строки собираются, но если они были подстроками одного большого буфера, держится весь буфер (см. главу про строки).
  • Мапа как очередь: пишем тысячи ключей в секунду, удаляем сразу, а бакеты вырастают до пикового размера и остаются.

Что делать

  1. Периодически пересоздавать: скопировать живые ключи в новую мапу нужного размера (make(map[K]V, len(old))) и заменить.
  2. Взять кэш с вытеснением (LRU) вместо голой мапы.
  3. Шардировать и пересоздавать шарды по очереди, тогда паузы копирования размажутся.
  4. Хранить map[K]*V, если тяжёлые именно значения.
Формулировка на собес

«Мапа в Go растёт, но не сжимается. delete и clear освобождают значения для сборщика, но массив бакетов остаётся раздутым до смерти самой мапы. Это же верно и для слайса: s = s[:0] не уменьшает cap.» Такую параллель ценят: видно, что тему понимаешь, а не вызубрил.

Суть: отдельного типа set в стандартной библиотеке нет; идиома - map[T]struct{}, потому что struct{} занимает 0 байт и под массив значений в бакете память не выделяется.
set := make(map[string]struct{}, 1024)
set["a"] = struct{}{}
_, ok := set["a"]        // проверка
delete(set, "a")
len(set)                 // размер

// Операции над множествами пишем руками:
func Union(a, b map[string]struct{}) map[string]struct{} {
    r := make(map[string]struct{}, len(a)+len(b))
    for k := range a { r[k] = struct{}{} }
    for k := range b { r[k] = struct{}{} }
    return r
}

Почему именно struct{}

unsafe.Sizeof(struct{}{}) == 0. Все экземпляры пустой структуры лежат по одному адресу, runtime.zerobase. В бакете мапы массив значений [8]struct{} занимает ноль байт, то есть накладных расходов на значение нет вообще. Для map[T]bool это был бы [8]bool = 8 байт на бакет плюс возможный padding.

map[T]struct{} map[T]bool
Память только ключи +1 байт на элемент
Проверка _, ok := s[k] s[k] - короче и читаемее
Смысл false невозможен двусмысленно: «нет ключа» или «есть, но false»
Запись s[k] = struct{}{} - шумно s[k] = true

Ещё две идиомы с пустой структурой

done := make(chan struct{})   // сигнальный канал: важен факт, а не значение
close(done)                   // broadcast всем читателям

type NoopLogger struct{}      // тип без состояния - экземпляры бесплатны
func (NoopLogger) Log(string) {}
Практика

Для множеств в горячем пути и на миллионы элементов бери map[T]struct{}. Для десятка флажков в конфиге сойдёт map[T]bool, читаемость важнее байта. В обоих случаях указывай ожидаемый размер в make: это убирает цепочку удвоений. И помни про slices.Contains из Go 1.21+: на маленьких наборах (до ~10 элементов) линейный поиск по слайсу быстрее мапы за счёт кэша.

Суть: средняя O(1) на поиск/вставку/удаление, амортизированная для вставки; худшая - O(n). Деградация: коллизии, разреженность после удалений, огромные значения, рост без преаллокации.
Операция Средняя Худшая Почему худшая такая
m[k] O(1) O(n) все ключи в одном бакете → длинная overflow-цепочка
m[k] = v амортиз. O(1) O(n) эта вставка запустила рост и переселение
delete O(1) O(n) та же цепочка коллизий
range O(n) O(n + бакеты) пустые бакеты всё равно просматриваются
len O(1) O(1) поле hmap.count

Четыре сценария деградации

  1. Коллизии. В обычной жизни их мало благодаря хорошему хешу, но специально подобранные ключи (или очень плохой самописный хеш в другой реализации) дают O(n). В Go от внешней атаки защищает случайный seed, поэтому подобрать коллизии снаружи нельзя.
  2. Разреженность. Положили миллион, удалили 990 тысяч, а бакетов по-прежнему миллион. range проходит по всем, локальность кэша плохая.
  3. Тяжёлые значения. map[string][4096]byte: каждое чтение и запись копирует 4 КБ, бакет огромный, кэш не работает. Решение: map[string]*T.
  4. Нет преаллокации. Насыпать 10 млн ключей «с нуля» стоит ~23 удвоений, и каждое перекладывает всё заново. make(map[K]V, n) сразу выбирает нужный B.
// Разницу легко измерить:
func BenchmarkNoPrealloc(b *testing.B) {
    for b.Loop() {                       // Go 1.24+: b.Loop вместо b.N
        m := map[int]int{}
        for i := 0; i < 1_000_000; i++ { m[i] = i }
    }
}
func BenchmarkPrealloc(b *testing.B) {
    for b.Loop() {
        m := make(map[int]int, 1_000_000)
        for i := 0; i < 1_000_000; i++ { m[i] = i }
    }
}
// преаллоцированная версия обычно быстрее в 1.5–2 раза и делает
// на порядок меньше аллокаций
Когда мапа вообще не нужна

На маленьких наборах (до 8–16 элементов) линейный поиск по слайсу обгоняет мапу: нет хеширования, данные лежат в одной-двух кэш-линиях. Компилятор Go даже оптимизирует switch по строкам лучше, чем поиск по мапе. Если на собесе спросят «как ускорить», уместно предложить измерить слайс против мапы, а не сразу тюнить мапу.

Суть: двухсложная форма чтения - единственный способ отличить «ключа нет» от «ключ есть, и в нём нулевое значение».
m := map[string]int{"a": 0}
v1 := m["a"]        // 0
v2 := m["zzz"]      // 0 - неотличимо
v3, ok := m["a"]    // 0, true
v4, ok := m["zzz"]  // 0, false

Синтаксическая форма, а не функция: компилятор видит присваивание в две переменные и вызывает mapaccess2 вместо mapaccess1. Поэтому её нельзя «передать дальше»: f(m[k]) всегда односложная форма.

Тот же синтаксис у трёх других конструкций

v, ok := m[k]           // мапа: ok == есть ли ключ
v, ok := i.(MyType)     // type assertion: ok == совпал ли динамический тип
v, ok := <-ch           // канал: ok == false, если канал закрыт и опустошён
for i, ok := it.Next(); ok; i, ok = it.Next() {}   // ручной итератор

Где обязательно

  • Значение это число или bool, и ноль/false валиден: счётчики, лимиты, фича-флаги.
  • Нужно отличить «не настроено» от «настроено в ноль», например в конфиге.
  • Кэш: в v, ok := cache[k] значение ok == false означает промах, а не «в кэше лежит ноль».

Где не нужно

counts[word]++              // отсутствующий ключ = 0, станет 1, comma-ok не нужен
total += prices[id]         // «нет цены» и «цена 0» здесь одно и то же
sums[k] = append(sums[k], v) // nil-слайс аппендится нормально
Классическая ошибка в проде

Фича-флаги в map[string]bool и проверка if flags["enable_new_flow"]. Опечатка в имени флага даёт false, ровно то же, что выключенный флаг, и баг живёт месяцами. Лечится либо comma-ok с явной ошибкой на неизвестный ключ, либо типизированным конфигом со структурой вместо мапы.

Что стоит запомнить

  • Ключ должен быть сравнимым; порядок range не определён.
  • Comma-ok различает zero value и отсутствие ключа.
  • Map не защищает конкурентные записи.

Частые ошибки

  • Не опирайся на порядок обхода.
  • Нельзя брать адрес map[key].

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи значения

Что напечатает фрагмент? Объясни, почему получился такой результат.

m := map[string]int{"ready": 0}
a := m["ready"]
b := m["missing"]
_, ok := m["ready"]
fmt.Println(a, b, ok)
Ответ и разбор

Печатается 0 0 true: чтение отсутствующего ключа возвращает zero value, но comma-ok отдельно показывает наличие ключа.

Код-ревью

2. Найди ошибку и исправь

Два worker вызывают add одновременно. Исправь защиту так, чтобы чтение и запись выполнялись под одной блокировкой.

var counts = map[string]int{}
func add(key string) { counts[key]++ }
Ответ и разбор

Map не синхронизирует запись. Добавь общий sync.Mutex и держи его на всей операции: var mu sync.Mutex; func add(key string) { mu.Lock(); defer mu.Unlock(); counts[key]++ }. Все чтения той же map тоже должны использовать этот mutex либо другую согласованную стратегию.

Применение

3. Реши задачу

Нужно изменить поле Age у значения map[string]User. Почему m["ann"].Age++ не компилируется и как записать исправление?

Ответ и разбор

Элемент map нельзя адресовать как стабильное место хранения. Скопируй значение, измени копию и присвой обратно: u := m["ann"]; u.Age++; m["ann"] = u. Если значения большие или часто меняются, можно хранить map[string]*User, отдельно учитывая владение указателями.

Применение

4. Реши задачу

Код обходит map и строит список ключей. Требуется одинаковый список при каждом запуске. Как получить детерминированный порядок?

Ответ и разбор

Собери ключи в слайс и отсортируй через slices.Sort(keys) перед использованием. Спецификация не задаёт порядок map iteration, поэтому нельзя использовать порядок обхода как порядок результата.