Как устроен слайс изнутри

Слайс — самая используемая структура данных в Go, и на собеседовании почти наверняка спросят, что у неё внутри.

Слайс (slice) — это не массив и не отдельный контейнер с данными, а компактная структура из трёх полей: указатель на массив в памяти, текущая длина (len) и вместимость (cap) — сколько элементов помещается в тот массив, на который слайс смотрит.

Что выведет этот код?

package main

import "fmt"

func main() {
	s := make([]int, 3, 5)
	fmt.Println(len(s), cap(s))
}

Вывод:

3 5

len — это сколько элементов слайс сейчас реально содержит (три штуки). cap — сколько места есть в базовом массиве, прежде чем понадобится его пересоздавать (пять штук). Разница между ними — это «запас прочности», как свободные полки в шкафу, которые ещё не заняты вещами.

Как это работает под капотом

Когда вы пишете []int, за кулисами Go хранит примерно такую структуру (это упрощение, но суть верна):

type sliceHeader struct {
	data     *int // указатель на первый элемент массива
	length   int  // сколько элементов сейчас используется
	capacity int  // сколько элементов вообще есть места в массиве
}

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

Отсюда и получается всё, что видно в примере выше: слайс размером 3 с capacity 5 — это заголовок, который смотрит на массив из как минимум 5 ячеек, но «видимыми» и доступными через индексацию считает только первые 3.

Слайс от массива — это ещё один взгляд на те же данные

Слайс необязательно создаётся через make. Его можно получить, «нарезав» существующий массив синтаксисом a[low:high]:

package main

import "fmt"

func main() {
	a := [5]int{10, 20, 30, 40, 50}
	s := a[1:3]
	fmt.Println(s, len(s), cap(s))
}

Вывод:

[20 30] 2 4

Срез a[1:3] берёт элементы с индекса 1 по 3 (не включая), то есть 20 и 30 — отсюда len(s) == 2. А вот cap считается иначе: это сколько элементов остаётся в исходном массиве от индекса 1 и до самого конца массива — индексы 1, 2, 3, 4, то есть 4 элемента.

Важный момент: s не копирует данные из a. Указатель внутри s смотрит прямо на память массива a. Это значит, что изменение через s меняет и a:

package main

import "fmt"

func main() {
	a := [5]int{10, 20, 30, 40, 50}
	s := a[1:3]
	s[0] = 999
	fmt.Println(a)
}

Вывод:

[10 999 30 40 50]

Слайс и массив, из которого он «нарезан», делят одну и ту же память. Это не баг, а фундаментальное свойство слайсов — и главный источник ловушек с append, которые разберём дальше.

Главная ловушка: append иногда создаёт новый массив, а иногда — нет

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

Сценарий 1: в базовом массиве есть свободное место (cap больше len). Тогда append просто записывает новый элемент в следующую свободную ячейку того же массива и возвращает слайс с увеличенным len. Новый массив НЕ создаётся:

package main

import "fmt"

func main() {
	s := make([]int, 2, 4) // len=2, cap=4 — есть запас
	s[0], s[1] = 1, 2

	t := append(s, 100) // запас есть, новый массив НЕ создаётся
	t[0] = 777

	fmt.Println(s) // s тоже изменился!
	fmt.Println(t)
}

Вывод:

[777 2]
[777 2 100]

Здесь s и t продолжают смотреть на один и тот же массив — поэтому изменение через t «просочилось» и в s, хотя append применяли только к t. Это неожиданно для многих новичков.

Сценарий 2: свободного места нет (cap равен len). Тогда Go выделяет совершенно новый, обычно вдвое больший массив, копирует туда все старые элементы и уже туда дописывает новый. Возвращённый слайс смотрит на новую память, а старый слайс остаётся смотреть на старую:

package main

import "fmt"

func main() {
	s := make([]int, 2, 2) // len=2, cap=2 — запаса НЕТ
	s[0], s[1] = 1, 2

	t := append(s, 100) // запас кончился, Go создаёт НОВЫЙ массив
	t[0] = 777

	fmt.Println(s) // s НЕ изменился
	fmt.Println(t)
}

Вывод:

[1 2]
[777 2 100]

Теперь s и t — это два независимых массива, и изменение одного не видно в другом. Отличие от предыдущего примера только в одном числе (стартовый cap), а поведение — принципиально разное.

Частые ошибки на собеседовании

  • Считают, что append всегда возвращает «тот же» слайс. На самом деле append может как переиспользовать память, так и выделить новую — и это зависит от того, есть ли запас cap. Поэтому результат append ВСЕГДА нужно сохранять в переменную: s = append(s, x), а не игнорировать.
  • Забывают, что срез a[low:high] не копирует данные, а расшаривает память с оригиналом — и потом удивляются, откуда «сами по себе» меняются другие переменные.
  • Путают len и cap: думают, что cap — это как len, только для «полного» слайса. На деле cap зависит от того, где начинается срез в исходном массиве, а не только от того, сколько элементов взято.
  • Не понимают, почему пример из этого урока с одинаковым кодом даёт разный результат — забывают, что стартовый cap, заданный в make, всё меняет.

Итоги-шпаргалка

  • Слайс — это структура из трёх полей: указатель на массив, len (сколько элементов используется), cap (сколько места есть в массиве).
  • Срез от массива или слайса не копирует данные — он ссылается на ту же память.
  • Если при append есть свободный cap — данные дописываются в тот же массив, старый и новый слайс делят память.
  • Если cap закончился — Go выделяет новый массив и копирует туда данные, связь со старым слайсом обрывается.
  • Правило безопасности: всегда переприсваивайте результат append обратно в переменную и не полагайтесь на то, что несколько слайсов «случайно» делят память.
Проверьте себя
1. Слайс s создан как make([]int, 3, 5). Что означают эти два числа?
A3 — минимальное значение элемента, 5 — максимальное
B3 — текущая длина (len), 5 — вместимость базового массива (cap)
C3 — количество слайсов, 5 — количество массивов
DОба числа задают размер в байтах
2. Почему в двух похожих примерах append(s, 100) в одном случае меняет исходный слайс s, а в другом — нет?
AЭто случайность, зависящая от сборщика мусора
BЗависит от того, был ли у s свободный запас cap: если да — append переиспользует массив, если нет — создаёт новый
Cappend никогда не должен менять исходный слайс, это всегда баг в коде
DЗависит только от типа элементов слайса (int или string)