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] уже паника).
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 не метод и не обычная функция, а
встроенная: компилятор разворачивает её вызов в инлайновый
код. Логика такая:
-
Посчитать нужную длину:
newLen = len(s) + len(добавляемого). -
Если
newLen <= cap(s), то ничего не выделять: записать элементы в существующий массив начиная с индексаlen(s)и вернуть заголовок с новымlen. -
Иначе вызвать
runtime.growslice: посчитать новый cap, выделить новый массив, скопировать в него старые элементы (memmove), дописать новые, вернуть новый заголовок.
Из шага 2 растёт вся «магия» слайсов: при достаточном cap
append молча пишет в память, которую видят и другие
слайсы. Из шага 3 растёт другое: после роста связь с исходным массивом теряется.
Стратегия роста capacity
Считает её runtime.growslice в runtime/slice.go.
На точный коэффициент роста нельзя опираться в прикладном коде. Алгоритм
двухступенчатый, а сверху ещё накладывается округление до классов размеров аллокатора.
+ (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.
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, а элементы никуда не делись: лежат в массиве
между 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.
Практика: владение буфером
У функции, которая принимает или возвращает слайс, должен быть понятный договор о данных. Можно ли менять элементы? Кто хранит результат? Когда исходный буфер будут использовать повторно? Ограничение ёмкости и копирование решают разные задачи.
| Что нужно | Подход | Граница |
|---|---|---|
| Прочитать часть буфера во время вызова | Подслайс без копии | Получатель не сохраняет и не меняет данные; буфер остаётся действительным до конца чтения. |
| Защитить соседние элементы от append | buf[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Массив [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. Нужен верхний предел.
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 меняют только локальную копию заголовка, которая умирает вместе
с кадром функции.
Три способа отдать изменённый слайс наружу
-
Вернуть его. Идиоматично, так устроен сам
append:func add(s []int, v int) []int. -
Передать
*[]T, когда возврат неудобен (пишешь интерфейс, заполняешь через колбэк). -
Передать буфер и вернуть срез его же: паттерн
AppendXxx(dst []byte, ...) []byteиз стандартной библиотеки (strconv.AppendInt,time.Time.AppendFormat). Вызывающий владеет памятью, аллокаций ноль.
«Слайс всегда передаётся по значению, но это значение содержит указатель. Поэтому функция становится совладельцем массива, но не владельцем длины».
len+k > cap. Стратегия: удвоение до 256
элементов, дальше cap += (cap + 3*256)/4 -
плавный переход к 1.25x; сверху округление до size class
аллокатора.
Алгоритм
-
newLen = len(s) + количество добавляемых. -
Если
newLen <= cap(s), то записать элементы на место и вернуть заголовок с новымlen. Аллокаций нет, массив тот же, изменения видны всем совладельцам. -
Иначе
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 вызывающий обычно не контролирует и даже не видит. Один и тот же код в одном месте работает «правильно», а в другом молча портит чужие данные. Баг противный: с виду недетерминированный, а по механике железно определённый.
Как правильно писать такие функции
-
Не мутировать вход без явного контракта. Нужна модификация, работай
с копией:
s = slices.Clone(in). -
Если функция обязана только дописывать, верни результат и обяжи
вызывающего присвоить (
s = f(s)), как делает самappend. -
Отдаёшь наружу подслайс, обрезай cap:
return buf[:n:n]илиslices.Clip. - Документируй в комментарии: «функция может изменить элементы аргумента». Это часть контракта.
«А как проверить, что массив тот же?» Сравнить адреса первых элементов
(&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 для настоящего связного списка или
буферизованный канал, если это межгорутинная очередь. Умение это назвать
отличает «знаю синтаксис» от «понимаю стоимость».
Что именно происходит
-
s = s[1:]двигаетarrayвперёд на один элемент и уменьшаетlenиcap. Освободившаяся ячейка в начале никуда не девается, она часть того же выделенного блока памяти. -
appendрасходует остатокcap. Черезcapитераций запас кончается и вызываетсяgrowslice: аллокация нового массива и копированиеlenэлементов. - Старый массив после этого становится мусором целиком, но только в момент роста. До этого он живёт полностью, включая уже «удалённые» элементы в начале.
- Если элементы указатели, всё, на что они ссылались, тоже остаётся живым. Это утечка уже не 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.
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 |
// Правильно: 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).