сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Интерфейсы и основы Go

Слайсы в Go: len, cap и append

Слайсы: заголовок, cap и общий массив

Ты отдал коллеге первые два элемента своего слайса. Он дописал к ним один свой - обычный append. После этого у тебя изменился третий элемент, которого он вообще не видел. Ошибки нет, паники нет, тест иногда зелёный. Это самая частая тема Go-собеседования после горутин, и ловят на ней почти всех.

Стержень: слайс - это заголовок из трёх полей поверх массива, и пока append умещается в cap, он пишет в чужую память.

// Формулировки: «что такое cap?», «почему изменился исходный слайс?», «как сделать независимую копию?»

Три поля поверх массива

Слайс - это не массив, а структура из трёх полей: указатель на массив, длина и вместимость. len - сколько элементов видно через этот слайс. cap - сколько помещается от его начала до конца массива, то есть сколько ещё можно дописать, не переезжая.

Копирование слайса копирует только эти три поля. Массив остаётся один на всех, и отсюда всё поведение. Передал слайс в функцию - правка s[0] внутри видна снаружи, потому что массив общий. А вот append внутри снаружи не виден: он поменял длину в локальной копии заголовка, а твой заголовок остался прежним. Поэтому функции, дополняющие слайс, обязаны его возвращать - это не стиль, а необходимость.

// nil-слайс (var s []int) и пустой (s := []int{}) ведут себя одинаково почти во всём: len и cap нулевые, append работает, range не падает. Различаются они в двух местах - при сравнении с nil и при сериализации: nil превращается в JSON в null, пустой - в []. Второе регулярно ломает фронтенд.

заголовок слайса
три поля: указатель на массив, длина, вместимость
cap
сколько элементов помещается от начала слайса до конца массива

append и алиасинг: смотрим на числах

Возьмём массив из четырёх элементов и отрежем от него первые два. Печатаем: у b длина 2, а вместимость 4 - потому что от начала b до конца массива помещается четыре элемента. Запас есть.

Теперь append(b, 99). Места хватает, новый массив не нужен, значит девяносто девять просто ложится в третью ячейку общего массива. Печатаем исходный слайс - он стал [1 2 99 4]. Никто ничего не нарушал: append честно записал в свободную по его мнению ячейку.

Если бы запаса не было, append выделил бы новый массив, скопировал туда данные и связь со старым порвалась бы. Отсюда главное коварство темы: поведение зависит от cap, а cap зависит от истории слайса. Один и тот же код на одних данных портит соседа, на других нет, и баг воспроизводится через раз.

// Лечится три-индексным срезом: a[:2:2] делает cap равным двум, и следующий же append вынужден копировать в новый массив - проверено, исходный [1 2 3 4] остался нетронутым. Это обязательный приём, когда срез уходит наружу: в чужую функцию, в горутину, в кеш.

a := []int{1, 2, 3, 4}
b := a[:2]                 // len(b)=2  cap(b)=4  запас есть
b = append(b, 99)
// a == [1 2 99 4]  <- append записал в общий массив

a2 := []int{1, 2, 3, 4}
b2 := a2[:2:2]             // cap(b2)=2  запаса нет
b2 = append(b2, 99)
// a2 == [1 2 3 4]  <- append был вынужден скопировать
алиасинг
два слайса смотрят в один и тот же массив
полный срез
a[low:high:max] - третий индекс ограничивает cap результата

Как растёт массив под слайсом

Когда места не хватает, рантайм выделяет массив побольше и копирует туда всё содержимое. Посчитаем, сколько раз это случится, если наливать в пустой слайс тысячу элементов по одному.

Замер даёт двенадцать перевыделений, и цепочка вместимостей такая: 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 848, 1280. До 256 идёт честное удвоение. Дальше формула другая, помягче: к текущей вместимости прибавляется примерно четверть от неё плюс запас. Проверим на числах: 512 плюс (512 плюс 768) делить на 4 - это 832, и рантайм округляет вверх до готового класса размера, получается 848. Следующий шаг: 848 плюс (848 плюс 768) делить на 4 - это 1252, округляется до 1280. Сходится.

