Как устроен слайс изнутри
Слайс — самая используемая структура данных в 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 обратно в переменную и не полагайтесь на то, что несколько слайсов «случайно» делят память.