Deep Engineering
Средний·Опубликовано·35 МИН

Срез в 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-среза и пустого.

Что здесь на самом деле спрашивают

Собеседование по срезам почти всегда идёт одной и той же лестницей, и полезно знать её заранее — тогда видно, куда ведёт разговор:

  1. «Что такое срез?» — проверяют, знаете ли вы про заголовок. Ответ «динамический массив» закрывает вопрос и открывает следующий.
  2. «Что делает append — проверяют, знаете ли вы, что случаев ДВА и что они различаются наличием запаса.
  3. «Почему здесь изменился соседний срез?» (с кодом на бумажке) — проверяют первое и второе вместе. Это главный вопрос темы.
  4. «Какая у append асимптотика?» — проверяют, отличаете ли вы амортизированную оценку от оценки одного вызова.

Все четыре — один и тот же вопрос под разными углами: знаете ли вы, что срез не владеет своими данными. Дальше урок идёт по этой лестнице.

База: срез — это окно над чужим массивом

Начнём с того, зачем срез вообще нужен. Массив в Go задаёт число элементов заранее и живёт с ним до конца: [6]int — это ровно шесть чисел, и других у него не будет. Работать так неудобно почти всегда: данных приходит столько, сколько приходит, и брать нужно то весь набор, то его кусок. Срез и есть ответ на это — способ смотреть на часть массива и говорить о ней как о целом.

Ключевое слово здесь — «смотреть». Срез не забирает элементы себе и не делает их копию: он лишь описывает, какой отрезок чужого массива через него виден. Отсюда и главная неожиданность темы: если сделать из среза кусок и записать что-то в этот кусок, изменится исходный массив, а с ним и все остальные срезы, которые смотрят в те же ячейки.

Посмотрите, как это выглядит. Один массив и два среза над ним — и одна запись:

GO
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] — это одна и та же ячейка массива.

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

контракт языкаГарантия языка: не изменится ни от версии Go, ни от машины.

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.

перевод

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

Спецификация Go — Slice types

Держите эту фразу под рукой: она одна отвечает на три разных вопроса собеседования. Раз данные общие — добавление в один срез может записать в чужие ячейки. Раз массив может смениться — добавление обязано возвращать новое окно. Раз окно удерживает весь массив — короткий срез держит длинный в памяти.

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

Механизм 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 из рантайма, и он делает четыре шага:

  1. считает новый запас — это nextslicecap, разбор в «Глубже»;
  2. переводит его в байты и округляет вверх до класса размеров аллокатора;
  3. выделяет новый массив;
  4. копирует в него len уже уложенных элементов и кладёт новый.

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

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

Второй сценарий на этой картинке важнее первого. Без него уносится правило «append опасен», а оно неверно: опасна ровно комбинация «длина меньше запаса» — то есть ровно та, которую создаёт s[1:3].

Механизм 3: удаление, вставка и цена сдвига

Каноническая идиома удаления выглядит одинаково для любого индекса:

GO
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 элементов:

наблюдение замераbench/goslice/internals.go, go1.24.7 linux/amd64, два ядра. Из каждой строки вычтено время подготовки среза; абсолютные наносекунды зависят от машины, содержателен ряд.
что делаемсдвигается элементоввремя
удалить первый9 9991 401 нс
удалить средний4 999667 нс
удалить предпоследний1ниже разрешения
укоротить с конца, s = s[:len(s)-1]0ниже разрешения

Линейность видна прямо в числах: 1401 к 667 — это примерно 9999 к 4999. Цена зависит не от длины среза, а от длины хвоста, который надо сдвинуть. Удаление из начала — O(n), с конца — O(1), и это одна и та же запись при разных i.

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

Практический вывод, который стоит назвать: если порядок элементов не важен, удаление делается за O(1) заменой на последний:

GO
s[i] = s[len(s)-1]
s = s[:len(s)-1]

И отдельная ловушка — та, из-за которой удаление течёт. Укоротив срез, вы уменьшили длину, но массив цел, и элемент за длиной по-прежнему в нём лежит. Если это указатель, объект за ним не соберётся:

GO
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: где срез держит память

Тот же механизм, но в масштабе, который стоит денег:

GO
func firstThree(path string) []byte {
    data, _ := os.ReadFile(path)   // 50 МБ
    return data[:3]                // отдали три байта
}

Вернули три байта — в памяти остались пятьдесят мегабайт. Сборщик мусора работает с массивом целиком: жив указатель на его начало — жив весь массив. Более узкий срез ссылку не убирает, он только уменьшает длину.

Убирает копия:

GO
return slices.Clone(data[:3])

slices.Clone returns a copy of the slice (возвращает копию среза), и после него исходный массив никем не удерживается. Оговорка из документации существенна: копия неглубокая, и если элементы сами на что-то ссылаются, это что-то останется общим.

Это, кстати, лучший ответ на вопрос «когда вы в последний раз ловили утечку в Go»: чаще всего утечка в Go — не забытая горутина, а вот такой удержанный массив.

Механизм 5: range копирует — гарантия и наблюдение

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

контракт языкаГарантия языка: проверять нечего, это следует из семантики оператора range.

v — это копия элемента, и запись в неё срез не меняет.

GO
for _, v := range items {
    v.done = true        // потеряно: v — копия
}
for i := range items {
    items[i].done = true // так
}

Это не про скорость и не про версию Go. Это про то, что range по значению присваивает элемент переменной цикла, а присваивание в Go копирует. Ошибка из-за этого не компилируется в предупреждение и не падает в тестах: код честно меняет копию.

наблюдение замераbench/goslice/internals.go, срез из 10 000 структур по 64 байта, go1.24.7 linux/amd64, два ядра.

А вот вывод «значит, range по значению медленнее» замер не подтверждает.

for _, v := range xs   21 782 нс   — структура в 64 байта копируется
for j := range xs      22 577 нс   — копии нет

По значению вышло быстрее, кратность 0,96. Причина в том, что у индексной формы своя цена: на каждом обращении xs[j] компилятор проверяет границы, а range по значению идёт по памяти подряд без проверок. Две цены оказались одного порядка, и какая перевесит — зависит от размера элемента, от того, что делается внутри цикла, и от того, сумел ли компилятор убрать проверку границ.

Обобщать это число нельзя ни в одну сторону: на структуре в килобайт копия наверняка перевесит. Содержательно здесь другое — что разница не на порядок, а значит, выбирать форму цикла надо по семантике, а не по скорости.

На собеседовании это хороший ход: назвать копирование как гарантию, а производительность — как измеряемое, и не путать их. Ровно так и обстоит дело.

Механизм 6: nil и пустой срез

GO
var a []int      // nil
b := []int{}     // не nil

Длина у обоих ноль, append работает с обоими, for range не сделает ни шага. Отличия ровно два, и оба всплывают далеко от места создания:

var a []int[]int{}
a == niltruefalse
json.Marshalnull[]

Второе — источник настоящих багов: клиент, ожидающий массив, получает null и падает. В коде это выглядит как «мы же вернули пустой список».

Правило: внутри кода разницы не делайте — проверяйте len(s) == 0, а не s == nil; а на границе с JSON инициализируйте явно.

Глубже: рост запаса и цена «амортизированно O(1)»

Дальше начинается другой сорт утверждений, и разницу стоит проговорить. Всё, что было выше, описывало контракт языка: он не изменится. Всё, что ниже про конкретные числа роста, — устройство сегодняшней реализации, и оно уже менялось.

деталь реализации · Go 1.24Формула роста запаса не описана в спецификации и менялась между версиями. Ниже — то, что делает нынешний рантайм; полагаться на конкретный cap в коде нельзя.

Здесь живёт самое частое заученное правило — и оно неверно. Вот последовательность запасов, через которые реально проходит []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. Вот соответствующий кусок рантайма, целиком:

GO
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 000121 8711,87
100 00028402 0794,02
1 000 000384 154 0154,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)

Практика · оцените

Сто тысяч элементов через append. Во сколько раз быстрее, если запас задан заранее через make(…, 0, N)?
раза

Проверка знаний

Вопрос 1 из 6

Функция принимает срез и делает s[0] = 42, ничего не возвращая. Увидит ли вызывающая сторона изменение?

Источники и что читать дальше

3 ИСТОЧНИКА

  1. Спецификация 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
  2. 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
  3. Пакет 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