Двенадцать перевыделений - это двенадцать выделений памяти и почти две тысячи скопированных элементов ради тысячи полезных. Когда размер известен заранее, пишут make([]T, 0, n): одно выделение, ноль копирований, и это хорошо видно в бенчмарке с флагом -benchmem.

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

Независимая копия: где ошибаются

Копия делается через make плюс copy, и вот тут ловушка, на которую попадаются даже опытные. copy переносит минимум из длин приёмника и источника. Не из вместимостей - из длин.

Значит приёмник, созданный как make([]int, 0, 3), скопирует ноль элементов: длина у него нулевая, а трёхэлементная вместимость copy не интересует. Проверено: возвращает 0 и пустой слайс. Правильно - make([]int, len(src)), тогда copy вернёт три и данные окажутся на месте.

Второй способ независимой копии появился в 1.21 - slices.Clone. Он же и читается лучше. А заодно в пакете slices лежит вся рутина, которую раньше писали руками: Sort, Contains, Index, Equal, Max, Min - типобезопасно и без функции сравнения, в отличие от sort.Slice.

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

src := []int{1, 2, 3}

dst := make([]int, 0, 3)
copy(dst, src)        // 0 элементов! длина приёмника нулевая

dst2 := make([]int, len(src))
copy(dst2, src)       // 3 элемента, [1 2 3]

dst3 := slices.Clone(src)  // то же самое, но читаемо (Go 1.21+)
copy
копирует минимум из ДЛИН приёмника и источника, возвращает число элементов
предвыделение
make([]T, 0, n) - одно выделение вместо череды перевыделений

Как отвечать: «Что будет с исходным слайсом после append в его срез?»

Зависит от вместимости, и это ядро вопроса. Слайс - заголовок из указателя на массив, длины и cap, и при срезе оба слайса смотрят в один массив. Конкретно: если a это четыре элемента, а b равно a[:2], то у b длина два, а вместимость четыре, потому что от начала b до конца массива помещается четыре. Значит append(b, 99) ничего не выделяет, а пишет в третью ячейку общего массива - и a становится [1 2 99 4]. Если бы запаса не было, append выделил бы новый массив, скопировал данные, и связь порвалась бы. Отсюда главная опасность: поведение зависит от cap, поэтому баг воспроизводится не на всех данных и не при каждом прогоне теста. Лечится двумя способами: либо явная копия через make длиной len(src) и copy, либо три-индексный срез a[:2:2], который обнуляет запас и заставляет append копировать. Второе я делаю всегда, когда отдаю срез наружу - в чужую функцию, в горутину или в кеш.

Назван механизм с конкретными числами, объяснено, почему баг плавающий, даны оба способа лечения и правило для границ пакета - это инженерное суждение, а не пересказ документации.

