Слайсы в Go: len, cap и append
Ты отдал коллеге первые два элемента своего слайса. Он дописал к ним один свой - обычный 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, остальные разбираются в тренажёре.
- Как надёжно получить независимую копию слайса 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 массив всё ещё общий. Копия ≠ разделяемый заголовок.
- Чем nil-слайс (var s []int) отличается от пустого (s := []int{})?A)К nil-слайсу append неприменим, а к пустому литералу применим свободноB)Они взаимозаменяемы во всём, различий между ними нетC)У nil-слайса len паникует, у пустого возвращает 0D)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{} без нужды.
- Что показывают len(s) и cap(s) у слайса?A)Размер элемента в байтах и общее число байтов под слайсомB)Индекс последнего элемента и индекс конца нижележащего массиваC)Число заполненных элементов и число оставшихся свободных ячеекD)Число доступных сейчас элементов и вместимость нижележащего массива
показать ответ и разбор
+D)Число доступных сейчас элементов и вместимость нижележащего массива// разбор: Слайс — заголовок из трёх полей: указатель на массив, длина и вместимость. len — сколько элементов видно через этот слайс, cap — сколько всего помещается от его начала до конца массива. Пока len меньше cap, append пишет в уже выделенную память; когда упирается — выделяется новый массив побольше, и связь со старым теряется.
- Функция принимает слайс и меняет элемент по индексу. Увидит ли изменение вызывающий код?A)Нет: слайс передаётся по значению, и функция работает с копией данныхB)Да, если функция объявила параметр как указатель на слайсC)Да: копируется заголовок, но указатель в нём ведёт на тот же массивD)Нет: изменения видны только после явного возврата слайса из функции
показать ответ и разбор
+C)Да: копируется заголовок, но указатель в нём ведёт на тот же массив// разбор: Копируется трёхсловный заголовок, а вот массив за указателем общий — правка s[0] внутри функции видна снаружи. А вот append внутри функции снаружи не виден: он меняет длину в локальной копии заголовка, а при переполнении ещё и уводит указатель на новый массив. Отсюда и идиома возвращать слайс из функций, которые его дополняют.
- Зачем при нарезке писать три индекса — a[:2:2] вместо a[:2]?A)Третий индекс ускоряет доступ, фиксируя шаг обхода элементовB)Третий индекс режет cap, и append выделит новый массив вместо порчи исходногоC)Третий индекс задаёт начало среза, а первые два — его границыD)Третий индекс помечает слайс неизменяемым для последующих операций
показать ответ и разбор
+B)Третий индекс режет cap, и append выделит новый массив вместо порчи исходного// разбор: Без третьего индекса cap среза тянется до конца исходного массива, и append в срез затирает соседние элементы оригинала — классическая тихая порча данных. Форма a[low:high:max] ограничивает cap значением max-low: append сразу упирается в предел и копирует данные в новый массив. Приём обязателен, когда срез уходит наружу — в чужой код или в горутину.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.