Слайс в Go выглядит как «динамический массив», а карта — как «словарь», и на первом уровне понимания этого достаточно. Но обе структуры прячут за удобным синтаксисом устройство, о которое регулярно спотыкаются даже опытные разработчики: слайс — это всего лишь заголовок над общим массивом, поэтому append умеет молча перезаписать данные, на которые смотрит совсем другой слайс; а карта намеренно рандомизирует порядок обхода и роняет весь процесс при конкурентной записи, причём так, что recover не помогает. Разберём внутренности и подводные камни — с опорой на живой стенд, где каждый эффект воспроизведён и сверен.
Это третья статья мини-серии «Основы Go вглубь». Она продолжает разговор об интерфейсах и обработке ошибок: там речь шла о том, как значения представлены в памяти и как ведут себя при копировании; здесь — про две структуры, где «копирование значения» и «общая память» расходятся сильнее всего. Тема конкурентного доступа к картам смыкается с примитивами синхронизации и моделью памяти — на них и будем ссылаться, где это уместно.
В статье
- Слайс — это заголовок
- Рост append
- Ловушка алиасинга append
- nil против пустого слайса
- Карты: устройство и порядок обхода
- nil-карта: чтение и запись
- Concurrent map writes
- Практические правила
- Демо и версии
- Документация и первоисточники
Слайс — это заголовок
Ключ к пониманию слайсов: слайс — это не массив. Слайс — это небольшая структура из трёх полей (в рантайме — reflect.SliceHeader по смыслу):
- указатель на первый доступный элемент backing-массива;
- длина (
len) — сколько элементов сейчас в слайсе; - ёмкость (
cap) — сколько элементов помещается в backing-массиве, начиная с указателя, до его конца.
Сам массив с данными живёт отдельно, в куче или на стеке. Заголовок на него только ссылается. Отсюда — главное следствие: когда слайс присваивают, передают в функцию или режут (s[a:b]), копируется заголовок (три машинных слова), но backing-массив остаётся общим. Два слайса-заголовка могут указывать на один и тот же массив и видеть правки друг друга.
a := []int{10, 20, 30, 40}
b := a // копируется заголовок: тот же указатель, len=4, cap=4
b[0] = 99 // пишем через b ...
// теперь a[0] == 99 — b и a смотрят в ОДИН массив
c := a[1:3] // новый заголовок: указатель сдвинут на a[1], len=2, cap=3
c[0] = 7 // это a[1]
// теперь a == [99 7 30 40]Мысленная модель: слайс — это «окно» (offset + длина) поверх массива, а cap показывает, сколько ещё места есть справа от окна до конца массива. Срез a[1:3] сдвигает начало окна на элемент a[1], поэтому его cap становится равным 3 (a[1], a[2], a[3]), а не 4. Пока разные слайсы делят один массив, запись по индексу в одном видна в другом — это не баг, а прямое следствие того, что данные лежат в общем массиве, а заголовки лишь ссылаются на него.
Рост append
append добавляет элементы в конец слайса. Его поведение определяется тем, есть ли в backing-массиве запас ёмкости:
len < cap— место есть.appendзаписывает новый элемент прямо в существующий массив (в ячейку с индексомlen), увеличиваетlenв возвращённом заголовке и не выделяет новую память. Backing-массив тот же — а значит, все слайсы, делящие этот массив, могут «увидеть» запись (об этом — в следующем разделе).len == cap— место кончилось.appendвыделяет новый, обычно кратно больший массив, копирует туда старые элементы, дописывает новый и возвращает заголовок, указывающий уже на новый массив. С этого момента слайс отвязан от старого backing-массива: правки в него больше не видны через старые слайсы.
Именно поэтому канонический способ вызова — s = append(s, x): возвращённый заголовок может отличаться от исходного (другой указатель, другой cap), и присваивание обязательно. Конкретный множитель роста — деталь реализации рантайма (исторически «удвоение» для маленьких слайсов и коэффициент около 1.25 для больших), и полагаться на точные числа нельзя. Гарантируется лишь амортизированная стоимость O(1) на элемент: редкие дорогие перевыделения размазываются по множеству дешёвых вставок.
На стенде это показывает функция AppendGrowth из slices.go — она добавляет элементы по одному, начиная с nil-слайса, и снимает (len, cap) после каждого шага:
func AppendGrowth(n int) []GrowthStep {
steps := make([]GrowthStep, 0, n)
var s []int // nil-слайс: len==0, cap==0
for i := 0; i < n; i++ {
s = append(s, i)
steps = append(steps, GrowthStep{Len: len(s), Cap: cap(s)})
}
return steps
}Тест стенда сознательно проверяет инварианты, а не жёсткие значения cap: len растёт на единицу за шаг, cap >= len на каждом шаге и cap никогда не убывает. Это правильная дисциплина при работе с деталями рантайма — фиксировать поведение (амортизированный рост), а не магические числа, которые вправе меняться между версиями Go.
Практический вывод: если итоговый размер известен заранее, стоит выделить ёмкость сразу — make([]T, 0, n) — и append ни разу не будет перевыделять массив. Это тот же приём, что использован внутри AppendGrowth для аккумулятора steps.
Ловушка алиасинга append
Теперь соединим два факта из предыдущих разделов — «слайсы делят backing-массив» и «append при наличии запаса cap пишет в этот массив на месте» — и получим самый коварный баг вокруг слайсов. Это демо cmd/append-aliasing, вывод сверен на Go 1.26.3.
full := []int{10, 20, 30, 40}
// full: [10 20 30 40]
// view делит backing-массив с full и имеет ЗАПАС ёмкости.
view := full[:2] // len=2, cap=4 -> [10 20]
// append влезает в имеющуюся ёмкость (len 2 < cap 4) — новый массив
// НЕ выделяется, запись идёт в общий массив, в ячейку, которую видит full[2].
view = append(view, 99)
// view: [10 20 99]
// full: [10 20 99 40] <- элемент full[2] МОЛЧА изменился с 30 на 99Разберём по шагам, что произошло:
view := full[:2]даёт заголовок сlen=2, ноcap=4— окно сузилось до двух элементов, однако backing-массив тот же, и ёмкость видна до самого его конца.append(view, 99)видитlen (2) < cap (4)— место есть. Значит, новый массив не выделяется:99записывается в ячейку с индексом2общего массива иlenувеличивается до 3.- Но эта ячейка с индексом 2 — та самая, на которую смотрит
full[2]. Черезfullтам был30. Теперь там99. Значениеfullизменилось молча, без единой строчки, где мы явно трогали быfull.
Ни компилятор, ни рантайм не выдают предупреждения: с точки зрения языка всё легально. Баг проявляется, когда «урезанный» слайс view живёт своей жизнью (например, возвращён из функции-парсера), а исходный full где-то ещё считается неизменным.
Защищаться можно двумя способами.
Трёхиндексный срез full[:2:2] — форма s[low:high:max] — задаёт не только длину (high-low), но и ёмкость (max-low). Ограничив cap длиной, мы лишаем слайс запаса: следующий append будет вынужден выделить новый массив, и сосед не пострадает.
base := []int{1, 2, 3, 4}
safe := base[:2:2] // len=2, cap=2 — запаса НЕТ
safe = append(safe, 777)
// safe: [1 2 777] — новый массив
// base: [1 2 3 4] — НЕ тронутЯвная копия — slices.Clone(s) (Go 1.21+) или классический append([]T(nil), s...). Копия не делит backing-массив с оригиналом вообще, поэтому любые append и записи по индексу безопасны. На стенде оба приёма — CloneViaStdlib и CloneViaAppend — покрыты тестом TestCloneIsIndependent, который правит копию и проверяет, что правка не протекла в исходник.
Правило по итогу: если урезанный слайс отдаётся наружу и должен быть независим — либо обрежьте его ёмкость трёхиндексным срезом, либо клонируйте. Полагаться на то, что «append наверняка выделит новый массив», нельзя — при наличии запаса cap он этого не сделает.
nil против пустого слайса
nil-слайс — это заголовок с нулевым указателем, len==0 и cap==0. Пустой слайс []T{} — это заголовок с ненулевым указателем (на массив нулевой длины), тоже len==0. По поведению они почти неотличимы, и это сделано намеренно:
appendкnil-слайсу работает — рантайм сам выделит массив:var s []int; s = append(s, 1, 2, 3)даёт[1 2 3];len(nil)иcap(nil)равны0;rangeпоnil-слайсу просто не делает ни одной итерации.
Поэтому проверять «пусто ли» надо через len(s) == 0, а не через s == nil: пустой не-nil слайс тоже пуст, но s == nil для него ложно. На стенде это фиксирует TestNilVsEmpty.
var nilSlice []int // nil: len==0, cap==0, ==nil истинно
emptySlice := []int{} // пустой: len==0, ==nil ложно
_ = append(nilSlice, 1) // работает: append к nil легален
// len обоих == 0; проверять пустоту надо через len(), не через == nilГде различие всё же важно — сериализация. Стандартный encoding/json кодирует nil-слайс как null, а пустой []T{} — как []:
type Resp struct {
Items []string `json:"items"`
}
// Items == nil -> {"items":null}
// Items == []string{} -> {"items":[]}Для многих клиентов (особенно фронтенда и строго типизированных API) null и [] — разные вещи: первое ломается на .map/.length, второе штатно означает «пустой список». Если контракт требует именно пустой массив, инициализируйте поле явным []string{} перед маршалингом (или через make([]string, 0)), а не оставляйте nil. В обратную сторону симметрично: при десериализации null в слайс даёт nil, отсутствующий ключ — тоже nil, а [] — пустой не-nil слайс.
Карты: устройство и порядок обхода
Карта в Go — это хеш-таблица. Ключ хешируется; часть битов хеша выбирает группу слотов, другая часть (top hash) хранится рядом со слотами и служит для быстрой отбраковки: сравнивая эти биты, рантайм пропускает заведомо чужие слоты, не трогая сами ключи, — это ускоряет поиск, но не задаёт «позицию» как прямой индекс. По мере наполнения таблица инкрементально перехешируется — обычно в больший набор слотов, но не всегда «ровно вдвое»: при разросшихся цепочках/перегрузке overflow возможен same-size grow — перепаковка в тот же размер, чтобы убрать фрагментацию. Точная внутренняя раскладка — деталь реализации и менялась между версиями (в Go 1.24 встроенная map переехала на Swiss Tables), поэтому опираться стоит не на неё, а на наблюдаемые свойства: неопределённый порядок обхода и фатальное падение при конкурентной записи (оба разбираем ниже). Заголовок карты хранится по указателю, поэтому карта, в отличие от массива, не копируется при присваивании — копируется ссылка, и два имени указывают на одну таблицу.
Важнейшая практическая деталь: по спецификации языка порядок обхода карты не определён — полагаться на него нельзя. Текущая реализация рантайма идёт дальше и намеренно его рандомизирует: каждый for k := range m стартует со случайного бакета и смещения. Это уже деталь реализации, а не гарантия спецификации — гарантия ровно одна: порядок непредсказуем. Демонстрирует cmd/map-order:
m := map[string]int{
"alpha": 1, "bravo": 2, "charlie": 3,
"delta": 4, "echo": 5, "foxtrot": 6,
}
for pass := 1; pass <= 3; pass++ {
keys := make([]string, 0, len(m))
for k := range m { // порядок range по map НЕ определён
keys = append(keys, k)
}
fmt.Printf("проход %d: %v\n", pass, keys)
}Проходы по одной и той же неизменной карте могут давать разные порядки ключей — эффект вероятностный: отдельные два прохода иногда совпадают, но полагаться на порядок нельзя (конкретные перестановки зависят от запуска — приводить их бессмысленно, они меняются). Чем больше проходов, тем очевиднее разброс. Это не побочный эффект реализации, а сознательное решение авторов языка: если бы порядок случайно оказывался стабильным, разработчики начали бы неявно на него полагаться, и код ломался бы при смене версии Go или размера карты. Рандомизация превращает скрытую зависимость в явную ошибку, заметную сразу.
Когда нужен детерминированный обход — соберите ключи в слайс и отсортируйте:
sorted := make([]string, 0, len(m))
for k := range m {
sorted = append(sorted, k)
}
sort.Strings(sorted) // или slices.Sort(sorted) (Go 1.21+)
for _, k := range sorted {
// обход в предсказуемом порядке
_ = m[k]
}Это единственный корректный способ получить воспроизводимый порядок: сам range его не гарантирует ни при каких условиях.
Частый смежный вопрос — можно ли менять карту прямо во время range. Спецификация отвечает раздельно для двух случаев. Удаление ключа во время обхода разрешено явно: удалённый до его посещения ключ дальше не будет отдан, а удаление текущего или уже пройденного — безопасно.
m := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
for k := range m {
if k%2 == 0 {
delete(m, k) // удалять в range можно
}
}
// len(m) == 2 — остались нечётные ключиА вот добавление ключей во время range спецификация оставляет неопределённым: новый ключ может быть отдан в этой же итерации, а может и нет — полагаться на исход нельзя. Практическое правило простое: удалять по ходу обхода — можно; добавлять — нет, для этого копят новые ключи в отдельный слайс/карту и вносят после цикла. (Это про однопоточную мутацию из той же горутины; параллельная запись из другой горутины — уже не «неопределённый порядок», а fatal error, см. ниже.)
nil-карта: чтение и запись
С nil-картой (объявлена, но не инициализирована через make или литерал) поведение асимметрично — и это регулярный источник паник.
- Чтение из
nil-карты легально.m["absent"]возвращает нулевое значение типа (0дляint,""дляstring), comma-ok даётv, false, аlen(m)равен0. Ничего не паникует. - Запись в
nil-карту паникует —panic: assignment to entry in nil map.
var m map[string]int // nil-карта
_ = m["absent"] // ок: вернёт 0
_, ok := m["x"] // ок: ok == false
_ = len(m) // ок: 0
m["key"] = 1 // panic: assignment to entry in nil mapНа стенде это проверено безопасно: TestReadFromNilMap убеждается, что чтение из nil-карты возвращает нули, а TestWriteToNilMapPanics через хелпер didPanic (с recover) фиксирует факт паники — тест ловит её в изоляции и потому проходит. Ключевой момент для следующего раздела: паника при записи в nil-карту — это обычная паника, её можно перехватить recover. Лечится проблема одной строкой инициализации: m := make(map[string]int) (или литерал map[string]int{}) перед первой записью.
Concurrent map writes
Встроенная карта не безопасна для конкурентного использования. Одновременное чтение из нескольких горутин допустимо, но параллельная запись из нескольких горутин (или запись одновременно с чтением) — это data race, и её нельзя «сделать безопаснее», не убрав саму гонку. У рантайма есть встроенная защита: если он застаёт такое пересечение, он аварийно завершает весь процесс. Важно не переоценить: это не гарантия поймать любой некорректный доступ и не замена детектору -race — рантайм ловит лишь часть реально пересёкшихся операций, когда они фактически столкнулись на исполненном пути (подробнее об этом ниже). Демо cmd/concurrent-map:
m := make(map[int]int)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(base int) {
defer wg.Done()
for i := 0; i < 100_000; i++ {
m[base] = i // незащищённая конкурентная запись
}
}(g)
}
wg.Wait() // до сюда обычно не доходим — процесс уже упалЗапуск роняет программу с сообщением:
fatal error: concurrent map writes
goroutine ... [running]:
... main.main.func1 ...
exit status 2Критически важное различие: concurrent map writes — это fatal error рантайма, а не паника. И recover её не ловит. Здесь принципиальный контраст с предыдущим разделом: запись в nil-карту — обычная паника, перехватываемая recover; конкурентная запись — фатальная ошибка, которая убивает процесс безусловно, никакой defer/recover её не остановит. Так сделано намеренно: обнаружив повреждение внутренней структуры карты гонкой, рантайм не пытается «продолжить как-нибудь» — состояние уже неопределённо, и единственный безопасный исход — немедленно упасть.
Обратите внимание: детектор конкурентного доступа к картам встроен в рантайм и работает всегда, без флага -race (это дешёвая проверка на уровне самих операций с картой, а не полноценный детектор гонок). Но, как любая динамическая проверка, он замечает нарушение только когда оно фактически произошло на исполненном пути.
Чинить — стандартными средствами синхронизации: sync.Mutex/sync.RWMutex вокруг чтения и записи, либо sync.Map (оптимизирована под сценарии «много читателей» и непересекающиеся ключи). Подробно об этих примитивах и о том, какие гарантии видимости они дают, — в статье про пакет sync и атомики.
type SafeMap struct {
mu sync.Mutex
m map[int]int
}
func (s *SafeMap) Set(k, v int) {
s.mu.Lock()
s.m[k] = v
s.mu.Unlock()
}
func (s *SafeMap) Get(k int) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[k]
return v, ok
}Практические правила
- Копируйте перед мутацией разделяемого слайса. Если слайс пришёл извне (аргумент, поле структуры) и вы собираетесь его менять
append-ом или по индексу, а оригинал должен остаться нетронутым — сделайтеslices.Clone(илиappend([]T(nil), s...)). - Обрезайте ёмкость на границах. Возвращаете «урезанный» срез наружу как самостоятельное значение — используйте трёхиндексный срез
s[low:high:high], чтобыappendполучателя не переписал соседние элементы общего массива. - Не полагайтесь на
append, что он выделит новый массив. Приlen < capон пишет в общий массив на месте. Хотите гарантированной независимости — клон или обрезанная ёмкость. - Проверяйте пустоту через
len(s) == 0, а неs == nil. Учитывайте разницуnil(null) и[]T{}([]) при JSON-сериализации, если контракт API это различает. - Никогда не полагайтесь на порядок обхода карты. Нужен стабильный порядок — соберите ключи в слайс и отсортируйте.
- Инициализируйте карту перед записью (
make/литерал): запись вnil-карту паникует. - Преаллоцируйте карту под известный размер —
make(map[K]V, n). Какmake([]T, 0, n)для слайса, подсказка ёмкости сокращает число перестроек хеш-таблицы (rehash) при наполнении: если знаете, что вложите ~N пар, скажите это сразу. - Мутируйте карту в
rangeосознанно:deleteпо ходу обхода легален, добавление ключей — поведение не определено; новые ключи копите и вносите после цикла. - Защищайте карты под конкуренцией. Любой параллельный доступ, где есть хотя бы одна запись, — под
Mutex/RWMutexили черезsync.Map. Помните:concurrent map writes— фатальная ошибка,recoverеё не перехватит.
Демо и версии
- Код: живой стенд
digital-cookbook/go-fundamentals/slices-maps/. В корне —slices.go(ростappend,nil/пустой, независимое клонирование) и тестыslices_test.go/maps_test.go(инварианты роста, паника записи вnil-карту черезrecover, независимость копий) — проходят подgo test ./.... Демонстрации спецэффектов — вcmd/:append-aliasing(тихая перезапись соседа, вывод сверен на Go 1.26.3),map-order(рандомизация порядка обхода),concurrent-map(намеренно падает сfatal error: concurrent map writes— эту программу не «чинить», она показывает эффект). Код в тексте — выжимки. Запуск из папкиslices-maps/:go run ./cmd/append-aliasing(и аналогично./cmd/map-order,./cmd/concurrent-map); из корня модуляgo-fundamentals— с префиксом пути:go run ./slices-maps/cmd/append-aliasing. - Версии:
slices.Clone,slices.Sort,mapsи другие обобщённые пакеты доступны с Go 1.21; трёхиндексный срез и всё остальное про слайсы/карты — с ранних версий языка. Собирайте на текущем стабильном релизе. - Смежные статьи мини-серии: интерфейсы, обработка ошибок. По конкурентному доступу — пакет sync и атомики и модель памяти Go.
Документация и первоисточники
- Go Slices: usage and internals — блог go.dev: заголовок слайса (указатель/len/cap), устройство
appendи рост, алиасинг backing-массива. - Arrays, slices (and strings): The mechanics of ‘append’ — как именно
appendкопирует и когда перевыделяет массив. - The Go Programming Language Specification — разделы Slice types, Slice expressions (в т.ч. трёхиндексная форма
a[low:high:max]), Map types, Appending to and copying slices: формальная семантика. - Go maps in action — блог go.dev: карта как хеш-таблица, отсутствие гарантий порядка,
nil-карты и потокобезопасность. - Go 1.21 Release Notes — появление стандартных пакетов
slicesиmaps(slices.Clone,slices.Sortи др.).
Комментарии