Модуль: Core Go · Уровень: Senior
TL;DR#
Слайс — это не контейнер, а трёхсловный дескриптор (header): указатель на бэкинг-массив, длина (len) и ёмкость (cap). Слайс передаётся по значению (header копируется), но указывает на тот же массив, поэтому мутации элементов видны вызывающему, а append — нет (он может реаллоцировать). Главные источники багов: молчаливый шаринг бэкинг-массива между подслайсами, реаллокация при росте и утечки памяти, когда маленький подслайс удерживает весь большой массив.
Теория#
Анатомия: SliceHeader (ptr / len / cap)#
В рантайме слайс описывается структурой из трёх машинных слов (runtime.slice):
// runtime/slice.go
type slice struct {
array unsafe.Pointer // указатель на начало бэкинг-массива (на элемент [0] слайса)
len int // количество доступных элементов: s[0]..s[len-1]
cap int // ёмкость от array до конца бэкинг-массива
}Есть исторический «зеркальный» тип reflect.SliceHeader (Data uintptr, Len int, Cap int), но он deprecated в пользу unsafe.Slice / reflect.Value, потому что Data uintptr не виден сборщику мусора и небезопасен при перемещении объектов.
Размер слайса на 64-битной платформе — 24 байта (3 × 8), независимо от типа и количества элементов. unsafe.Sizeof(s) всегда вернёт 24 для любого слайса.
s := make([]int, 3, 8)
header (24 байта) backing array (8 ячеек по 8 байт)
┌──────────┬─────┬─────┐ ┌──┬──┬──┬──┬──┬──┬──┬──┐
│ array ●──┼ len │ cap │ │ 0│ 0│ 0│ ? │ ? │ ? │ ? │ ? │
│ │ │ 3 │ 8 │ └──┴──┴──┴──┴──┴──┴──┴──┘
└───┼──────┴─────┴─────┘ ▲ ▲
└──────────────────────────┘ len=3 cap=8 ─┘Слайс vs массив#
Массив [N]T | Слайс []T | |
|---|---|---|
| Размер | Часть типа: [3]int ≠ [4]int | Не часть типа |
| Передача в функцию | Копируется целиком (value type) | Копируется только header (24 байта), массив шарится |
| Размер известен | На этапе компиляции | В рантайме |
len/cap | Константы | Динамические |
Сравнение == | Да (поэлементно) | Нет, только с nil (compile error при == слайса) |
| Может расти | Нет | Да, через append (с реаллокацией) |
Массив — это значение целиком; слайс — окно (view) поверх массива. s := arr[:] создаёт слайс, смотрящий в arr.
append и реаллокация#
append концептуально:
- Если
len < cap— пишет элемент вarray[len], возвращает header сlen+1, тот же указатель. Мутирует общий бэкинг-массив. - Если
len == cap— рантайм аллоцирует новый, больший массив (growslice), копирует старые элементы, пишет новый, возвращает header с новым указателем. Старый массив остаётся у тех, кто на него ссылался.
Поэтому канонично только s = append(s, x): результат обязателен к присваиванию, иначе при росте вы потеряете новый header.
Стратегия роста (growslice)#
Исторически (до Go 1.18) правило было простым:
- если требуемая ёмкость больше удвоенной текущей — берём требуемую;
- иначе: для маленьких слайсов (cap < 1024) — удвоение, для больших — рост на ~25% за итерацию.
В Go 1.18 порог удвоения сгладили, чтобы избежать резкого скачка с ×2 на ×1.25 ровно на 1024:
// runtime/slice.go (~1.18+), упрощённо
const threshold = 256
if oldCap < threshold {
newcap = oldCap * 2 // маленькие — удваиваем
} else {
// плавный переход от 2x к ~1.25x
for newcap < newLen {
newcap += (newcap + 3*threshold) / 4
}
}Итог: маленькие слайсы по-прежнему растут ×2, большие — плавно к коэффициенту ~1.25. После расчёта newcap рантайм ещё округляет размер вверх по size class аллокатора (roundupsize), поэтому фактический cap часто больше расчётного — нельзя полагаться на точные значения cap после append.
s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Println(len(s), cap(s)) // cap: 1 2 4 8 8 8 8 16 16 16... (с округлением по классам)
}Важно: точные значения cap — деталь реализации. На собеседовании показывайте понимание стратегии (амортизированная O(1), удвоение для малых, ~1.25 для больших, округление по size class), а не зубрёжку чисел.
append с распаковкой: append(dst, src...) — может расти один раз под весь объём. append([]byte, "str"...) — спец-кейс для строк без копии в промежуточный слайс.
Шаринг бэкинг-массива#
Слайсинг b := a[1:3] не копирует данные — b.array указывает внутрь массива a. Запись b[0] = X видна в a[1].
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4 (от индекса 1 до конца массива a)
b[0] = 99
fmt.Println(a) // [1 99 3 4 5] — изменили a через b
b = append(b, 100) // len<cap → пишет в a[3]!
fmt.Println(a) // [1 99 3 100 5] — append затёр элемент a[3]Это классический источник «спуки экшн на расстоянии»: подслайс с запасом по cap при append молча портит данные исходного слайса.
copy()#
copy(dst, src) копирует min(len(dst), len(src)) элементов, возвращает число скопированных. Это единственный безопасный способ получить независимую копию.
src := []int{1, 2, 3}
dst := make([]int, len(src)) // len, а НЕ cap! copy смотрит на len dst
n := copy(dst, src) // n == 3, dst — независимая копияЧастая ошибка: dst := make([]int, 0, len(src)); copy(dst, src) копирует 0 элементов, т.к. len(dst)==0. Нужно make([]int, len(src)) или append([]int(nil), src...).
copy работает и при перекрытии регионов (как memmove). copy(dst []byte, s string) — копирование из строки в байты.
Трюки#
Удаление элемента по индексу i (порядок важен):
s = append(s[:i], s[i+1:]...) // сдвиг хвоста влево; len уменьшается на 1
// ВНИМАНИЕ: если элементы — указатели/содержат указатели, последний слот
// держит старую ссылку → утечка. Обнулите хвост:
s[len(s)-1] = nil // или zero value
s = s[:len(s)-1]В Go 1.21 появился slices.Delete, который сам зануляет освобождённый хвост.
Удаление без сохранения порядка (O(1)):
s[i] = s[len(s)-1]
s = s[:len(s)-1]Фильтрация in-place без аллокации (zero-alloc filter):
filtered := s[:0] // тот же бэкинг-массив, len=0, cap прежний
for _, x := range s {
if keep(x) {
filtered = append(filtered, x) // пишет поверх s, т.к. cap общий
}
}
s = filteredРаботает потому, что append пишет в тот же массив (cap хватает). Не делайте так, если на исходный s ещё кто-то смотрит.
Очистка:
s = s[:0]— обнулить len, сохранить cap (переиспользование буфера); не освобождает элементы для GC.s = nil— отдать массив сборщику.clear(s)(Go 1.21) — обнуляет элементы до zero value, len не меняет (полезно передs[:0]для GC указателей).
Утечки памяти через слайсы#
Подслайс удерживает весь бэкинг-массив, пока жив сам подслайс:
func firstByte(data []byte) []byte {
return data[:1] // держит ВЕСЬ data (может быть мегабайты) ради 1 байта
}Пока возвращённый слайс жив, GC не может собрать большой массив. Решение — скопировать:
return append([]byte(nil), data[:1]...) // независимая маленькая копияТа же проблема при «удалении из начала» циклически растущего слайса (s = s[1:]): голова массива недостижима, но не освобождается, cap не уменьшается, память растёт.
Full slice expression: a[low:high:max]#
Трёхиндексный слайс задаёт cap явно: cap = max - low, len = high - low. Ограничивает рост и защищает от затирания соседних данных.
a := []int{1, 2, 3, 4, 5}
b := a[1:3:3] // len 2, cap 2 (max=3)
b = append(b, 100) // cap==len → РЕАЛЛОКАЦИЯ, a не тронут
fmt.Println(a) // [1 2 3 4 5] — целостность сохраненаПрименение: API, возвращающие подслайс внутреннего буфера, должны отдавать buf[i:j:j], чтобы клиентский append не портил буфер.
nil слайс vs пустой слайс#
var s []int (nil) | s := []int{} / make([]int, 0) | |
|---|---|---|
s == nil | true | false |
len/cap | 0 / 0 | 0 / 0 |
| ptr | nil | не-nil (указывает на runtime.zerobase) |
append к нему | работает | работает |
range по нему | 0 итераций | 0 итераций |
| JSON-маршалинг | null | [] |
Идиома: предпочитайте nil-слайс как «пустое» значение (меньше аллокаций, zero value полезен). Различие критично для JSON-API и сравнений в тестах (reflect.DeepEqual(nil_slice, empty_slice) == false).
Передача слайса в функцию#
В функцию копируется header (ptr/len/cap, 24 байта). Следствия:
- Изменение элемента (
s[i] = x) видно снаружи — общий массив. appendвнутри функции: если влез в cap — изменение видно (затёрли общий массив!), если реаллокация — невидимо. Поведение зависит от cap → недетерминированно для вызывающего.- Изменение длины (
s = s[:n]) внутри функции снаружи не видно — это локальная копия header.
Чтобы функция могла менять сам слайс (len/реаллокация) для вызывающего — передавайте *[]T или возвращайте новый слайс (return append(s, x)).
Подводные камни / gotchas#
- Игнорирование результата append.
append(s, x)безs =— потеря нового header при росте. Линтер ловит не всегда. - append в подслайс с запасом cap.
b := a[:2]; b = append(b, x)затираетa[2]. Лечитсяa[:2:2]. - copy в слайс с len=0.
make([]T, 0, n)+copyкопирует ноль. Нужен len, а не cap. - Утечка большого массива через подслайс. Возврат
big[:1]держитbigцеликом. - Утечка указателей при удалении/усечении.
s = s[:len(s)-1]не зануляет освобождённый слот; объект под указателем не собирается. Используйтеclear/slices.Deleteили ручное зануление. - Range копирует значение элемента.
for _, v := range s { v.X = 1 }меняет копию. Нужноs[i].X = 1. - append аргумента-слайса в функции даёт разное поведение в зависимости от cap → трудноуловимые баги. Не мутируйте чужие слайсы.
- nil vs empty в JSON и DeepEqual. Тесты падают на
nullvs[]. - Слайс не сравнивается через == (кроме
== nil) — compile error, в отличие от массивов. - Полагаться на точный cap после append — он округляется по size class аллокатора.
- Многомерные слайсы (
[][]int) — это слайс хедеров; «строки» не лежат непрерывно в памяти, в отличие от[N][M]int.
Вопросы на собеседовании#
В: Из чего состоит слайс и сколько он занимает в памяти?
О: Из трёх машинных слов: указателя на бэкинг-массив, длины и ёмкости (runtime.slice). На 64-битной платформе — 24 байта, независимо от типа элементов и их количества. Сами данные лежат в отдельном бэкинг-массиве, на который указывает ptr.
В: Чем слайс отличается от массива? О: Массив — значение фиксированного размера, размер часть типа, копируется целиком, сравним через ==. Слайс — дескриптор-окно поверх массива: размер динамический, при передаче копируется только header (массив шарится), сравнивать можно только с nil, умеет расти через append с возможной реаллокацией.
В: Что происходит при append, когда заканчивается cap?
О: Рантайм (growslice) аллоцирует новый больший массив, копирует существующие элементы, добавляет новый и возвращает header с новым указателем. Старый массив остаётся у тех, кто на него ссылался. Поэтому результат append обязателен к присваиванию.
В: Какова стратегия роста и менялась ли она? О: Амортизированно O(1). Исторически: удвоение при cap < 1024, ~+25% выше. В Go 1.18 порог сместили к 256 и сделали плавный переход от ×2 к ~×1.25, чтобы убрать резкий скачок. После расчёта размер округляется вверх по size class аллокатора, поэтому фактический cap часто больше расчётного и на точные числа полагаться нельзя.
В: Почему b := a[1:3]; b = append(b, x) может испортить a?
О: Подслайс b смотрит в массив a и наследует cap до конца массива. Пока len(b) < cap(b), append пишет в общий массив — в данном случае в a[3], затирая исходное значение. Защита — full slice expression a[1:3:3], ограничивающее cap до len, что форсирует реаллокацию при append.
В: Зачем нужен третий индекс в a[low:high:max]?
О: Он задаёт ёмкость явно: cap = max - low. Используется, чтобы ограничить cap подслайса и не дать чужому append затереть соседние элементы или внутренний буфер. Идиома buf[i:j:j] отдаёт «изолированный» подслайс.
В: В чём разница между nil-слайсом и пустым слайсом?
О: Оба имеют len 0 и cap 0, оба пригодны для append и range. Но nil == nil true, у пустого ptr не-nil. Различие проявляется в == nil, в JSON (null vs []) и в reflect.DeepEqual. Идиоматично использовать nil как пустое значение.
В: Как сделать независимую копию слайса и где тут ловушка?
О: dst := make([]T, len(src)); copy(dst, src) либо append([]T(nil), src...). Ловушка: copy копирует min(len(dst), len(src)), поэтому make([]T, 0, len(src)) скопирует ноль элементов — нужен len, а не cap.
В: Как слайс может вызвать утечку памяти?
О: Подслайс удерживает весь бэкинг-массив. Возврат big[:1] не даёт собрать большой big. Также s = s[:len(s)-1] без зануления хвоста удерживает объект под указателем. Лечится копированием маленького среза и clear/slices.Delete/ручным занулением слотов.
В: Видны ли вызывающему изменения слайса, сделанные внутри функции?
О: Изменение элементов (s[i]=x) — да, массив общий. Изменение длины и реаллокация — нет, копируется только header. append опасен: при наличии cap он молча затрёт общий массив (видно снаружи частично), при реаллокации — невидим. Для управляемой мутации передают *[]T или возвращают новый слайс.
На что копают на senior+#
- Точная механика growslice + roundupsize. Senior не зубрит числа cap, а объясняет: удвоение для малых, ~1.25 для больших, округление по size class mcache, и почему cap после append может «прыгнуть» неожиданно. Follow-up: «почему рост экспоненциальный → амортизированная O(1) на n вставок».
- GC и слайсы. Понимание, что GC видит весь бэкинг-массив через ptr; что
reflect.SliceHeader.Data uintptrне отслеживается GC и опасен; зачемclear/зануление хвоста. Follow-up проunsafe.Slice/unsafe.SliceData(Go 1.20) вместо SliceHeader. - Профилирование утечек. Чем диагностировать удержание массива подслайсом (pprof heap, рост inuse), как сборщик не освобождает «голову» при
s = s[1:]. - Архитектурное мышление вокруг буферов. Возврат
buf[i:j:j]из API; пулы (sync.Pool) переиспользуемых слайсов черезs[:0]; почему zero-alloc-фильтрация безопасна только если на исходный слайс никто не смотрит. - Многомерные структуры. Разница между
[][]int(слайс хедеров, непоследовательная память, лишняя косвенность) и[N][M]int/ выровненным 1D-слайсом с ручной индексацией для cache locality. - Эволюция стандартной библиотеки. Знание пакета
slices(1.21):Clip,Grow,Delete,Insert,Compact,Clone, и чемClip(обрезка cap до len) помогает против утечек и затирания. - Конкурентность. Слайс не потокобезопасен; одновременный append из горутин — гонка по header и данным; шаринг бэкинг-массива между горутинами даёт скрытые data race даже без явного append.