voidlearn
 

1.2Массивы и слайсы

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

Что меняется при передаче слайса

При передаче []int в функцию копируется заголовок слайса. Элементы остаются в прежнем массиве. Поэтому нужно отдельно следить за изменениями заголовка и за записями в массив. Это пригодится, когда функция наполняет переданный буфер, обработчик сохраняет часть запроса или несколько горутин используют одни данные.

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

Для разбора конкретного случая найди его в оглавлении. Стратегия роста ёмкости объясняет работу рантайма; точные значения cap после роста не входят в гарантии языка.

Массив: длина - часть типа

Массив в Go хранит фиксированное число элементов, они лежат в памяти подряд. Дальше всё держится на одном: длина входит в тип. [3]int и [4]int считаются разными типами, и функция, принимающая [3]int, не примет [4]int. Длина обязана быть константным выражением, известным на этапе компиляции.

var a [3]int                 // [0 0 0]
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3, 4}    // [4]int - компилятор посчитал длину сам
d := [5]int{2: 9}            // [0 0 9 0 0] - индексированный литерал

fmt.Printf("%T %T\n", b, c)  // [3]int [4]int - разные типы
// b = c                     // ошибка компиляции:
//                           // cannot use c (variable of type [4]int) as [3]int value in assignment

e := b                       // копия всех 24 байт
e[0] = 99
fmt.Println(b[0], e[0])      // 1 99 - независимы

fmt.Println(b == [3]int{1,2,3}) // true - массивы comparable, если comparable элементы

Массив полезен, когда размер данных фиксирован и должен быть частью типа. Его копирование переносит сами элементы, а сравнение доступно, если сравним тип элемента. Размещение на стеке или в куче зависит от использования значения и решений компилятора.

  • Фиксированные буферы и хеши: [32]byte для SHA-256, [16]byte для UUID. Фиксированный размер проверяется типом; такие массивы сравнимы и годятся в ключи мапы.
  • Ключи мапы: map[[2]int]bool работает, а map[[]int]bool нет.
  • Стековые буферы: var buf [64]byte; s := buf[:]. Массив остаётся на стеке, если не убегает, и слайс над ним не даёт аллокации в куче.

Слайс - это структура из трёх полей

Что хранится в переменной

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

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

Указатель, длина и ёмкость

Каждое поле задаёт отдельную часть поведения слайса.

  • array: где начинается окно. Адрес первого элемента, который слайсу «виден». Без него до данных не добраться.
  • len: сколько элементов сейчас в окне. Это вернёт len(s), по этому пройдётся range, а за этой границей индексация даёт панику.
  • cap: сколько места есть в запасе, то есть сколько элементов можно охватить от начала окна без смены массива. Третий индекс может ограничить эту область. Это поле отвечает на вопрос «переживёт ли следующий append без переезда в новую память».

len ограничивает индексацию, а cap определяет доступный запас для нарезки и добавления элементов. Число свободных ячеек равно cap(s) - len(s).

В рантайме заголовок представлен тремя машинными словами. На 64-битной платформе он занимает 24 байта независимо от числа элементов. Размер массива с данными учитывается отдельно.

// runtime/slice.go: вот что лежит в переменной типа []T
type slice struct {
    array unsafe.Pointer // указатель на первый элемент окна
    len   int            // сколько элементов видно
    cap   int            // сколько элементов есть от array до конца массива
}
// на 64-битной платформе: 8 + 8 + 8 = 24 байта
fmt.Println(unsafe.Sizeof([]int{}))   // 24

У слайса три инварианта, и из них выводится всё остальное поведение: 0 <= len <= cap; len держит границу индексации (за ней паника index out of range); cap держит границу нарезки (s[:cap(s)] легально, s[:cap(s)+1] уже паника).

Массив в памяти: arr := [8]int{10,20,30,40,50,60,70,80} 0 1 2 3 4 5 6 7 10 20 30 40 50 60 70 80 len = 3 - сюда можно индексировать cap = 5 - досюда можно нарезать и расти без аллокации недостижимо Заголовок: s := arr[1:4:6] array len cap ptr 3 5 low=1 high=4 max=6 len = high - low = 3 cap = max - low = 5 Переменная типа []int - это ровно эти 24 байта. Копирование слайса копирует их, массив остаётся один. Третий индекс (max) обрезает cap: без него было бы cap = 7, и append залез бы в чужие элементы 4 и 5.
Slice header и окно в массиве. len ограничивает индексацию, cap ограничивает нарезку и «бесплатный» рост. Всё поведение слайсов выводится из этой картинки.
Как пользоваться моделью

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

Три способа создать слайс (и ещё пара)

Способ Код len / cap Аллокация
Литерал s := []int{1, 2, 3} 3 / 3 да (или на стеке, если не убегает)
make с длиной s := make([]int, 5) 5 / 5, нули да
make с длиной и ёмкостью s := make([]int, 0, 5) 0 / 5 да
Нарезка массива/слайса s := arr[1:4] 3 / cap(arr)-1 нет - общий массив
Объявление (nil) var s []int 0 / 0, ptr=nil нет
new + нарезка s := new([5]int)[:] 5 / 5 да

make([]T, 0, n) это преаллокация: «мне нужно место под n элементов, но пока их ноль». Смысл в том, чтобы append не пересоздавал массив по дороге. Разница видна на бенчмарке: без ёмкости на 10 000 элементов будет примерно 20 перевыделений и столько же копирований всего содержимого, суммарно около 20 000 скопированных элементов вместо 10 000 записей.

// Плохо: 0 → 1 → 2 → 4 → 8 → ... → 16384, ~20 аллокаций и копирований
func bad(n int) []int {
    var s []int
    for i := 0; i < n; i++ { s = append(s, i) }
    return s
}

// Хорошо: одна аллокация, append только пишет
func good(n int) []int {
    s := make([]int, 0, n)
    for i := 0; i < n; i++ { s = append(s, i) }
    return s
}

// Частая ошибка: make([]T, n) вместо make([]T, 0, n)
func wrong(n int) []int {
    s := make([]int, n)              // len == n, слайс уже полон нулей
    for i := 0; i < n; i++ { s = append(s, i) }
    return s                          // длина 2n: n нулей, потом n значений
}
Заполняй по индексу или добавляй в конец

make([]T, n) и make([]T, 0, n) путают постоянно. Первый вариант годится, когда ты пишешь по индексу (s[i] = v), второй когда через append. Смешивать нельзя: получишь слайс двойной длины с нулями в начале.

Передача в функцию: копируется заголовок

Слайс передаётся по значению. Функция получает свой заголовок с указателем на тот же массив. Запись в существующий элемент меняет общие данные, а присваивание s = append(s, ...) обновляет локальный заголовок. Чтобы вызывающий код получил новую длину, функция должна вернуть результат или изменить переменную через *[]T.

func modify(s []int)   { s[0] = 100 }        // видно снаружи: общий массив
func grow(s []int)     { s = append(s, 4) }  // не видно: изменили копию заголовка
func growP(s *[]int)   { *s = append(*s, 4) }// видно: пишем в саму переменную