На чём валят

  • Считают срез независимой копией: append в него молча портит исходный массив.
  • Делают приёмник для copy через make([]T, 0, n) и копируют ноль элементов.
  • Ждут, что append внутри функции будет виден снаружи без возврата слайса.
  • Не знают три-индексный срез и отдают наружу слайс с чужим запасом.
  • Наливают тысячи элементов в пустой слайс, хотя размер известен заранее.
  • Путают nil-слайс и пустой при сериализации: в JSON это null против [].

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #go_slices1 / 5
    Как надёжно получить независимую копию слайса src (правки не видны в src)?
    A)dst := src — присваивание копирует элементы в новый массив
    B)dst := src[:] — срез по всей длине отвязывает от исходного массива
    C)make([]T, len(src)) + copy(dst, src) — отдельный массив с копией
    D)dst := src[0:len(src):len(src)] — three-index срез даёт независимую копию
    показать ответ и разбор
    +C)make([]T, len(src)) + copy(dst, src) — отдельный массив с копией

    // разбор: Присваивание или полный срез src[:] копируют лишь заголовок слайса (ptr, len, cap) — массив остаётся общим, и запись в dst видна в src. Независимую копию дают make+copy или append([]T(nil), src...): они выделяют новый массив. Three-index срез ограничивает cap, но до первого append массив всё ещё общий. Копия ≠ разделяемый заголовок.

  2. #go_slices2 / 5
    Чем nil-слайс (var s []int) отличается от пустого (s := []int{})?
    A)К nil-слайсу append неприменим, а к пустому литералу применим свободно
    B)Они взаимозаменяемы во всём, различий между ними нет
    C)У nil-слайса len паникует, у пустого возвращает 0
    D)len 0 у обоих; append работает с обоими, но s==nil лишь у nil-слайса
    показать ответ и разбор
    +D)len 0 у обоих; append работает с обоими, но s==nil лишь у nil-слайса

    // разбор: И nil-слайс, и пустой имеют len 0 и cap 0, range по ним не итерирует. Разница почти только семантическая: s == nil истинно лишь у nil-слайса, а json.Marshal даёт null против []. append одинаково работает с обоими (к nil тоже — вернёт новый слайс). Идиома Go — использовать nil-слайс как пустой, не создавая []T{} без нужды.

  3. #go_slices3 / 5
    Что показывают len(s) и cap(s) у слайса?
    A)Размер элемента в байтах и общее число байтов под слайсом
    B)Индекс последнего элемента и индекс конца нижележащего массива
    C)Число заполненных элементов и число оставшихся свободных ячеек
    D)Число доступных сейчас элементов и вместимость нижележащего массива
    показать ответ и разбор
    +D)Число доступных сейчас элементов и вместимость нижележащего массива

    // разбор: Слайс — заголовок из трёх полей: указатель на массив, длина и вместимость. len — сколько элементов видно через этот слайс, cap — сколько всего помещается от его начала до конца массива. Пока len меньше cap, append пишет в уже выделенную память; когда упирается — выделяется новый массив побольше, и связь со старым теряется.

  4. #go_slices4 / 5
    Функция принимает слайс и меняет элемент по индексу. Увидит ли изменение вызывающий код?
    A)Нет: слайс передаётся по значению, и функция работает с копией данных
    B)Да, если функция объявила параметр как указатель на слайс
    C)Да: копируется заголовок, но указатель в нём ведёт на тот же массив
    D)Нет: изменения видны только после явного возврата слайса из функции
    показать ответ и разбор
    +C)Да: копируется заголовок, но указатель в нём ведёт на тот же массив

    // разбор: Копируется трёхсловный заголовок, а вот массив за указателем общий — правка s[0] внутри функции видна снаружи. А вот append внутри функции снаружи не виден: он меняет длину в локальной копии заголовка, а при переполнении ещё и уводит указатель на новый массив. Отсюда и идиома возвращать слайс из функций, которые его дополняют.

  5. #go_slices5 / 5
    Зачем при нарезке писать три индекса — a[:2:2] вместо a[:2]?
    A)Третий индекс ускоряет доступ, фиксируя шаг обхода элементов
    B)Третий индекс режет cap, и append выделит новый массив вместо порчи исходного
    C)Третий индекс задаёт начало среза, а первые два — его границы
    D)Третий индекс помечает слайс неизменяемым для последующих операций
    показать ответ и разбор
    +B)Третий индекс режет cap, и append выделит новый массив вместо порчи исходного

    // разбор: Без третьего индекса cap среза тянется до конца исходного массива, и append в срез затирает соседние элементы оригинала — классическая тихая порча данных. Форма a[low:high:max] ограничивает cap значением max-low: append сразу упирается в предел и копирует данные в новый массив. Приём обязателен, когда срез уходит наружу — в чужой код или в горутину.

дальше

Теорию прочитали. Навык ставится повторением

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