Перейти к содержимому

Slices в Go — под капотом

Слайсы — одна из самых частых тем на собесе Go-джуна. И одна из самых “ловушечных”. Если вы понимаете SliceHeader, growth pattern append, sharing backing array — большинство caverzных вопросов снимается. Этот файл — фундамент, без которого не пройти собес.

  1. Базовое определение и API
  2. Внутреннее устройство (под капотом)
  3. Тонкие моменты / Gotchas
  4. Производительность
  5. Типичные вопросы на собеседовании Junior
  6. Practice — задачки на проверку
  7. Источники и дополнительно

Slice — это дескриптор (header) над фрагментом массива. Не сам массив. Slice состоит из 3 полей:

  • ptr — указатель на начало фрагмента в underlying array
  • len — текущая длина (количество доступных элементов)
  • cap — capacity (сколько ещё можно “вырасти” без перевыделения памяти)
var s []int // nil slice: ptr=nil, len=0, cap=0
s1 := []int{1,2,3} // literal, len=3, cap=3
s2 := make([]int, 5) // len=5, cap=5, нули
s3 := make([]int, 3, 10) // len=3, cap=10
s := []int{10, 20, 30, 40, 50}
len(s) // 5
cap(s) // 5
s[0] // 10
s[1:3] // [20 30] (новый slice, общий backing array)
s[:2] // [10 20]
s[2:] // [30 40 50]
s[:] // [10 20 30 40 50]
s = append(s, 60) // [10 20 30 40 50 60]
s = append(s, 70, 80) // вариативный
other := []int{100, 200}
s = append(s, other...) // распаковка
// 1. Literal
a := []int{1, 2, 3}
// 2. make
b := make([]int, 5) // len=5, cap=5
c := make([]int, 0, 10) // len=0, cap=10 — preallocation
// 3. Из массива
arr := [5]int{1,2,3,4,5}
d := arr[1:4] // [2 3 4]
// 4. Nil
var e []int // nil
fmt.Println(e == nil) // true
fmt.Println(len(e)) // 0
e = append(e, 1) // работает! Создаст новый backing array

// runtime/slice.go (упрощённо)
type slice struct {
array unsafe.Pointer // ptr к началу элементов
len int
cap int
}
// reflect/value.go — публичная версия (deprecated с Go 1.20):
type SliceHeader struct {
Data uintptr
Len int
Cap int
}

⚠️ reflect.SliceHeader помечен deprecated с Go 1.20. Современный способ — unsafe.Slice/unsafe.SliceData.

Размер header на 64-bit: 24 байта (3 × 8).

arr := [10]int{10,20,30,40,50,60,70,80,90,100}
s := arr[2:5] // len=3, cap=8
Slice header (24 байта)
┌────────────────────┐
│ ptr ──────────────┼──────┐
│ len = 3 │ │
│ cap = 8 │ │
└────────────────────┘ │
Underlying array (массив на 10 элементов):
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ 60 │ 70 │ 80 │ 90 │100 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲
└ ptr указывает сюда (на 30)
└ len = 3 ─────┘
└ cap = 8 ──────────────────────────┘

Когда len == cap и нужно добавить элемент, аллоцируется новый массив большего размера, и старые элементы копируются.

Алгоритм (Go 1.18+):

если новый требуемый размер < threshold (256):
new_cap = 2 * old_cap
иначе:
new_cap = old_cap + (old_cap + 3*256) / 4
(плавно сходится к 1.25x для больших слайсов)

До Go 1.18 порог был 1024 и удвоение использовалось дольше. С 1.18 алгоритм сделали более плавным, чтобы экономить память на больших слайсах.

s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// Примерный вывод (зависит от версии Go):
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=4 cap=4
// len=5 cap=8
// len=6 cap=8
// ...

⚠️ Точные значения cap не гарантированы спецификацией. Не закладывайтесь на них в коде.

append может вернуть тот же или новый слайс. Поэтому всегда присваивайте:

s = append(s, x) // правильно
append(s, x) // бессмысленно: результат теряется

Когда append НЕ копирует:

  • если len+n <= cap — просто пишет в существующий backing array, обновляет len.