s := []int{1, 2, 3}
modify(s); fmt.Println(s)   // [100 2 3]
grow(s);   fmt.Println(s)   // [100 2 3] - длина не изменилась
growP(&s); fmt.Println(s)   // [100 2 3 4]

Указатель на слайс (*[]T) в прикладном коде почти не нужен: идиоматично вернуть новый слайс (s = f(s)), ровно так устроен сам append. *[]T оправдан в горячих местах, где надо наполнять переданный буфер без возврата, и когда пишешь json.Unmarshaler / sql.Scanner.

append: общий массив или новая память

append не метод и не обычная функция, а встроенная: компилятор разворачивает её вызов в инлайновый код. Логика такая:

  1. Посчитать нужную длину: newLen = len(s) + len(добавляемого).
  2. Если newLen <= cap(s), то ничего не выделять: записать элементы в существующий массив начиная с индекса len(s) и вернуть заголовок с новым len.
  3. Иначе вызвать runtime.growslice: посчитать новый cap, выделить новый массив, скопировать в него старые элементы (memmove), дописать новые, вернуть новый заголовок.

Из шага 2 растёт вся «магия» слайсов: при достаточном cap append молча пишет в память, которую видят и другие слайсы. Из шага 3 растёт другое: после роста связь с исходным массивом теряется.

cap хватает: newLen <= cap до: len=3 cap=4 1 2 3 пусто append(s, 9) после: len=4 cap=4 - тот же массив 1 2 3 9 Все слайсы, смотрящие на этот массив, увидят девятку. Это и есть источник «таинственно изменившихся» данных. cap не хватает: growslice до: len=4 cap=4 1 2 3 9 append(s, 7) → новый массив после: len=5 cap=8 - ДРУГОЙ массив 1 2 3 9 7 Старые элементы скопированы (memmove). Связь со старым массивом разорвана: записи в новый слайс старому не видны. Вывод один и тот же в обе стороны: результат append надо ВСЕГДА присваивать - s = append(s, v).
Две ветки append. Пока вместимости хватает, массив общий и правка видна всем. Не хватило, значит массив новый, а старые владельцы остались со старыми данными. Классические задачи про слайсы сводятся к выбору между этими двумя картинками.

Стратегия роста capacity

Считает её runtime.growslice в runtime/slice.go. На точный коэффициент роста нельзя опираться в прикладном коде. Алгоритм двухступенчатый, а сверху ещё накладывается округление до классов размеров аллокатора.

runtime.growslice: как считается newcap (Go 1.18+) newLen > 2 * oldCap ? newcap = newLen разовый большой append - берём ровно сколько надо да нет oldCap < 256 ? newcap = 2 * oldCap маленькие слайсы удваиваются, как раньше да нет newcap += (newcap + 3*256) / 4 повторять, пока newcap < newLen коэффициент плавно падает с 2x к 1.25x Последний шаг: roundupsize() newcap умножается на размер элемента и округляется вверх до ближайшего size class аллокатора (8, 16, 24, 32, 48, 64, 80, 96, 112, 128…). Поэтому реальный cap часто больше расчётного: append одного int к слайсу с cap=5 даст cap=8, а не 6 - так требует mallocgc.
growslice. Порог 256 элементов и формула + (cap + 768)/4 появились в Go 1.18 взамен старого порога в 1024 элемента и жёсткого множителя 1.25. Рост стал плавным, без скачка коэффициента.
Версия Порог Ниже порога Выше порога Проблема, которую чинили
до Go 1.18 1024 элемента cap × 2 cap × 1.25 (жёстко) резкий разрыв: на 1024 коэффициент падал с 2.0 до 1.25 одним шагом
Go 1.18 и новее 256 элементов cap × 2 cap += (cap + 3*256) / 4 плавный переход: при cap=256 это ×1.75, при cap=1024 - ×1.44, при cap=100000 - ×1.25
// Быстрее всего проверить руками, что «всегда x2» неверно
s := make([]int, 0)
prev := 0
for i := 0; i < 5000; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("len=%-5d cap=%-6d x%.2f\n", len(s), cap(s), float64(cap(s))/float64(max(prev,1)))
        prev = cap(s)
    }
}
// Прогон на go1.27, []int, пары len cap:
// 1 4 | 5 8 | 9 16 | 17 32 | 33 64 | 65 128 | 129 256 | 257 512
// 513 848 | 849 1280 | 1281 1792 | 1793 2560 | 2561 3408 | 3409 5120
// Первый же append даёт cap=4, а не 1: с Go 1.25 слайсу, который не убегает,
// компилятор даёт стартовый буфер в 32 байта на стеке, отсюда cap = 32/размер -
// []byte → 32, []int32 → 8, []int/[]int64 → 4, []string → 2, struct{4 int} → 1.
// До 256 элементов ровно x2, после 256 множитель падает
// (x1.66, x1.51, x1.40...), а «некруглые» цифры даёт roundupsize.
Детали рантайма: округление размера

growslice считает байты, а не элементы, и отдаёт их mallocgc, который умеет выделять только фиксированные классы размеров (size classes), иначе фрагментация и медленный аллокатор. Округление вверх до класса «возвращается» обратно в cap, чтобы память не пропадала зря. Отсюда 848 вместо 640 и 3408 вместо 3200. Ещё для элементов размера 1 байта ([]byte) и для размеров-степеней двойки есть отдельные быстрые ветки, поэтому у []byte и []int прогрессии cap разные.

Full slice expression: третий индекс

s[low:high:max] задаёт len = high-low и cap = max-low, при 0 <= low <= high <= max <= cap(s). При max == high запас для роста равен нулю. Добавление хотя бы одного элемента через append потребует нового массива. Существующие элементы остаются общими.

base := []int{1, 2, 3, 4, 5}

a := base[0:2]        // len=2 cap=5  - опасно
a = append(a, 99)
fmt.Println(base)     // [1 2 99 4 5]  ← затёрли base[2]

base = []int{1, 2, 3, 4, 5}
b := base[0:2:2]      // len=2 cap=2  - безопасно
b = append(b, 99)     // cap исчерпан → новый массив
fmt.Println(base, b)  // [1 2 3 4 5] [1 2 99]
Правило для библиотечного кода

Если получатель подслайса не должен дописывать в хвост твоего буфера, верни buf[i:j:j] или используй slices.Clip. Это ограничивает рост через append, но запись view[0] = v по-прежнему меняет общий массив. Для независимых элементов нужна копия. Для короткого чтения без сохранения результата общий буфер может быть частью явного контракта функции.

Утечки памяти через подслайсы

Сборщик мусора в Go работает с объектами целиком: он не умеет освободить «хвост» массива, если на его начало кто-то смотрит. Один живой слайс на пять элементов удерживает в куче весь массив на десять миллионов. Ровно та же история со строками и с []byte из io.ReadAll.

