Срез в Go: окно над чужим массивом, два пути append и правило роста, которое все помнят неверно
Срез не владеет данными: это окно над чужим массивом, и одна запись через одно окно видна во втором. Урок идёт от этой модели к механизму — два пути append, настоящая формула роста, что стоит за словами «амортизированно O(1)» и почему «×2 до 1024, дальше ×1,25» неверно в обоих числах.
Полное техническое изложение
TL;DR
Срез не владеет данными — это окно над чужим массивом. Два окна могут
смотреть в одни и те же ячейки, и запись через одно видна во втором: прогон
печатает, как одна запись меняет три значения — оба среза и сам массив. Это
гарантия языка, а не деталь реализации. А append либо дописывает в тот же
массив, либо переезжает в новый — и поэтому возвращает срез.
Отсюда главное следствие: append может молча записать в чужие ячейки.
Запас у s[1:3] равен не двум: по спецификации это cap(s) − low, то есть
до конца массива, — и append в такой срез пишет туда, где лежат данные
соседа. Из той же общей памяти растёт и удержание: три байта, оставленные от
файла в 50 МБ, держат все пятьдесят. И цена удаления зависит не от длины среза,
а от хвоста, который надо сдвинуть: измерено 1401 и 667 нс при сдвиге 9999 и
4999 элементов, а при сдвиге одного и нуля разница уже ниже разрешения замера.
Дальше — числа, версии и границы. Срез — заголовок из трёх машинных
слов (указатель, длина, запас); замер даёт 24 байта, и ровно они
копируются при передаче в функцию, сколько бы элементов ни лежало в массиве, а
[5]int — 40 байт, потому что массив это значение. Правило «×2 до 1024,
дальше ×1,25» неверно в обоих числах: в go1.24 порог назван константой
256 в nextslicecap, а множитель сразу после порога — 1,66, потому что
формула прибавляет четверть плюс 192; конкретные значения cap при этом не
гарантированы. «Амортизированно O(1)» — про сумму, а не про каждый вызов:
измерено, что при росте до миллиона копируется 4,15·n элементов, а один
append в момент переезда стоит O(n). А for _, v := range копирует элемент —
но это довод про семантику, а не про скорость: в том же замере он оказался
даже быстрее индексной формы.
- массив — это подряд лежащие элементы одного типа, и число их задано заранее;
- элемент берут по номеру:
s[0]— первый, и по этому же номеру в него можно записать; appendдобавляет элемент в конец, а результат присваивают обратно той же переменной.
- из чего состоит сам срез: указатель, длина, запас,
cap, трёхиндексная формаs[a:b:c]; growslice,nextslicecap, классы размеров аллокатора, амортизированная оценка;slices.Clone, разницаnil-среза и пустого.
Что здесь на самом деле спрашивают
Собеседование по срезам почти всегда идёт одной и той же лестницей, и полезно знать её заранее — тогда видно, куда ведёт разговор:
- «Что такое срез?» — проверяют, знаете ли вы про заголовок. Ответ «динамический массив» закрывает вопрос и открывает следующий.
- «Что делает
append?» — проверяют, знаете ли вы, что случаев ДВА и что они различаются наличием запаса. - «Почему здесь изменился соседний срез?» (с кодом на бумажке) — проверяют первое и второе вместе. Это главный вопрос темы.
- «Какая у
appendасимптотика?» — проверяют, отличаете ли вы амортизированную оценку от оценки одного вызова.
Все четыре — один и тот же вопрос под разными углами: знаете ли вы, что срез не владеет своими данными. Дальше урок идёт по этой лестнице.
База: срез — это окно над чужим массивом
Начнём с того, зачем срез вообще нужен. Массив в Go задаёт число элементов
заранее и живёт с ним до конца: [6]int — это ровно шесть чисел, и других у
него не будет. Работать так неудобно почти всегда: данных приходит столько,
сколько приходит, и брать нужно то весь набор, то его кусок. Срез и есть ответ
на это — способ смотреть на часть массива и говорить о ней как о целом.
Ключевое слово здесь — «смотреть». Срез не забирает элементы себе и не делает их копию: он лишь описывает, какой отрезок чужого массива через него виден. Отсюда и главная неожиданность темы: если сделать из среза кусок и записать что-то в этот кусок, изменится исходный массив, а с ним и все остальные срезы, которые смотрят в те же ячейки.
Посмотрите, как это выглядит. Один массив и два среза над ним — и одна запись:
arr := [6]int{10, 20, 30, 40, 50, 60}
a := arr[1:4] // [20 30 40]
b := arr[3:6] // [40 50 60]
a[2] = 99Прогон bench/goslice/internals.go печатает, что стало:
массив [10 20 99 40 50 60] ← было [10 20 30 40 50 60]
a [20 30 99]
b [99 50 60]
Одна запись — три изменившихся значения, и ни одно из них не копия. a[2]
и b[0] — это одна и та же ячейка массива.
Вот и вся модель, из которой дальше выводится всё остальное: срез не владеет данными. Он описывает, какой отрезок чужого массива через него виден, — окно, а не коробка. Спецификация говорит это дословно:
A slice, once initialized, is always associated with an underlying array that
holds its elements. A slice therefore shares storage with its array and with
other slices of the same array.
Срез после инициализации всегда связан с нижележащим массивом, который хранит его элементы. Поэтому срез разделяет память со своим массивом и с другими срезами того же массива.
Держите эту фразу под рукой: она одна отвечает на три разных вопроса собеседования. Раз данные общие — добавление в один срез может записать в чужие ячейки. Раз массив может смениться — добавление обязано возвращать новое окно. Раз окно удерживает весь массив — короткий срез держит длинный в памяти.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: срез — не контейнер со своими данными, а окно над чужим массивом. Всё дальнейшее — про то, из чего это окно сделано, что происходит, когда данные в него перестают помещаться, и во что это обходится.
Механизм 1: срез, массив и строка — три разные вещи
В Go есть три «последовательности», и их постоянно смешивают. Разница видна по одному числу — размеру самого значения:
[]int 24 байта — три слова: указатель, длина, запас
string 16 байт — два слова: указатель и длина
[5]int 40 байт — МАССИВ это значение: пять чисел по восемь
Прочитайте эту табличку ещё раз: у [5]int размер зависит от количества
элементов, у []int — нет. Это и есть вся разница. Массив в Go — значение,
и func f(a [5]int) получает копию всех пяти чисел. Срез — описатель, и
func f(s []int) получает копию трёх слов, за которыми лежит чужая память.
Отсюда сразу два практических вывода, которые стоит держать наготове:
- «Передавать срез по указателю ради скорости» бессмысленно: вы экономите
восемь байт из двадцати четырёх. А вот
*[1000]intвместо[1000]int— осмысленно, потому что там копируются восемь килобайт. - Функция может изменить ваши данные, ничего не возвращая:
s[0] = 1внутри видно снаружи, потому что указатель в копии заголовка тот же.
Теперь посмотрите на сам заголовок и на то, как из выражения получаются его три поля. Переключайте выражения — фигура одна и та же, меняются числа:
Главное, что должно остаться от этой картинки: красная зона — не «свободное
место». Это ячейки исходного массива, которые кто-то другой считает своими.
s[1:3] не «отрезает кусок» — он открывает окно, за правым краем которого
лежат чужие данные, и append пишет именно туда.
Правило из спецификации, которое стоит уметь произнести дословно: у s[low:high]
запас равен cap(a) - low
(cap(a) − low), а у трёхиндексной формы
s[low:high:max] — max - low
(max − low). Второе и есть
единственный способ отрезать хвост.
Механизм 2: что делает append
append — не функция в обычном смысле, а встроенная операция, которую
компилятор разворачивает в два пути.
Быстрый путь: запаса хватает. Тогда не происходит ничего интересного:
элемент кладётся в ячейку s[len], длина увеличивается на единицу, указатель
и запас остаются прежними. Никакого выделения памяти, никакого копирования.
Именно поэтому append в цикле по преаллоцированному срезу почти бесплатен.
Медленный путь: запаса не хватает. Вызывается growslice из рантайма, и он
делает четыре шага:
- считает новый запас — это
nextslicecap, разбор в «Глубже»; - переводит его в байты и округляет вверх до класса размеров аллокатора;
- выделяет новый массив;
- копирует в него
lenуже уложенных элементов и кладёт новый.
Отсюда ответ на вопрос, который любят задавать следом: почему append
возвращает срез? Потому что на медленном пути массив другой, и в старом
заголовке указатель ведёт не туда. Изменить заголовок у вызывающей стороны
append не может — тот передан по значению. Единственный способ сообщить новый
указатель и новую длину — вернуть их.
И отсюда же главный вопрос всей темы: на быстром пути append пишет в уже
существующий массив, который может быть общим. Посмотрите, как это выглядит
по шагам — и как та же операция ведёт себя, когда запас исчерпан:
Второй сценарий на этой картинке важнее первого. Без него уносится правило
«append опасен», а оно неверно: опасна ровно комбинация «длина меньше
запаса» — то есть ровно та, которую создаёт s[1:3].
Механизм 3: удаление, вставка и цена сдвига
Каноническая идиома удаления выглядит одинаково для любого индекса:
s = append(s[:i], s[i+1:]...)Прежде чем смотреть на цену, стоит понять, что именно тут происходит, — иначе одинаковая запись с разной ценой выглядит произволом. В массиве дырок не бывает: убрать элемент из середины — значит сдвинуть весь хвост на одну позицию влево.
до: [A B C D E F] удаляем C (i = 2)
s[:2] = [A B] s[3:] = [D E F]
шаг: append дописывает D, E, F в ячейки после B —
то есть в ячейки, где лежали C, D, E того же массива
после: [A B D E F | F] длина 5, шестая ячейка не стёрта
Три вещи видны сразу, и все три спрашивают. Во-первых, append здесь пишет в
тот же массив — это тот самый быстрый путь из «Механизма 2», запаса заведомо
хватает. Во-вторых, работы ровно столько, сколько элементов в хвосте. В-третьих,
последняя ячейка остаётся заполненной — к этому урок вернётся ниже.
Отсюда и цена. Замер на срезе из 10 000 элементов:
| что делаем | сдвигается элементов | время |
|---|---|---|
| удалить первый | 9 999 | 1 401 нс |
| удалить средний | 4 999 | 667 нс |
| удалить предпоследний | 1 | ниже разрешения |
укоротить с конца, s = s[:len(s)-1] | 0 | ниже разрешения |
Линейность видна прямо в числах: 1401 к 667 — это примерно 9999 к 4999. Цена
зависит не от длины среза, а от длины хвоста, который надо сдвинуть.
Удаление из начала — O(n), с конца — O(1), и это одна и та же запись при разных
i.
Две нижние строки без числа — не пробел в замере, а его граница: сдвиг одного элемента и сдвиг нуля стоят меньше, чем сама подготовка среза, и вычитание отсчёта уходит в шум. Содержательно это тот же ответ — их цена не растёт с длиной среза.
Практический вывод, который стоит назвать: если порядок элементов не важен, удаление делается за O(1) заменой на последний:
s[i] = s[len(s)-1]
s = s[:len(s)-1]И отдельная ловушка — та, из-за которой удаление течёт. Укоротив срез, вы уменьшили длину, но массив цел, и элемент за длиной по-прежнему в нём лежит. Если это указатель, объект за ним не соберётся:
live := []*Node{a, b, c}
live = live[:2] // c больше не виден... но массив его держитПроверено запуском: третий элемент остаётся в массиве и остаётся ненулевым.
slices.Delete эту проблему решает сам — документация прямо говорит, что он
zeroes the elements
s[len(s)-(j-i):len(s)]
(обнуляет элементы s[len(s)−(j−i):len(s)]). Через append обнулять хвост приходится руками.
Механизм 4: где срез держит память
Тот же механизм, но в масштабе, который стоит денег:
func firstThree(path string) []byte {
data, _ := os.ReadFile(path) // 50 МБ
return data[:3] // отдали три байта
}Вернули три байта — в памяти остались пятьдесят мегабайт. Сборщик мусора работает с массивом целиком: жив указатель на его начало — жив весь массив. Более узкий срез ссылку не убирает, он только уменьшает длину.
Убирает копия:
return slices.Clone(data[:3])slices.Clone returns a copy of the slice
(возвращает копию среза), и
после него исходный массив никем не удерживается. Оговорка из документации
существенна: копия неглубокая, и если элементы сами на что-то ссылаются,
это что-то останется общим.
Это, кстати, лучший ответ на вопрос «когда вы в последний раз ловили утечку в Go»: чаще всего утечка в Go — не забытая горутина, а вот такой удержанный массив.
Механизм 5: range копирует — гарантия и наблюдение
У этого раздела два утверждения разного сорта, и урок специально их разводит: одно верно всегда, второе верно на конкретной машине под конкретной нагрузкой. Смешивать их — значит производить суеверия.
v — это копия элемента, и запись в неё срез не меняет.
for _, v := range items {
v.done = true // потеряно: v — копия
}
for i := range items {
items[i].done = true // так
}Это не про скорость и не про версию Go. Это про то, что range по значению
присваивает элемент переменной цикла, а присваивание в Go копирует. Ошибка
из-за этого не компилируется в предупреждение и не падает в тестах: код честно
меняет копию.
А вот вывод «значит, range по значению медленнее» замер не подтверждает.
for _, v := range xs 21 782 нс — структура в 64 байта копируется
for j := range xs 22 577 нс — копии нет
По значению вышло быстрее, кратность 0,96. Причина в том, что у индексной
формы своя цена: на каждом обращении xs[j] компилятор проверяет границы, а
range по значению идёт по памяти подряд без проверок. Две цены оказались
одного порядка, и какая перевесит — зависит от размера элемента, от того, что
делается внутри цикла, и от того, сумел ли компилятор убрать проверку границ.
Обобщать это число нельзя ни в одну сторону: на структуре в килобайт копия наверняка перевесит. Содержательно здесь другое — что разница не на порядок, а значит, выбирать форму цикла надо по семантике, а не по скорости.
На собеседовании это хороший ход: назвать копирование как гарантию, а производительность — как измеряемое, и не путать их. Ровно так и обстоит дело.
Механизм 6: nil и пустой срез
var a []int // nil
b := []int{} // не nilДлина у обоих ноль, append работает с обоими, for range не сделает ни шага.
Отличия ровно два, и оба всплывают далеко от места создания:
var a []int | []int{} | |
|---|---|---|
a == nil | true | false |
json.Marshal | null | [] |
Второе — источник настоящих багов: клиент, ожидающий массив, получает null и
падает. В коде это выглядит как «мы же вернули пустой список».
Правило: внутри кода разницы не делайте — проверяйте len(s) == 0, а не
s == nil; а на границе с JSON инициализируйте явно.
Глубже: рост запаса и цена «амортизированно O(1)»
Дальше начинается другой сорт утверждений, и разницу стоит проговорить. Всё, что было выше, описывало контракт языка: он не изменится. Всё, что ниже про конкретные числа роста, — устройство сегодняшней реализации, и оно уже менялось.
Здесь живёт самое частое заученное правило — и оно неверно. Вот
последовательность запасов, через которые реально проходит []int при append
по одному:
1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560 3408 5120 7168 9216 12288 16384 21504
Удвоение кончается не на 1024, а на 512: следующее число — 848. Вот соответствующий кусок рантайма, целиком:
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices.
newcap += (newcap + 3*threshold) >> 2
if uint(newcap) >= uint(newLen) {
break
}
}Разберём по частям, потому что оба «известных» числа ломаются именно здесь.
Порог — 256, а не 1024. Пока старый запас меньше 256, возвращается ровно
удвоение. При oldCap == 256 включается формула, но она сама даёт
256 + (256 + 768)/4 = 512 — то есть удвоение «случайно» продолжается ещё один
шаг. Первое число, где расхождение видно, — это 848 после 512.
Множитель — не 1,25. Формула прибавляет четверть запаса плюс 192
(это 3*256/4). При запасе 512 получается 512 + (512 + 768)/4 = 832, и
множитель равен 1,63. Доля от 192 падает с ростом запаса, поэтому к 1,25 ряд
сходится — но идёт к нему сверху и долго:
2,00 … 2,00 1,66 1,51 1,40 1,43 1,33 1,50 1,40 1,29 1,33 1,33 1,31
Обратите внимание: ряд не монотонный. Это уже третий эффект — округление до классов размеров. Формула просила 832 элемента, то есть 6656 байт; аллокатор выдал ближайший класс, 6784 байта, — а это 848 элементов. Рантайм считает в байтах, и обратно в элементы делится с остатком.
Проще всего это увидеть на типе, размер которого не степень двойки. Вот тот же
append для структуры в 15 байт:
1 2 4 8 16 32 68 136 273 546 904 1365 1911 2730
Шестьдесят восемь. Двести семьдесят три. Эти числа никто не задумывал — они получились делением байтов на пятнадцать.
Что из этого нужно на собеседовании. Не последовательность наизусть, а три
утверждения: рост геометрический с убывающим множителем; порог 256; конкретные
значения cap не гарантированы, потому что считается в байтах. Последнее —
самое полезное: код, который полагается на равенство cap какому-то числу,
сломается при смене версии Go или типа элемента.
Асимптотика, и что за ней прячется
«Амортизированно O(1)» — правильный ответ, который обычно произносят как пароль. За ним стоят два разных утверждения, и на собеседовании ценно разделить их вслух.
Первое: сумма работы линейна. Переезды случаются всё реже, и объёмы копирования складываются в геометрическую прогрессию. Поэтому построение среза из n элементов стоит O(n), а не O(n²), как было бы при росте на единицу.
Второе: отдельный вызов может стоить O(n). Тот append, на котором
случился переезд, копирует весь массив. Для среднего времени это неважно; для
хвостовых задержек — потенциально важно.
Слово «потенциально» здесь не осторожность, а точность. Скачок виден в p99
только там, где переезд большой и попадает в горячий путь запроса: срез растёт
до десятков тысяч элементов внутри обработчика. Если срез строится один раз
на старте, или его длина — сотни элементов, или переезды случаются вне запроса,
в хвосте это не проявится вовсе. Проверять надо профилем, а не арифметикой:
утверждение «append портит p99» верно про нагрузку, а не про append.
Посмотрите на обе половины сразу. Линия — суммарное копирование, отвесные засечки — переезды:
А теперь то, что обычно не рассказывают. Множитель при n — не единица. Вот замер: сколько элементов всего скопировал рантайм, пока срез рос до n:
| n | переездов | скопировано | во сколько раз больше n |
|---|---|---|---|
| 1 000 | 12 | 1 871 | 1,87 |
| 100 000 | 28 | 402 079 | 4,02 |
| 1 000 000 | 38 | 4 154 015 | 4,15 |
Множитель растёт с размером — и это не дефект, а прямое следствие
предыдущего раздела. Сумма геометрической прогрессии со знаменателем r равна
n/(r−1): при удвоении это ≈1·n, при множителе 1,25 — уже ≈4·n. На тысяче
элементов рантайм ещё удваивает, поэтому выходит 1,87; на миллионе давно
работает медленная формула, поэтому 4,15.
Вот настоящий размен, заложенный в nextslicecap: медленный рост экономит
память (меньше неиспользуемого запаса) и доплачивает копированием (вчетверо
больше перемещённых элементов). Ни то, ни другое не «оптимизация вообще» — это
выбор в пользу памяти.
И отсюда же ответ на «как это убрать»: make([]T, 0, n), если конечный размер
известен. Не «чтобы append был быстрее», а чтобы переездов не было вовсе —
в задаче в конце урока это оказалось семикратной разницей.
Как отвечать на собеседовании
Короткий ответ: срез не владеет данными — это окно над чужим массивом, и два
окна могут смотреть в одни и те же ячейки. Поэтому запись через один срез
видна во втором, а append в одних случаях дописывает в тот же массив, в других
переезжает в новый — и именно поэтому он возвращает срез, а результат
присваивают обратно.
Начинать надо с владения, а не с устройства: заголовок из трёх слов объясняет, почему окно устроено именно так, но сам по себе на вопрос не отвечает. После этой пары половина следующих вопросов уже закрыта, и это слышно.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Держите наготове минутный разбор append. Два пути; на быстром — запись в
существующий массив; на медленном — новый массив и копирование; возвращает срез
именно поэтому. Это ядро темы, и его спрашивают всегда.
Про рост говорите честно, а не уверенно. «Удваивается на малых размерах,
дальше медленнее; порог 256; множитель сразу после порога заметно больше 1,25,
к нему он только сходится; конкретные значения cap не гарантированы, потому
что считается в байтах» — это ответ человека, который смотрел в nextslicecap.
«×2 до 1024, потом ×1,25» — ответ по чужому пересказу, и один уточняющий вопрос
это вскроет.
Асимптотику разделяйте на две. «Амортизированно O(1) — это про сумму. Один вызов в момент переезда стоит O(n), и для p99 это важно». Такое разделение почти никто не проговаривает, а оно показывает, что за формулой стоит понимание.
На «как этого избежать» отвечайте формами, а не намерениями. Три штуки:
make([]T, 0, n) — убрать переезды; s[a:b:b] — отрезать запас перед выдачей
наружу; slices.Clone — не удерживать большой массив.
Дальше спросят
Если append может писать в тот же массив, зачем он вообще возвращает срез?
Потому что во втором своём случае — когда запаса не хватило — массив новый, и старый заголовок указывает уже не туда. Функция не может поменять заголовок у вызывающей стороны: он передан по значению. Единственный способ сообщить новый указатель и новую длину — вернуть их.
Отсюда и правило «результат append всегда присваивают». Компилятор Go не
пропустит вызов, у которого результат выброшен, — но append(s[:0], x...) в
чужую переменную пропустит с удовольствием.
Почему тогда не растить запас всегда вдвое — так ведь меньше копирования?
Меньше, и это измеримо: при удвоении суммарное копирование ≈1·n, при множителе 1,25 — ≈4·n. Но плата за удвоение — память: срез на миллион элементов может держать массив на два миллиона, и половина этого не используется никогда. Для долгоживущих структур в сервисе это хуже, чем лишнее копирование.
nextslicecap и есть компромисс: удвоение, пока срез мал и перерасход
незаметен, и медленный рост дальше, где перерасход в мегабайтах. Смысл вопроса
именно в этом — увидите ли вы, что это размен, а не «оптимизация».
Правда ли, что copy быстрее, чем append в цикле?
Дело не в скорости самой записи, а в числе переездов. append без заранее
заданного запаса заставляет рантайм несколько раз выделить новый массив и
скопировать уже уложенное. Если конечный размер известен, make([]T, 0, n)
убирает эти переезды — в замере в конце урока вышла семикратная разница на ста
тысячах элементов.
copy при этом не альтернатива append, а другая операция: он не растит срез,
а заполняет уже имеющуюся длину и возвращает число скопированных элементов —
минимум из двух длин. Забыть про этот минимум — отдельный источник тихих багов:
copy(dst, src) в срез нулевой длины не копирует ничего и не жалуется.
Что будет, если делать append к срезу внутри цикла по нему же?
for i := range s вычисляет длину один раз, в начале, — поэтому цикл не
станет бесконечным, сколько бы вы ни добавляли. А вот for i := 0; i < len(s); i++
перечитывает len(s) каждый шаг, и такой цикл не кончится.
Вторая половина ловушки: for i, v := range s при append может выдавать
значения из старого массива, если произошёл переезд. Диапазон запомнил
исходный заголовок, а s уже указывает на новую память.
Почему cap иногда оказывается числом, которого никто не просил?
Потому что рантайм считает в байтах: нужный размер округляется вверх до
ближайшего класса размеров аллокатора, и обратно в элементы это делится с
остатком. Поэтому в ряду для []int стоит 848, а не 832 (формула просила
6656 байт, аллокатор выдал 6784), а у структуры в 15 байт запасы идут 68, 136,
273.
Практическое следствие: на конкретные значения cap полагаться нельзя.
Гарантируется cap >= len и амортизированная константа на append, а не
последовательность чисел.
Срез это ссылочный тип?
Формулировка неудачная, и лучше её не использовать — но понимать, что человек имеет в виду, надо. В Go нет ссылочных типов в смысле C++: всё передаётся по значению. Срез передаётся по значению тоже — просто его значение это заголовок, внутри которого лежит указатель.
Разница не словесная. «Ссылочный тип» предсказывает, что функция сможет
удлинить срез у вызывающей стороны, — а она не сможет. Правильная формулировка
объясняет и почему s[0] = 1 видно снаружи, и почему append внутри функции
снаружи не виден.
Частые заблуждения
срез — это динамический массив
Срез — это заголовок над массивом: указатель, длина, запас, 24 байта на 64-битной машине. Массив ему не принадлежит и может принадлежать сразу нескольким срезам. Из аналогии с динамическим массивом не выводится ни одно из поведений, о которых спросят дальше: общая запись, удержание памяти, разница nil и пустого.
append всегда выделяет новый массив, поэтому исходные данные в безопасности
Только если не хватило запаса. Спецификация называет оба случая: Otherwise, append re-uses the underlying array
(Иначе append переиспользует нижележащий массив). У среза, взятого как s[1:3] из пятиэлементного, запас равен четырём — и append молча запишет в исходный массив. Отрезать запас можно только трёхиндексной формой s[1:3:3].
запас удваивается до 1024, дальше растёт на четверть
Оба числа не те. Порог в nextslicecap на go1.24 — 256, а не 1024: наблюдаемый ряд идёт 256, 512, 848. Множитель сразу после порога — 1,66, а не 1,25: формула прибавляет четверть плюс 192, и к 1,25 отношение только сходится. Комментарий рантайма действительно говорит «1.25x» — но он про предел, и правило, которое учат наизусть, выросло из чтения комментария вместо кода.
амортизированно O(1) значит, что append всегда дёшев
Амортизация — утверждение про сумму. Отдельный append в момент переезда копирует весь массив и стоит O(n); на графике это отвесная ступень. Для среднего времени неважно, для p99 — важно. И сумма не бесплатна: измерено, что при росте до миллиона копируется 4,15·n элементов, а не n.
удаление из среза — это просто append, и стоит оно одинаково
Запись одинаковая, цена — нет. Измерено на 10 000 элементов: удалить первый — 1401 нс, средний — 667, а на предпоследнем разница уже ниже разрешения замера. Цена пропорциональна хвосту, который надо сдвинуть, а не длине среза. И если порядок не важен, то же самое делается за O(1) заменой на последний элемент.
range по значению медленнее, потому что копирует элемент
Копирует — да, и это важно семантически: запись в v срез не меняет. Но вывод про скорость из этого не следует. Замер на структурах в 64 байта, одна машина и один прогон: 21 789 нс у range по значению против 23 157 нс у индексной формы, то есть по значению даже быстрее — у индексной своя цена, проверка границ на каждом обращении.
если вернуть маленький кусок большого среза, память освободится
Не освободится. Сборщик мусора работает с массивом целиком, и указатель на его начало жив, пока жив любой срез над ним. Три байта, возвращённые из пятидесятимегабайтного файла, держат все пятьдесят мегабайт. Освобождает копия — slices.Clone, — а не более узкий срез.
nil-срез и пустой срез — это одно и то же
Для len, append и range — да, и именно поэтому разница всплывает далеко от места создания. a == nil различает их, и json.Marshal различает: null против []. Клиент, ожидающий массив, получает null — а в коде это выглядит как «мы вернули пустой список».
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
s := []int{1, 2, 3, 4, 5}
t := s[1:3]
fmt.Println(len(t), cap(t))
t = append(t, 99)
fmt.Println(s)
fmt.Println(t)Практика · оцените
Проверка знаний
Функция принимает срез и делает s[0] = 42, ничего не возвращая. Увидит ли вызывающая сторона изменение?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Срез не владеет данными — это окно над чужим массивом. Два окна могут смотреть в одни и те же ячейки, и запись через одно видна во втором: прогон печатает, как одна запись меняет три значения — оба среза и сам массив. Это гарантия языка, а не деталь реализации. А
appendлибо дописывает в тот же массив, либо переезжает в новый — и поэтому возвращает срез. - Отсюда главное следствие:
appendможет молча записать в чужие ячейки. Запас уs[1:3]равен не двум: по спецификации этоcap(s) − low, то есть до конца массива, — иappendв такой срез пишет туда, где лежат данные соседа. Из той же общей памяти растёт и удержание: три байта, оставленные от файла в 50 МБ, держат все пятьдесят. И цена удаления зависит не от длины среза, а от хвоста, который надо сдвинуть: измерено 1401 и 667 нс при сдвиге 9999 и 4999 элементов, а при сдвиге одного и нуля разница уже ниже разрешения замера. - Дальше — числа, версии и границы. Срез — заголовок из трёх машинных слов (указатель, длина, запас); замер даёт 24 байта, и ровно они копируются при передаче в функцию, сколько бы элементов ни лежало в массиве, а
[5]int— 40 байт, потому что массив это значение. Правило «×2 до 1024, дальше ×1,25» неверно в обоих числах: в go1.24 порог назван константой 256 вnextslicecap, а множитель сразу после порога — 1,66, потому что формула прибавляет четверть плюс 192; конкретные значенияcapпри этом не гарантированы. «Амортизированно O(1)» — про сумму, а не про каждый вызов: измерено, что при росте до миллиона копируется 4,15·n элементов, а одинappendв момент переезда стоит O(n). Аfor _, v := rangeкопирует элемент — но это довод про семантику, а не про скорость: в том же замере он оказался даже быстрее индексной формы.
На самом деле
- Срез — это заголовок над массивом: указатель, длина, запас, 24 байта на 64-битной машине. Массив ему не принадлежит и может принадлежать сразу нескольким срезам. Из аналогии с динамическим массивом не выводится ни одно из поведений, о которых спросят дальше: общая запись, удержание памяти, разница
nilи пустого. - Только если не хватило запаса. Спецификация называет оба случая: Otherwise, append re-uses the underlying array. У среза, взятого как
s[1:3]из пятиэлементного, запас равен четырём — иappendмолча запишет в исходный массив. Отрезать запас можно только трёхиндексной формойs[1:3:3]. - Оба числа не те. Порог в
nextslicecapна go1.24 — 256, а не 1024: наблюдаемый ряд идёт 256, 512, 848. Множитель сразу после порога — 1,66, а не 1,25: формула прибавляет четверть плюс 192, и к 1,25 отношение только сходится. Комментарий рантайма действительно говорит «1.25x» — но он про предел, и правило, которое учат наизусть, выросло из чтения комментария вместо кода. - Амортизация — утверждение про сумму. Отдельный
appendв момент переезда копирует весь массив и стоит O(n); на графике это отвесная ступень. Для среднего времени неважно, для p99 — важно. И сумма не бесплатна: измерено, что при росте до миллиона копируется 4,15·n элементов, а не n. - Запись одинаковая, цена — нет. Измерено на 10 000 элементов: удалить первый — 1401 нс, средний — 667, а на предпоследнем разница уже ниже разрешения замера. Цена пропорциональна хвосту, который надо сдвинуть, а не длине среза. И если порядок не важен, то же самое делается за O(1) заменой на последний элемент.
- Копирует — да, и это важно семантически: запись в
vсрез не меняет. Но вывод про скорость из этого не следует. Замер на структурах в 64 байта, одна машина и один прогон: 21 789 нс у range по значению против 23 157 нс у индексной формы, то есть по значению даже быстрее — у индексной своя цена, проверка границ на каждом обращении. - Не освободится. Сборщик мусора работает с массивом целиком, и указатель на его начало жив, пока жив любой срез над ним. Три байта, возвращённые из пятидесятимегабайтного файла, держат все пятьдесят мегабайт. Освобождает копия —
slices.Clone, — а не более узкий срез. - Для
len,appendиrange— да, и именно поэтому разница всплывает далеко от места создания.a == nilразличает их, иjson.Marshalразличает:nullпротив[]. Клиент, ожидающий массив, получаетnull— а в коде это выглядит как «мы вернули пустой список».
Что разобрано
- Что здесь на самом деле спрашивают
- База: срез — это окно над чужим массивом
- Механизм 1: срез, массив и строка — три разные вещи
- Механизм 2: что делает append
- Механизм 3: удаление, вставка и цена сдвига
- Механизм 4: где срез держит память
- Механизм 5: range копирует — гарантия и наблюдение
- Механизм 6: nil и пустой срез
- Глубже: рост запаса и цена «амортизированно O(1)»
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
3 ИСТОЧНИКА
- Спецификация Go — Slice types, Slice expressions, Appending to and copying slicesОфициальная документация. Определение среза: «A slice is a descriptor for a contiguous segment of an underlying array and provides access to a numbered sequence of elements from that array» (Срез — это описатель непрерывного участка нижележащего массива, дающий доступ к пронумерованной последовательности его элементов). Совместное владение названо прямо: «A slice, once initialized, is always associated with an underlying array that holds its elements. A slice therefore shares storage with its array and with other slices of the same array» (Срез после инициализации всегда связан с нижележащим массивом, который хранит его элементы. Поэтому срез разделяет память со своим массивом и с другими срезами того же массива). Оттуда же правило запаса для `a[low:high]` — «the capacity is cap(a) - low» (запас равен cap(a) − low) — и для трёхиндексной формы `a[low:high:max]`, которая «sets the capacity to max - low» (задаёт запас равным max − low). И поведение append: «If the capacity of s is not large enough to fit the additional values, append allocates a new, sufficiently large underlying array that fits both the existing slice elements and the additional values. Otherwise, append re-uses the underlying array» (Если запаса s не хватает для дополнительных значений, append выделяет новый, достаточно большой нижележащий массив, вмещающий и существующие элементы среза, и дополнительные. Иначе append переиспользует нижележащий массив).https://go.dev/ref/spec#Slice_types
- runtime/slice.go — nextslicecap на go1.24.7Исходный код Go. Функция, которая и решает, каким станет запас. Порог назван в ней константой `const threshold = 256`, и до него возвращается ровно удвоение: `if oldCap < threshold { return doublecap }`. Дальше работает формула `newcap += (newcap + 3*threshold) >> 2` с комментарием авторов: «Transition from growing 2x for small slices to growing 1.25x for large slices. This formula gives a smooth-ish transition between the two» (Переход от роста ×2 для маленьких срезов к росту ×1,25 для больших. Формула даёт более-менее гладкий переход между ними). Комментарий описывает предел, а не ближайшие шаги: при запасе 512 та же формула даёт множитель 1,625, и только на длинных срезах он сходится к 1,25. Там же видно, что запрошенный размер потом округляется до класса размеров аллокатора — отсюда 848 вместо 832. Проверено запуском на go1.24.7.https://github.com/golang/go/blob/go1.24.7/src/runtime/slice.go
- Пакет slices — Clone и DeleteОфициальная документация. Штатный ответ на удержание большого массива маленьким срезом: «Clone returns a copy of the slice. The elements are copied using assignment, so this is a shallow clone. The result may have additional unused capacity» (Clone возвращает копию среза. Элементы копируются присваиванием, то есть это неглубокая копия. У результата может оказаться дополнительный неиспользуемый запас). Слово «shallow» здесь существенно: копируются элементы, а не то, на что они ссылаются. У Delete же документация прямо предупреждает про хвост: «Delete zeroes the elements s[len(s)-(j-i):len(s)]» (Delete обнуляет элементы s[len(s)−(j−i):len(s)]) — то есть делает руками ровно то, о чём забывают при удалении через append.https://pkg.go.dev/slices#Clone