Когда append копирует:

  • если len+n > cap — новая аллокация, копирование, новый backing array.
s[low : high : max]
  • low — start index (включительно)
  • high — end index (исключительно), len = high - low
  • max — capacity limit, cap = max - low
arr := [10]int{0,1,2,3,4,5,6,7,8,9}
s := arr[2:5:6]
fmt.Println(s) // [2 3 4]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4 (= 6-2)

Это критически важно для предотвращения memory leaks (см. ниже).


Gotcha 1: Изменение слайса в функции — backing array shared

Заголовок раздела «Gotcha 1: Изменение слайса в функции — backing array shared»
func modify(s []int) {
s[0] = 999 // ВИДНО снаружи!
}
s := []int{1,2,3}
modify(s)
fmt.Println(s) // [999 2 3]

Слайс — это header, копируется по значению, но указатель внутри — тот же. Поэтому изменение элементов видно.

Но:

func appendInside(s []int) {
s = append(s, 999) // s — локальная копия header
}
s := []int{1,2,3}
appendInside(s)
fmt.Println(s) // [1 2 3] — не изменился!

Здесь append поменял локальную копию header (новый len). Снаружи slice остался прежним.

✅ Если функция меняет slice → возвращайте новый:

func appendInside(s []int) []int {
return append(s, 999)
}
s = appendInside(s)
a := []int{1, 2, 3, 4, 5}
b := a[:3] // b = [1 2 3], len=3, cap=5
b = append(b, 999) // НЕ выделил новый массив, cap=5 хватает
fmt.Println(a) // [1 2 3 999 5] — !!!
fmt.Println(b) // [1 2 3 999]

⚠️ append записал 999 в a[3], потому что cap позволял.

Решение — full slice expression:

b := a[:3:3] // cap=3 — append вынужден будет аллоцировать
b = append(b, 999)
fmt.Println(a) // [1 2 3 4 5] — не тронут
func firstTen(big []byte) []byte {
return big[:10] // ⚠️ Backing array всего big остаётся жив!
}

Несмотря на то, что мы вернули только 10 байт, оригинальный массив (например, 1 ГБ) не будет освобождён GC, пока есть ссылка на наш slice.

✅ Решение: скопировать:

func firstTen(big []byte) []byte {
res := make([]byte, 10)
copy(res, big[:10])
return res
}
// или Go 1.21+:
res := slices.Clone(big[:10])
type Pt struct{ X, Y int }
pts := []Pt{{1,2}, {3,4}, {5,6}}
for _, p := range pts {
p.X = 0 // ИЗМЕНЯЕТ КОПИЮ!
}
fmt.Println(pts) // [{1 2} {3 4} {5 6}]
// Правильно:
for i := range pts {
pts[i].X = 0
}

⚠️ В Go 1.22 семантика loop variable изменилась (теперь каждая итерация — новая переменная), но значение всё равно копия.

var s []int // nil
s = append(s, 1, 2)
fmt.Println(s) // [1 2]
fmt.Println(s == nil) // false (теперь — обычный slice)

Это удобно: можно не инициализировать пустые слайсы.

a := make([]int, 5) // [0 0 0 0 0], len=5, cap=5
b := make([]int, 0, 5) // [], len=0, cap=5
a[0] = 1 // OK
// b[0] = 1 // PANIC: out of range
b = append(b, 1) // OK

✅ Для preallocation под append — всегда make([]T, 0, N).

Если slice имеет cap=1000, но len=1, GC всё равно держит весь backing array.

big := make([]byte, 1_000_000)
small := big[:1] // cap = 1_000_000, GC держит всё
big = nil // ничего не освободилось, small держит ref
// Освободить:
small = slices.Clone(small) // теперь small — независимая копия
var a []int // nil, len=0, cap=0
b := []int{} // не nil, len=0, cap=0
c := make([]int, 0) // не nil, len=0, cap=0
a == nil // true
b == nil // false
c == nil // false
// Но: len, range, append — ведут себя одинаково
for range a {} // 0 итераций
for range b {} // 0 итераций

⚠️ В JSON-сериализации различаются:

json.Marshal(a) // "null"
json.Marshal(b) // "[]"

Gotcha 9: Сравнение слайсов через == запрещено

Заголовок раздела «Gotcha 9: Сравнение слайсов через == запрещено»
a := []int{1,2,3}
b := []int{1,2,3}
// a == b // COMPILE ERROR
a == nil // OK
// Правильно:
slices.Equal(a, b) // Go 1.21+
reflect.DeepEqual(a, b)

copy возвращает min(len(dst), len(src)) элементов.

src := []int{1,2,3,4,5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(n, dst) // 3 [1 2 3]
// Распространённая ошибка:
var dst2 []int // len=0
n2 := copy(dst2, src)
fmt.Println(n2, dst2) // 0 [] — ничего не скопировалось!

⚠️ copy НЕ растягивает dst. Нужно make([]T, len(src)).

// До Go 1.22:
var fns []func()
for _, v := range []int{1, 2, 3} {
fns = append(fns, func() { fmt.Println(v) })
}
for _, f := range fns { f() } // 3 3 3 (одна переменная)
// Go 1.22+:
// Тот же код выведет: 1 2 3 (новая v на каждой итерации)
a := []int{1, 2, 3}
b := []int{4, 5, 6}
c := append(a, b...) // распаковка b
// Внимание: первый аргумент append не распаковывается
// append(a..., b) // syntax error
// Самый простой способ — slice of slices:
m := make([][]int, 3)
for i := range m {
m[i] = make([]int, 4)
}
// Память: 3 отдельные аллокации для рядов

Это не плоский массив. Если важна locality (CPU cache), используйте плоский:

const rows, cols = 3, 4
data := make([]int, rows*cols)
// доступ: data[i*cols + j]
// Стиль "Go way":
func find() []int {
return nil // OK — клиенты могут range и len
}
// Не делайте:
func find2() []int {
return []int{} // лишняя аллокация
}

✅ Возвращайте nil для “ничего не найдено”. Range/len работают одинаково.

Gotcha 15: append может (но необязан) изменять оригинал

Заголовок раздела «Gotcha 15: append может (но необязан) изменять оригинал»
a := make([]int, 3, 10)
a = []int{1,2,3}
b := append(a, 4)
fmt.Println(a) // [1 2 3] (len=3)
fmt.Println(b) // [1 2 3 4] (len=4)
fmt.Println(a[:4]) // [1 2 3 4] — backing array общий!

b и a делят backing array, но имеют разные len. a[:4] “оживляет” четвёртый элемент.


// ПЛОХО:
var s []int
for i := 0; i < 1_000_000; i++ {
s = append(s, i) // ~20 копирований за время роста
}
// ХОРОШО:
s := make([]int, 0, 1_000_000)
for i := 0; i < 1_000_000; i++ {
s = append(s, i) // 0 копирований
}

Выигрыш: 5-10x скорости, меньше нагрузка на GC.

Если элементы — большие структуры, копирование в range v дорогое.

type Big struct { data [1024]byte }
items := make([]Big, 1000)
// Дорого:
for _, item := range items {
_ = item
}
// Дешевле:
for i := range items {
_ = items[i]
}

copy оптимизирован через memmove. Никогда не пишите свой цикл копирования.

copy(dst, src) // быстро (memmove)
// vs
for i, v := range src { dst[i] = v } // медленнее
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 4096) },
}
buf := bufPool.Get().([]byte)
defer func() {
buf = buf[:0] // сбросить len, сохранить cap
bufPool.Put(buf)
}()

Появился пакет slices со стандартными операциями:

import "slices"
slices.Contains(s, 5)
slices.Index(s, 5)
slices.Sort(s)
slices.Reverse(s)
slices.Clone(s)
slices.Equal(a, b)
slices.Concat(a, b, c)
slices.Min(s)
slices.Max(s)
slices.BinarySearch(sortedS, 42)
slices.DeleteFunc(s, func(x int) bool { return x < 0 })

Все они оптимизированы через generics.

4.6 Growth pattern: считайте, сколько раз будет realloc