Плохо: small := big[:5] small len=5 cap=10M 9 999 995 элементов: недостижимы из кода, но живы для GC - примерно 80 МБ GC видит указатель внутрь массива → массив целиком остаётся в куче, пока жив small. Хорошо: small := slices.Clone(big[:5]) small len=5 cap=5 40 Б старый массив: ссылок больше нет → следующий цикл GC его заберёт Копия стоит O(k), но освобождает O(n). При k << n это всегда выгодно. Те же грабли: s = s[i:] в цикле (начало массива недостижимо, но живо), strings.Split большого файла, хранение sub := longString[10:20] в кэше, io.ReadAll + сохранение маленького куска ответа.
Удержание массива подслайсом. Лечение всегда одно: скопировать нужное в свежий слайс (slices.Clone, copy) и отпустить исходный.
// Утечка: маленький результат держит гигантский вход
func firstLine(data []byte) []byte {
    i := bytes.IndexByte(data, '\n')
    if i < 0 {
        i = len(data)
    }
    return data[:i]                     // держит весь data
}

// Лечим
func firstLineOK(data []byte) []byte {
    i := bytes.IndexByte(data, '\n')
    if i < 0 {
        i = len(data)
    }
    return bytes.Clone(data[:i])        // копия не удерживает исходный буфер
}

// Вторая утечка: элементы-указатели остаются достижимыми после «удаления»
func pop(s []*Item) []*Item {
    last := len(s) - 1
    s[last] = nil                       // без этого Item жив, хоть и вне len
    return s[:last]
}
Второй уровень утечки: хвост между len и cap

Уменьшил len, а элементы никуда не делись: лежат в массиве между len и cap. Если это указатели, интерфейсы, строки или структуры с указателями внутри, GC продолжает считать их живыми. Поэтому в очередях и стеках хвост явно обнуляют. В стандартной библиотеке так делают slices.Delete (с Go 1.22) и slices.DeleteFunc: они зануляют освободившиеся ячейки.

Удаление элементов и сложность операций

Операция Идиома Сложность Комментарий
Добавить в конец s = append(s, v) O(1) амортизированно худший случай O(n) при росте массива
Удалить с конца s = s[:len(s)-1] O(1) обнули s[len(s)-1], если это указатель
Удалить из начала s = s[1:] O(1) но начало массива больше не освободить
Удалить из начала (с копией) s = append(s[:0], s[1:]...) O(n) сдвигает всё, массив тот же
Удалить из середины (порядок важен) s = slices.Delete(s, i, i+1) O(n) эквивалент append(s[:i], s[i+1:]...) + зануление
Удалить из середины (порядок не важен) s[i] = s[len(s)-1]; s = s[:len(s)-1] O(1) «swap-remove», ломает порядок
Вставить в середину s = slices.Insert(s, i, v) O(n) сдвиг вправо + возможный рост
Фильтрация на месте s = slices.DeleteFunc(s, pred) O(n) без лишней аллокации
Поиск slices.Index / slices.BinarySearch O(n) / O(log n) бинарный - только по отсортированному
// Фильтрация без аллокации: пишем в тот же массив
s = s[:0]
for _, v := range src {
    if keep(v) { s = append(s, v) }
}

// Очередь на слайсе: s = s[1:] в бесконечном цикле даёт утечку
q := make([]int, 0, 8)
for {
    q = append(q, produce())   // хвост растёт вправо
    head := q[0]
    q = q[1:]                  // окно ползёт вправо, cap уменьшается
    _ = head
    // cap(q) убывает → append периодически выделяет новый массив и копирует,
    // а «отрезанное» начало старого массива недостижимо, но живо до следующего роста.
    // Память пилообразно растёт. Правильно: кольцевой буфер, container/list,
    // или copy(q, q[1:]); q = q[:len(q)-1]
}

copy: семантика

n := copy(dst, src) копирует min(len(dst), len(src)) элементов и возвращает это число. Обрати внимание: смотрит на len, а не на cap, вот самая частая ошибка. При перекрытии областей копирование тоже работает корректно (внутри memmove). Особый случай: copy(dst []byte, src string) разрешён.

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

dst := make([]int, 3)
fmt.Println(copy(dst, src), dst)   // 3 [1 2 3]

bad := make([]int, 0, 10)
fmt.Println(copy(bad, src), bad)   // 0 [] - len(dst)==0, ничего не скопировалось

ok := make([]int, len(src))
copy(ok, src)                      // 5

// Сдвиг влево с перекрытием работает как надо
copy(src, src[1:])                 // [2 3 4 5 5]

n := copy(make([]byte, 4), "привет") // 4 байта строки

nil-слайс и пустой слайс

var s []int s := []int{} make([]int, 0)
Поле array nil указатель на runtime.zerobase то же
len / cap 0 / 0 0 / 0 0 / 0
s == nil true false false
append работает работает работает
range, len работают работают работают
json.Marshal null [] []
Аллокация нет нет (нулевой размер) нет
Практический вывод

Для проверки пустоты используй len(s) == 0: это работает и с nil, и с непустым по указателю слайсом без элементов. Различать их нужно, когда это часть контракта. В encoding/json nil-слайс кодируется как null, пустой ненулевой слайс как []. omitempty убирает пустое поле целиком, поэтому выбирай его только тогда, когда отсутствие поля допустимо.

Сравнение и многомерность

Слайсы не comparable: == для них запрещён компилятором (разрешено только сравнение с nil). Причина не техническая, а семантическая: непонятно, что считать равенством, совпадение заголовков, поэлементное сравнение или глубокое? Go отказался выбирать. Сравнивают через slices.Equal (быстро, поэлементно, без рефлексии), slices.EqualFunc, bytes.Equal для []byte или reflect.DeepEqual (медленно, но «глубоко»).

a := []int{1, 2, 3}
b := []int{1, 2, 3}
// _ = a == b                      // ошибка: slice can only be compared to nil
fmt.Println(slices.Equal(a, b))    // true    (Go 1.21+)
fmt.Println(reflect.DeepEqual(a,b))// true, сравнение через рефлексию
fmt.Println(bytes.Equal([]byte("x"), []byte("x"))) // true

// DeepEqual считает nil и пустой слайс разными
fmt.Println(reflect.DeepEqual([]int(nil), []int{}))  // false
fmt.Println(slices.Equal([]int(nil), []int{}))       // true - сравнивает содержимое

Многомерного слайса в Go нет, есть слайс слайсов. Каждая строка это отдельный заголовок; массивы строк могут быть раздельными или общими. Строки бывают разной длины (jagged), и памяти это стоит дороже, чем плоский массив.

// Обычный способ: n аллокаций + 1
grid := make([][]int, rows)
for i := range grid { grid[i] = make([]int, cols) }

// Ловушка: одна общая строка на всех
row := make([]int, cols)
bad := make([][]int, rows)
for i := range bad { bad[i] = row }   // все строки - один массив
bad[0][0] = 7
fmt.Println(bad[1][0])                // 7

// Общее хранилище элементов и отдельный слайс заголовков строк
flat := make([]int, rows*cols)
grid2 := make([][]int, rows)
for i := range grid2 { grid2[i] = flat[i*cols : (i+1)*cols : (i+1)*cols] }

Проверь модель на коде

// (A)
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3]
s2[0] = 99
s2 = append(s2, 100)
fmt.Println(s1)
fmt.Println(s2, len(s2), cap(s2))
// (B)
a := make([]int, 3, 4)
b := append(a, 1)
c := append(a, 2)
b[0] = 7
fmt.Println(a, b, c)
fmt.Println(b[3], c[3])

(A) → [1 99 3 100 5], затем [99 3 100] 3 4. s2 имеет len=2, cap=4 (от индекса 1 до конца массива), поэтому append не выделяет новый массив, а пишет в s1[3].

(B) → [7 0 0] [7 0 0 2] [7 0 0 2], затем 2 2. У a есть запас cap=4, поэтому оба append ничего не выделяют, а пишут в одну и ту же ячейку с индексом 3: второй затёр единицу двойкой. b, c и a смотрят в один массив тремя заголовками, поэтому b[0] = 7 видно везде. Если бы a создали как make([]int, 3) (то есть cap=3), каждый append выделил бы свой массив и ответ стал бы [0 0 0] [7 0 0 1] [0 0 0 2], а b[0] = 7 было бы видно только в b. Разница объясняется тем, какой массив получил каждый результат append.

Практика: владение буфером

У функции, которая принимает или возвращает слайс, должен быть понятный договор о данных. Можно ли менять элементы? Кто хранит результат? Когда исходный буфер будут использовать повторно? Ограничение ёмкости и копирование решают разные задачи.

Что нужноПодходГраница
Прочитать часть буфера во время вызоваПодслайс без копииПолучатель не сохраняет и не меняет данные; буфер остаётся действительным до конца чтения.
Защитить соседние элементы от appendbuf[i:j:j] или slices.ClipЗапись по индексу всё ещё меняет общий массив.
Сохранить данные после повторного использования буфераbytes.Clone или slices.CloneКопия поверхностная: указатели и вложенные слайсы сохраняют общие объекты.

1. Защита хвоста и изменение элементов

Предскажи обе строки вывода. Что изменится, если заменить нарезку на bytes.Clone(base[:4])?

base := []byte("HEADbody")
header := base[:4:4]
header[0] = 'h'
extended := append(header, '!')
fmt.Println(string(base))
fmt.Println(string(extended))
Разбор изменения буфера

Получится hEADbody и hEAD!. Запись в header[0] изменила общий массив. Для append места нет, поэтому extended получил новый массив. С bytes.Clone первая строка будет HEADbody: заголовок пакета меняется уже в копии.

2. Результат переживает входной буфер

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

func prefix(packet []byte) []byte {
    return packet[:4:4]
}

packet := []byte("HEADbody")
saved := prefix(packet)
copy(packet, "NEXTbody")
fmt.Println(string(saved))
Разбор времени жизни данных

Вывод будет NEXT. Ограничение ёмкости не мешает владельцу массива перезаписать его. Для независимого результата верни bytes.Clone(packet[:4]); тогда сохранится HEAD. Здесь длина пакета заранее не меньше четырёх байт. В парсере внешних данных сначала проверь длину и верни ошибку для короткого пакета.

Если запись и чтение общего буфера выполняются одновременно, возникает гонка данных. Передача заголовка в горутину или канал не копирует элементы. Обсуждение синхронизации продолжает тема "Go: конкурентность".

3. Поверхностная копия

Предскажи вывод. Достаточно ли здесь slices.Clone, чтобы можно было независимо менять каждую строку?

rows := [][]byte{[]byte("Go")}
cloned := slices.Clone(rows)
cloned[0][0] = 'N'
fmt.Println(string(rows[0]), string(cloned[0]))
Разбор вложенных слайсов

Получится No No. Новый внешний массив хранит копии заголовков строк. Внутренний байтовый массив общий. Для независимых строк скопируй каждую отдельно:

cloned := make([][]byte, len(rows))
for i := range rows {
    cloned[i] = bytes.Clone(rows[i])
}

Связи с другими темами

Размещение массива на стеке или в куче и стоимость его удержания продолжает тема "Память и runtime". Совместный доступ из горутин разбирается в "Go: конкурентность". Здесь достаточно разделить копирование заголовка, изменение общих элементов и создание нового массива.

Вопросы

16
Суть: массив - значение фиксированного размера, длина входит в его тип. Слайс - заголовок из трёх полей (ptr/len/cap), описывающий окно в чужом массиве.
Массив [N]T Слайс []T
Длина часть типа, константа времени компиляции поле len, меняется в рантайме
Что в переменной сами элементы, подряд 24 байта: указатель, len, cap
Копирование все элементы только заголовок, массив общий
Передача в функцию дорого при больших N всегда 24 байта
Сравнение == да, если элементы comparable нет, только с nil
Ключ мапы да нет
Рост невозможен append
Zero value массив нулей nil: ptr=nil, len=0, cap=0
arr := [3]int{1, 2, 3}
sl  := []int{1, 2, 3}
fmt.Println(unsafe.Sizeof(arr), unsafe.Sizeof(sl))  // 24 24 - совпало случайно
arr2 := [10]int{}
sl2  := make([]int, 10)
fmt.Println(unsafe.Sizeof(arr2), unsafe.Sizeof(sl2)) // 80 24

Практически: массивы берут для фиксированных структур (хеши [32]byte, UUID [16]byte, ключи мап, стековые буферы), слайсы во всём остальном. Массив почти всегда живёт внутри слайса или структуры, а не сам по себе.

Общий массив и разные заголовки

«Слайс это не контейнер, а вид (view) на массив. Массив владеет памятью, слайс только смотрит. Поэтому два слайса смотрят на одну память, а один слайс после append может перестать смотреть на прежнюю».

Суть: три машинных слова - unsafe.Pointer на первый элемент окна, len (граница индексации) и cap (граница нарезки и запас под append).
type slice struct {          // runtime/slice.go
    array unsafe.Pointer
    len   int
    cap   int
}

Инварианты

  • 0 <= len <= cap. Нарушить нельзя: make([]int, 5, 3) не скомпилируется.
  • Индексация проверяется по len: s[len(s)] → паника index out of range.
  • Нарезка проверяется по cap: s[:cap(s)] легально и «воскрешает» элементы за пределом len; s[:cap(s)+1] → паника slice bounds out of range.
  • cap считается от начала окна до конца массива, а не от начала массива. Поэтому у s[2:] cap меньше, чем у s.
s := make([]int, 3, 10)
fmt.Println(len(s), cap(s))        // 3 10
t := s[2:]
fmt.Println(len(t), cap(t))        // 1 8  - cap уменьшился на смещение
u := s[:cap(s)]
fmt.Println(len(u), cap(u))        // 10 10 - «расширили» окно до cap

// Посмотреть на указатель без reflect.SliceHeader (он deprecated с Go 1.21):
fmt.Printf("%p %p\n", unsafe.SliceData(s), unsafe.SliceData(t)) // t сдвинут на 16 байт
Зачем это знать на практике

Заголовок это значение, значит он живёт на стеке вызывающего и копируется при каждой передаче. Отсюда: передавать *[]T «ради скорости» бессмысленно (24 байта против 8, разница в пределах шума, зато появляется разыменование). Отсюда же и то, что слайс в структуре занимает 24 байта и содержит указатель, то есть структура становится «интересной» GC: при сканировании кучи её придётся обходить.