Заголовок раздела «4.6 Growth pattern: считайте, сколько раз будет realloc»
// 1М append без preallocation:
// аллокации на cap = 1, 2, 4, 8, ..., 524288, 1048576 → ~21 realloc
// Stack (если не escape):
func sum() int {
a := []int{1,2,3} // обычно escape на heap (slice — указатель)
s := 0
for _, v := range a { s += v }
return s
}

⚠️ Большинство слайсов escape на heap. Используйте -gcflags="-m" для проверки.


A: Slice — это header, состоящий из трёх полей: указатель на начало underlying array, длина (len) и capacity (cap). Slice не владеет данными — он ссылается на массив.

s := []int{1, 2, 3}
fmt.Println(len(s), cap(s))

A: 3 3.

s := make([]int, 3, 10)
fmt.Println(len(s), cap(s))

A: 3 10.

A:

  • len — количество доступных через s[i] элементов.
  • cap — сколько можно добавить через append без перевыделения.
  • Всегда len <= cap.

A: Да. var s []int; s = append(s, 1) — работает. Будет создан новый backing array.

a := []int{1, 2, 3}
b := a[:2]
b = append(b, 99)
fmt.Println(a)

A: [1 2 99]. b имеет cap=3, append записал в существующий backing array, а a[2] показывает на тот же элемент.

func modify(s []int) {
s[0] = 100
s = append(s, 999)
}
s := []int{1, 2, 3}
modify(s)
fmt.Println(s)

A: [100 2 3]. s[0] = 100 поменял backing array (видно снаружи). append создал новый или нет — неважно, локальная s обновилась, внешняя — нет.

A: Если len + n <= cap, просто пишет на месте. Иначе аллоцирует новый backing array размером growslice-формулы (примерно 2x для маленьких, 1.25x для больших >256), копирует старые элементы, добавляет новые. Возвращает новый slice header.

A: s[low:high:max] — задаёт slice с len = high-low и cap = max-low. Используется для ограничения cap, чтобы append не мутировал соседние элементы оригинала.

A: Нет, ошибка компиляции. Только с nil. Для поэлементного сравнения: slices.Equal() или reflect.DeepEqual().

A: Оба 0. Можно range (0 итераций), len, append. Нельзя индексировать s[0] — panic.

A: Когда big := make([]int, 1_000_000); small := big[:1] — backing array на 1М остаётся жив, пока есть ссылка на small. Решение: slices.Clone(big[:1]) или copy.

A: Если v — большая структура, она копируется на каждой итерации. Решение: for i := range s { _ = s[i] }.

s := make([]int, 0)
s = append(s, 1, 2, 3)
s2 := s[1:]
s2[0] = 999
fmt.Println(s)

A: [1 999 3]. s2 указывает на тот же backing array.

A:

// Удалить s[i]:
s = append(s[:i], s[i+1:]...)
// или с Go 1.21:
s = slices.Delete(s, i, i+1)

⚠️ Это сдвигает элементы. Для slices указателей — обнулите последний, иначе memory leak.

A: Минимум len(dst) и len(src) — количество скопированных элементов. Если dst короче — копируется не весь src. Если dst пустой — 0.

A: Семантически почти да: оба имеют len=0, оба работают с range и append. Но nil == []int{} — это true для nil-сравнения справа, и false для []int{}. В JSON: nilnull, []int{}[].

A: 24 байта на 64-битной системе (3 поля по 8 байт: ptr, len, cap).

s := make([]int, 5)
s[10] = 1

A: Panic: runtime error: index out of range [10] with length 5.

A:

m := make([][]int, rows)
for i := range m {
m[i] = make([]int, cols)
}

Или плоский для лучшего cache locality:

m := make([]int, rows*cols)

A: Можно (Go копирует header в начале range), но это опасно. Если append вызовет realloc, изменения “потеряются” после реаллокации.

A: Стандартный пакет с Go 1.21 для работы со slices: Contains, Index, Sort, Equal, Clone, Delete, BinarySearch и др. Использует generics.

A: До Go 1.18 — 2x для малых, потом 1.25x. С Go 1.18 — плавный переход, threshold ~256. Точные значения не гарантируются спецификацией.