Суть: литерал, make, нарезка существующего массива/слайса. make([]T, 0, n) - преаллокация: один раз выделили память, дальше append только пишет, без перевыделений и копирований.
a := []int{1, 2, 3}          // 1) литерал: len=3 cap=3
b := make([]int, 5)          // 2) make: len=5 cap=5, заполнен нулями
c := make([]int, 0, 5)       //    make с cap: len=0 cap=5
d := arr[1:4]                // 3) нарезка: делит массив с arr
var e []int                  // +  nil-слайс: len=0 cap=0, аллокации нет
f := new([5]int)[:]          // +  экзотика: массив в куче + слайс на него

Почему преаллокация важна

Каждое перевыделение это mallocgc плюс memmove всего содержимого. Наполняешь слайс из 10 000 элементов без ёмкости, получаешь около 20 аллокаций и суммарно около 20 000 скопированных элементов; вдобавок промежуточные массивы становятся мусором и дают работу GC. С make([]T, 0, n) будет одна аллокация и ноль копирований.

// Типичная разница на бенчмарке (n = 10 000, []int)
// BenchmarkNoPrealloc   ~ 45 000 ns/op   386 KB/op   20 allocs/op
// BenchmarkPrealloc     ~  9 000 ns/op    81 KB/op    1 allocs/op

// Чаще всего это забывают при маппинге из БД/gRPC:
out := make([]DTO, 0, len(rows))     // длина известна заранее - грех не воспользоваться
for _, r := range rows { out = append(out, toDTO(r)) }
Границы преаллокации

1) make([]T, n) вместо make([]T, 0, n): получаешь 2n элементов, первые n нулевые. Линтер makezero это ловит. 2) «Преаллокация всегда хорошо»: если n приходит снаружи (из тела запроса), то make([]T, 0, n) превращается в вектор DoS. Клиент пришлёт n = 1e9, и сервер ляжет по OOM. Нужен верхний предел.

Суть: по значению - копируется заголовок из 24 байт. Массив общий, поэтому изменения элементов видны снаружи, а изменения len/cap - нет.
func setFirst(s []int) { s[0] = 100 }          // видно снаружи
func appendOne(s []int) { s = append(s, 4) }   // не видно
func clear(s []int) { s = nil }                // не видно
func appendPtr(s *[]int) { *s = append(*s, 4) }// видно

s := []int{1, 2, 3}
setFirst(s);  fmt.Println(s)  // [100 2 3]
appendOne(s); fmt.Println(s)  // [100 2 3]
clear(s);     fmt.Println(s)  // [100 2 3]
appendPtr(&s); fmt.Println(s) // [100 2 3 4]

Почему так

В функции появляется своя переменная s, копия заголовка. Запись s[0] = 100 идёт по указателю в общий массив, поэтому видна всем. А s = append(...) и s = nil меняют только локальную копию заголовка, которая умирает вместе с кадром функции.

Три способа отдать изменённый слайс наружу

  1. Вернуть его. Идиоматично, так устроен сам append: func add(s []int, v int) []int.
  2. Передать *[]T, когда возврат неудобен (пишешь интерфейс, заполняешь через колбэк).
  3. Передать буфер и вернуть срез его же: паттерн AppendXxx(dst []byte, ...) []byte из стандартной библиотеки (strconv.AppendInt, time.Time.AppendFormat). Вызывающий владеет памятью, аллокаций ноль.
Передача по значению и общий массив

«Слайс всегда передаётся по значению, но это значение содержит указатель. Поэтому функция становится совладельцем массива, но не владельцем длины».

Суть: новый массив выделяется, только когда len+k > cap. Стратегия: удвоение до 256 элементов, дальше cap += (cap + 3*256)/4 - плавный переход к 1.25x; сверху округление до size class аллокатора.

Алгоритм

  1. newLen = len(s) + количество добавляемых.
  2. Если newLen <= cap(s), то записать элементы на место и вернуть заголовок с новым len. Аллокаций нет, массив тот же, изменения видны всем совладельцам.
  3. Иначе runtime.growslice: посчитать newcap, вызвать mallocgc, memmove старых элементов, дописать новые. Старый массив станет мусором, если на него больше никто не смотрит.

Как менялась стратегия

Версия Правило
до Go 1.18 если cap < 1024 → cap*2, иначе cap*1.25 в цикле. Порог 1024, коэффициент прыгал разом с 2.0 до 1.25
Go 1.18+ если newLen > 2*oldCap → newcap = newLen; иначе если oldCap < 256 → newcap = 2*oldCap; иначе в цикле newcap += (newcap + 3*256) / 4, пока newcap < newLen

Новая формула даёт множитель, который плавно спадает: примерно 1.75x при cap 256, 1.5x при 512, 1.44x при 1024 и приближается к 1.25x на больших размерах. Меняли ради того, чтобы убрать разрыв: раньше слайс на 1023 элемента рос вдвое, а на 1025 всего на четверть, и профиль потребления памяти скакал.

Округление до size class

Посчитанный newcap рантайм умножает на sizeof(T) и округляет вверх до ближайшего класса размеров аллокатора Go (8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144…). Разницу он возвращает обратно в cap. Поэтому в реальности видишь cap 848 вместо 640 и cap 8 вместо 6, а прогрессии для []byte, []int и []struct{...} отличаются.

На что можно опираться в коде

Язык гарантирует новый массив, когда ёмкости для добавления не хватает. Точная ёмкость результата зависит от реализации. Если нужен буфер известного размера, выдели его явно; если оптимизируешь частые добавления, измерь аллокации и время на своих данных.

Суть: зависит только от cap. Хватает - массив тот же, и исходный слайс «увидит» запись. Не хватает - массив новый, исходный останется со старыми данными.
func addOne(s []int) []int {
    s = append(s, 42)
    s[0] = -1
    return s
}

// Случай 1: cap хватает
a := make([]int, 3, 10)          // len=3 cap=10
b := addOne(a)
fmt.Println(a, len(a), cap(a))   // [-1 0 0] 3 10   ← a[0] изменился
fmt.Println(b, len(b), cap(b))   // [-1 0 0 42] 4 10
fmt.Println(&a[0] == &b[0])      // true - один массив

// Случай 2: cap не хватает
c := make([]int, 3, 3)           // len=3 cap=3
d := addOne(c)
fmt.Println(c, len(c), cap(c))   // [0 0 0] 3 3     ← c не тронут
fmt.Println(d, len(d), cap(d))   // [-1 0 0 42] 4 6
fmt.Println(&c[0] == &d[0])      // false - разные массивы

Почему это опасно на практике

Поведение функции зависит от cap аргумента, а cap вызывающий обычно не контролирует и даже не видит. Один и тот же код в одном месте работает «правильно», а в другом молча портит чужие данные. Баг противный: с виду недетерминированный, а по механике железно определённый.

Как правильно писать такие функции

  1. Не мутировать вход без явного контракта. Нужна модификация, работай с копией: s = slices.Clone(in).
  2. Если функция обязана только дописывать, верни результат и обяжи вызывающего присвоить (s = f(s)), как делает сам append.
  3. Отдаёшь наружу подслайс, обрезай cap: return buf[:n:n] или slices.Clip.
  4. Документируй в комментарии: «функция может изменить элементы аргумента». Это часть контракта.
Как проверить общий массив

«А как проверить, что массив тот же?» Сравнить адреса первых элементов (&a[0] == &b[0]) или unsafe.SliceData(a) == unsafe.SliceData(b). Сравнивать cap недостаточно: он может совпасть случайно.

Суть: запись по индексу - да, изменится всегда. append - изменится, если у s2 остался запас cap (то есть если s1 длиннее пяти элементов или у него есть свободный хвост).
s1 := []int{0, 1, 2, 3, 4, 5, 6, 7}
s2 := s1[2:5]                 // len=3, cap=6 (от индекса 2 до конца массива)

s2[0] = 99
fmt.Println(s1)               // [0 1 99 3 4 5 6 7]  ← запись видна

s2 = append(s2, 100)          // len 3→4, cap 6 - места хватает
fmt.Println(s1)               // [0 1 99 3 4 100 6 7] ← затёрли s1[5]
fmt.Println(s2)               // [99 3 4 100]

s2 это окно внутри массива s1. Первая запись очевидна. Вторая главный подвох: append пишет в первую ячейку за пределом len, но внутри cap, а эта ячейка принадлежит s1. Ни ошибки, ни предупреждения, просто данные соседа заменились.

Когда s1 НЕ изменится

s1 := []int{0, 1, 2, 3, 4}
s2 := s1[2:5]                 // len=3, cap=3 - окно упирается в конец массива
s2 = append(s2, 100)          // cap исчерпан → новый массив
fmt.Println(s1, s2)           // [0 1 2 3 4] [2 3 4 100]

// Явная защита - full slice expression:
s3 := s1[2:5:5]               // cap=3 принудительно
// или
s4 := slices.Clip(s1[2:5])    // то же самое, читаемее
Как проверить изменения

Не отвечай «да» или «нет», отвечай механикой: «запись по индексу всегда видна, потому что массив общий. append виден, если cap(s2) > len(s2), то есть если в массиве за окном ещё есть место. Проверяется одной строкой: cap(s2)».

Суть: encoding/json наполняет слайс через append в том же массиве, пока хватает cap, - значит запишет прямо поверх элементов s1 начиная с индекса 30 и дальше. Хвост при этом не зануляется - декодер просто укорачивает длину.

Что делает Unmarshal со слайсом

Декодер сначала пытается переиспользовать переданную память: выставляет len = 0 и добавляет элементы через append (в коде encoding/json это growSlice/переиспользование v.SetLen). Пока cap хватает, новый массив не выделяется, элементы ложатся в тот же буфер. Когда входной массив короче, чем len слайса, декодер просто укорачивает длину через SetLen. Хвост он не зануляет, старые элементы так и остаются в массиве (проверено на go1.27 и с GOEXPERIMENT=nojsonv2).

s1 := make([]int, 100)
for i := range s1 { s1[i] = i }

sub := s1[30:40]                       // len=10, cap=70
_ = json.Unmarshal([]byte(`[1,2,3]`), &sub)

fmt.Println(sub)          // [1 2 3]   - len стал 3, cap остался 70
fmt.Println(s1[28:44])    // [28 29 1 2 3 33 34 35 36 37 38 39 40 41 42 43]
//                              ^^^^^^  затёрли s1[30..32]: массив тот же, не копия
// Хвост (s1[33..39]) декодер не трогает: он просто ставит len=3,
// а старые элементы остаются лежать в массиве.

А если во входном JSON элементов больше, чем cap подслайса (в примере больше 70), декодер выделит новый массив, скопирует туда и дальше будет писать уже в него. Тогда s1 пострадает частично: первые 70 элементов затрутся, остальные уйдут в новую память.

Декодирование в общий буфер

Вопрос проверяет, понимаешь ли ты, что любая библиотека, которая пишет в слайс через append, работает с твоим массивом. Это не особенность json: то же самое сделают io.ReadFull в buf[10:20], sql.Rows.Scan в подслайс, protobuf. Правильный приём: отдать декодеру отдельный слайс (var dst []int), а потом скопировать через copy(s1[30:], dst) и контролировать длину самому.

Как защититься, если подслайс всё-таки нужен

sub := s1[30:40:40] задаёт cap 10, и при более длинном JSON декодер выделит новую память вместо порчи s1[40:]. Полностью проблему это не снимает: первые 10 элементов всё равно перезапишут.

Суть: из начала - s = s[1:] за O(1) (но память начала не освобождается) или сдвигом за O(n). Из середины - slices.Delete за O(n), либо swap-remove за O(1), если порядок не важен.
// Начало, O(1): просто сдвигаем окно
s = s[1:]                       // массив тот же, начало недостижимо, но живо

// Начало, O(n): сдвигаем данные, окно остаётся на месте
s = append(s[:0], s[1:]...)     // или copy(s, s[1:]); s = s[:len(s)-1]

// Середина с сохранением порядка, O(n)
s = append(s[:i], s[i+1:]...)   // классика
s = slices.Delete(s, i, i+1)    // Go 1.22+: то же самое, но зануляет хвост

// Середина без сохранения порядка, O(1)
s[i] = s[len(s)-1]
s[len(s)-1] = zero              // обнулить, если элементы содержат указатели
s = s[:len(s)-1]

// Конец, O(1)
s[len(s)-1] = zero
s = s[:len(s)-1]

// Пачкой по предикату, O(n) и без аллокаций
s = slices.DeleteFunc(s, func(v T) bool { return !keep(v) })

Про зануление

Если элементы это указатели, интерфейсы, строки, слайсы, мапы или структуры с такими полями, «удалённое» значение остаётся в массиве между len и cap и держится сборщиком мусора. Поэтому в аккуратном коде ячейку явно обнуляют. slices.Delete с Go 1.22 делает это сам.

Когда слайс не та структура

Если операция «взять из начала» частая (очередь, буфер задач), слайс плохой выбор: либо O(n) на каждый pop, либо утечка. Бери кольцевой буфер, container/list для настоящего связного списка или буферизованный канал, если это межгорутинная очередь. Умение это назвать отличает «знаю синтаксис» от «понимаю стоимость».

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

Что именно происходит

  1. s = s[1:] двигает array вперёд на один элемент и уменьшает len и cap. Освободившаяся ячейка в начале никуда не девается, она часть того же выделенного блока памяти.
  2. append расходует остаток cap. Через cap итераций запас кончается и вызывается growslice: аллокация нового массива и копирование len элементов.
  3. Старый массив после этого становится мусором целиком, но только в момент роста. До этого он живёт полностью, включая уже «удалённые» элементы в начале.
  4. Если элементы указатели, всё, на что они ссылались, тоже остаётся живым. Это утечка уже не 8 байт на элемент, а целых объектов.