a := []int{1,2,3,4,5}
b := a[1:4]
fmt.Println(len(b), cap(b))

A: 3 4. len = 4-1 = 3. cap = len(a) - 1 = 4 (от индекса 1 до конца underlying array).

s := []int{1,2,3}
s = append(s[:1], s[2:]...)
fmt.Println(s)

A: [1 3]. Удалили s[1]=2.


s := []int{1, 2, 3, 4, 5}
slice1 := s[1:3] // [2 3]
slice2 := s[2:5] // [3 4 5]
slice1 = append(slice1, 99)
fmt.Println(s)
fmt.Println(slice1)
fmt.Println(slice2)

Решение:

  • slice1 имеет len=2, cap=4 (от индекса 1 до конца, 5-1=4).
  • append(slice1, 99) — есть запас, пишет в backing array, в s[3].
  • s становится [1 2 3 99 5].
  • slice1 = [2 3 99].
  • slice2 — указывает на s[2:5] = [3 99 5] — изменилось!

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

func FirstN(data []byte, n int) []byte {
// ???
}

Решение:

func FirstN(data []byte, n int) []byte {
if n > len(data) { n = len(data) }
out := make([]byte, n)
copy(out, data)
return out
}
// Или Go 1.21+:
return slices.Clone(data[:n])
func main() {
s := []int{1, 2, 3}
s2 := append(s, 4)
s3 := append(s, 5)
fmt.Println(s2)
fmt.Println(s3)
}

Решение:

  • s имеет cap=3.
  • append(s, 4) — нужна расширение, аллоцируется новый array, cap станет ~6. s2 = [1 2 3 4].
  • append(s, 5) — снова смотрит на s (len=3, cap=3 — старый), нужна расширение, аллоцируется ЕЩЁ ОДИН новый array. s3 = [1 2 3 5].

Подвох: если бы cap(s) > 3, то s2 и s3 использовали бы один и тот же backing array, и s2[3] стал бы 5 (последняя запись).

Удалите элемент с индексом 2 из slice [10 20 30 40 50]. Покажите 2 способа.

Решение:

s := []int{10, 20, 30, 40, 50}
// Способ 1: с сохранением порядка
s = append(s[:2], s[3:]...)
// [10 20 40 50]
// Способ 2: O(1), без сохранения порядка
s2 := []int{10, 20, 30, 40, 50}
s2[2] = s2[len(s2)-1]
s2 = s2[:len(s2)-1]
// [10 20 50 40]
// Способ 3 (Go 1.21+):
s3 := []int{10, 20, 30, 40, 50}
s3 = slices.Delete(s3, 2, 3)
type Stack[T any] struct {
data []T
}
func (s *Stack[T]) Push(v T) { s.data = append(s.data, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.data) == 0 { return zero, false }
n := len(s.data) - 1
v := s.data[n]
s.data[n] = zero // !!! очищаем ref для GC
s.data = s.data[:n]
return v, true
}

⚠️ Обратите внимание на s.data[n] = zero. Если T — это указатель/slice/map, без зануления старая ссылка останется в backing array и GC не освободит её.


  1. Go Blog — “Go Slices: usage and internals”https://go.dev/blog/slices-intro — официальный гайд.
  2. Go Blog — “Arrays, slices: the mechanics of ‘append’”https://go.dev/blog/slices — глубокое объяснение append.
  3. Dave Cheney — “How the Go runtime implements maps efficiently (without generics)” — есть и про slices.
  4. Russ Cox — “Go Data Structures”https://research.swtch.com/godata
  5. runtime/slice.go — исходники: https://github.com/golang/go/blob/master/src/runtime/slice.go — функция growslice.
  6. Habr — “Слайсы в Go: подробно с примерами” (2024-2025) — поиск свежих обзоров.
  7. “100 Go Mistakes and How to Avoid Them” (Teiva Harsanyi) — раздел про слайсы — обязательное чтение.
  8. slices пакетhttps://pkg.go.dev/slices — стандартная библиотека Go 1.21+.

Слайс — это вид на массив. Понимайте header, понимайте sharing — и большинство багов исчезает.