// Проверим на деле
s := make([]int, 0, 4)
for i := 0; i < 12; i++ {
    s = append(s, i)
    s = s[1:]
    fmt.Println(i, len(s), cap(s))
}
// вывод (go1.27), i len cap:
// 0 0 3 | 1 0 2 | 2 0 1 | 3 0 0   ← окно доехало до конца исходного массива
// 4 0 3 | 5 0 2 | 6 0 1 | 7 0 0   ← append выделил новый массив (cap=4), и всё повторилось
// 8 0 0 | 9 0 0 | 10 0 0 | 11 0 0 ← дальше каждый append выделяет память заново
// Какой cap даст очередная переаллокация, решает аллокатор (тут 4, потом 1),
// но cap монотонно тает до нуля, и с этого момента каждая итерация
// это отдельная аллокация. Хуже некуда.

Как делать правильно

// 1) Кольцевой буфер: O(1) на push и pop, память не растёт
type Ring[T any] struct {
    buf        []T
    head, tail int
    size       int
}

// 2) Остаёмся на слайсе, но сдвигаем данные, а не окно
head := q[0]
q[0] = zero
copy(q, q[1:])
q = q[:len(q)-1]      // O(n) на pop, зато массив не «уползает»

// 3) Периодически уплотняем: голова ушла далеко, пора переезжать
if offset > len(q) {
    q = append(q[:0:0], q...)   // новый массив ровно по размеру
    offset = 0
}

// 4) Для очереди между горутинами хватит канала
Как это выглядит в проде

График RSS «пила с растущим средним», runtime.MemStats.Mallocs растёт линейно, в профиле alloc_space первым номером стоит runtime.growslice. Это классический симптом «очередь на слайсе». Диагностируется за минуту через go tool pprof -alloc_space.

Суть: GC освобождает объект целиком, поэтому подслайс из пяти элементов держит в памяти весь массив на миллион. Третий индекс задаёт cap = max-low - он не лечит утечку, а защищает от того, чтобы подслайс писал в чужой хвост.

Утечка

func loadAndKeepPrefix(path string) []byte {
    data, _ := os.ReadFile(path)      // 100 МБ
    return data[:16]                  // держит все 100 МБ
}

func loadOK(path string) []byte {
    data, _ := os.ReadFile(path)
    return bytes.Clone(data[:16])     // 16 байт, остальное соберёт GC
}

Аллокатор Go выделяет объект одним куском в span; сборщик отмечает живым весь объект, если на него есть хоть один указатель. «Частичное» освобождение невозможно в принципе. То же со строками: substr := bigString[10:20] удерживает всю большую строку.

Третий индекс - про другое

buf := make([]byte, 0, 1024)
buf = append(buf, "header"...)

// Отдаём наружу кусок своего буфера
view  := buf[0:6]      // cap = 1024 - вызывающий сделает append и затрёт наш буфер
safe  := buf[0:6:6]    // cap = 6    - любой append у вызывающего выделит свою память
safe2 := slices.Clip(buf[0:6])  // то же самое, читаемее (Go 1.21+)
Проблема Лечение
Маленький подслайс держит большой массив slices.Clone / bytes.Clone / copy в новый слайс
Подслайс через append портит соседние элементы s[low:high:high] или slices.Clip
«Удалённые» элементы-указатели держат объекты занулять ячейки; slices.Delete делает это сам
Подстрока держит большую строку strings.Clone(sub) (Go 1.18+)
Как измерить, а не угадать

go tool pprof -inuse_space покажет, что живая память сидит в os.ReadFile / io.ReadAll, хотя логика давно вернула «маленький» результат. Второй инструмент: runtime.SetFinalizer в тесте на большой объект. Финализатор не вызвался после runtime.GC(), значит кто-то держит ссылку.

Суть: copy(dst, src) копирует min(len(dst), len(src)) элементов и возвращает это число. Смотрит на len, а не на cap - отсюда самая частая ошибка.
n := copy(dst, src)

dst := make([]int, 0, 100)      // len=0
src := []int{1, 2, 3}
fmt.Println(copy(dst, src))     // 0 - ничего не скопировано

dst2 := make([]int, 3)
fmt.Println(copy(dst2, src))    // 3

dst3 := make([]int, 10)
fmt.Println(copy(dst3, src))    // 3 - остальные 7 остались нулями

Особенности

  • Перекрытие безопасно: под капотом memmove, а не memcpy. copy(s, s[1:]) корректно сдвигает влево.
  • Специальный случай copy(dst []byte, src string) язык разрешает: строка не конвертируется в []byte и аллокации не происходит.
  • Типы должны совпадать (с точностью до алиасов элементов); copy([]int, []int64) не скомпилируется.
  • Копирование поверхностное: у []*T скопируются указатели, объекты останутся общими.
  • С Go 1.21 есть slices.Clone(s): внутри append(s[:0:0], s...), возвращает новый слайс той же длины.
// Идиома «клонировать»: три эквивалентных варианта
c1 := slices.Clone(s)                   // Go 1.21+, читаемо
c2 := append([]int(nil), s...)          // работает везде
c3 := make([]int, len(s)); copy(c3, s)  // самый явный

// Идиома «дописать строку в байтовый буфер без аллокации»
buf = append(buf, "hello"...)           // да, так можно
Классический баг

dst := make([]T, 0, len(src)); copy(dst, src) компилируется, ничего не делает, и потом «данные пропали». Нужно make([]T, len(src)). Ловится линтером staticcheck (проверка SA4006/SA4010) и тестом.

Суть: можно - &s[i] легален, потому что адрес элемента стабилен внутри массива. Опасно тем, что после append с ростом слайс переезжает в новый массив, а указатель продолжает смотреть на старый - «мёртвый» - и изменения расходятся.
s := make([]int, 3, 3)
p := &s[0]
*p = 10
fmt.Println(s)        // [10 0 0]

s = append(s, 4)      // cap исчерпан → новый массив
*p = 99
fmt.Println(s, *p)    // [10 0 0 4] 99 - запись ушла в старый массив, s её не видит

Где это стреляет в реальном коде

// 1) Цикл range даёт копию элемента: &v это адрес копии
for _, v := range items {
    register(&v)          // до Go 1.22 - один адрес на все вызовы; с 1.22 разные, но это копии
}
for i := range items {
    register(&items[i])   // правильно
}

// 2) Указатели на элементы + append в том же цикле
var ptrs []*Item
for i := range items {
    ptrs = append(ptrs, &items[i])
    items = append(items, newItem())   // ← массив переехал, ptrs протухли
}

// 3) Метод с pointer receiver у элемента слайса
type Counter struct {
    n int
}
func (c *Counter) Inc() { c.n++ }
cs := []Counter{{}, {}}
cs[0].Inc()               // ок: компилятор берёт &cs[0]
for _, c := range cs { c.Inc() }   // бесполезно: инкрементим копию

Время жизни указателя

  • Элемент слайса адресуем, элемент мапы нет. Причина ровно в этом: мапа переселяет элементы при росте, слайс не переселяет (переезжает целиком, но старая память остаётся валидной).
  • Указатель удерживает весь массив от сборки, ещё один канал утечки.
  • С Go 1.22 переменная цикла своя на каждой итерации, поэтому &v в range перестал давать «один адрес на всех». Но это по-прежнему адрес копии, а не элемента: семантика не изменилась, изменилось только время жизни.
Правило

Держать *T на элемент слайса можно ровно до первого append. Если время жизни указателя больше, храни либо индекс, либо []*T (слайс указателей на самостоятельные объекты), а не []T.

Суть: у обоих len == 0 и cap == 0, оба поддерживают append, range и len. Отличаются только указателем внутри заголовка, результатом s == nil и сериализацией в JSON.
var a []int          // nil-слайс:   array=nil
b := []int{}         // пустой:      array=&runtime.zerobase
c := make([]int, 0)  // пустой

fmt.Println(a == nil, b == nil, c == nil)   // true false false
fmt.Println(len(a), cap(a), len(b), cap(b)) // 0 0 0 0

a = append(a, 1)     // работает: append умеет растить nil
for range a {}       // работает
fmt.Println(a[0])    // 1

j1, _ := json.Marshal(struct {
    S []int
}{nil})     // {"S":null}
j2, _ := json.Marshal(struct {
    S []int
}{[]int{}}) // {"S":[]}

Почему append работает с nil

append смотрит только на len и cap. У nil-слайса они нулевые, значит сразу идёт ветка growslice, которая выделяет новый массив и возвращает новый заголовок. Указатель nil никогда не разыменовывается, потому что копировать нечего. Та же логика в copy(nil, src): вернёт 0 без паники.

Где разница реально важна

  • JSON-контракты: null вместо [] ломает клиентов. Лечится так: инициализировать out := []T{} в DTO или ставить omitempty (тогда поля не будет вовсе).
  • reflect.DeepEqual считает их разными, поэтому тесты на nil/пустой падают. slices.Equal считает равными.
  • Protobuf/gRPC: repeated-поля приходят как nil, если элементов нет.
Практика

Внутри кода объявляй var s []T: дешевле (нет даже нулевой аллокации) и идиоматичнее. Проверяй пустоту через len(s) == 0, а не через s == nil: первое верно для обоих случаев. Явный []T{} оставь для границы наружу, для JSON и API-ответов.

Суть: == запрещён компилятором, потому что нет единственно верного смысла равенства (заголовки? элементы? глубоко?). Сравнивают slices.Equal, bytes.Equal или reflect.DeepEqual.

Почему запрещено

  • Побайтовое сравнение заголовков сравнивало бы адреса, а не данные. Почти всегда не то, чего ждёт человек, и вдобавок непереносимо.
  • Поэлементное сравнение это O(n) под видом оператора ==, который во всех остальных местах языка стоит O(1). Go принципиально не прячет дорогие операции за короткий синтаксис.
  • Слайсы бывают циклическими (s[0] содержит s через any), и «глубокое» сравнение потребовало бы обнаружения циклов в рантайме.
  • Следствие того же ограничения: слайс не может быть ключом мапы и не удовлетворяет comparable.
a, b := []int{1,2,3}, []int{1,2,3}
// a == b     // ошибка компиляции:
//            // invalid operation: a == b (slice can only be compared to nil)
fmt.Println(a == nil)                        // false - с nil сравнивать можно

slices.Equal(a, b)                           // true, Go 1.21+, O(n), без рефлексии
slices.EqualFunc(a, b, func(x, y int) bool { return x == y })
bytes.Equal([]byte("a"), []byte("a"))        // для []byte быстрее всего
reflect.DeepEqual(a, b)                      // true, но медленно и nil != []T{}

// Множества сортируем и сравниваем
slices.Sort(a); slices.Sort(b)
fmt.Println(slices.Equal(a, b))              // true

// В тестах
if diff := cmp.Diff(want, got); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) }
Способ Скорость nil vs []T{} Где применять
slices.Equal быстро, инлайнится равны прод-код, comparable элементы
bytes.Equal очень быстро (asm) равны []byte
reflect.DeepEqual медленно не равны тесты, произвольные структуры
go-cmp медленно настраивается тесты - даёт читаемый diff
Суть: многомерных слайсов в Go нет - есть слайс слайсов. Каждая строка - это отдельный заголовок и, как правило, отдельный кусок памяти; строки могут быть разной длины.
// Правильно: rows+1 аллокаций
grid := make([][]int, rows)
for i := range grid {
    grid[i] = make([]int, cols)
}

// Ловушка 1: одна строка на всех
row := make([]int, cols)
bad := make([][]int, rows)
for i := range bad { bad[i] = row }
bad[0][0] = 7
fmt.Println(bad[1][0])         // 7 - все строки указывают на один массив

// Ловушка 2: копия неглубокая
cp := make([][]int, len(grid))
copy(cp, grid)                 // скопировали заголовки, данные общие
cp[0][0] = 9
fmt.Println(grid[0][0])        // 9

// Ловушка 3: append к строке может её «отвязать»
grid[0] = append(grid[0], 1)   // если cap исчерпан - новый массив только у этой строки

Многомерный массив - это другое

[3][4]int это настоящий двумерный массив: 12 int подряд, одно значение, копируется целиком, comparable. [][]int это слайс из трёх заголовков, каждый из которых указывает куда-то ещё. Разница в памяти: [3][4]int занимает 96 байт одним куском, [][]int берёт 24 байта заголовка + 3 × 24 байта заголовков строк + 3 × 32 байта данных, и всё это в разных местах кучи.

Плоское представление - быстрый и правильный способ

type Matrix struct {
    data       []float64
    rows, cols int
}
func New(rows, cols int) *Matrix {
    return &Matrix{data: make([]float64, rows*cols), rows: rows, cols: cols}
}
func (m *Matrix) At(i, j int) float64     { return m.data[i*m.cols+j] }
func (m *Matrix) Set(i, j int, v float64) { m.data[i*m.cols+j] = v }

// Или гибрид: одна аллокация + удобная индексация grid[i][j]
flat := make([]int, rows*cols)
grid := make([][]int, rows)
for i := range grid {
    grid[i] = flat[i*cols : (i+1)*cols : (i+1)*cols]
}
Почему плоский вариант быстрее

Одна аллокация вместо rows+1; данные лежат непрерывно, значит обход по строкам идёт последовательно и попадает в предвыборку кэша; GC обходит один объект вместо rows+1. На матрице 1000×1000 полный обход обычно быстрее в 2–4 раза, а давление на GC падает на порядок. Именно так устроены image.RGBA (поле Pix []uint8 плюс Stride) и большинство численных библиотек.

Что стоит запомнить

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

Частые ошибки

  • Не игнорируй возвращённый append слайс.
  • Рост capacity зависит от реализации рантайма.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Код-ревью

1. Найди ошибку и исправь

Автор считает, что изменение t[0] не затронет s, потому что append вернул новый слайс. Проверь предположение и предложи исправление, которое изолирует данные.

s := make([]int, 1, 2)
s[0] = 4
t := append(s, 5)
t[0] = 9
fmt.Println(s[0])
Ответ и разбор

У append была свободная ёмкость, поэтому новый заголовок t указывает на тот же backing array. В результате печатается 9. Для независимого результата выдели память и скопируй: t := append([]int(nil), s...); t = append(t, 5).