16. Slice. Главная структура данных в Go
Как мы выяснили в лекции 15. Массивы и их особенности, массивы в Go — это монолитные блоки памяти фиксированного размера. Их длина намертво зашита в тип. В реальном программировании привязывать функции к жестким размерам вроде [10]int было бы непозволительной роскошью.
Для решения этой задачи создатели Go спроектировали слайс (slice, срез). Это безоговорочно главная и наиболее часто используемая структура данных в языке. Разработчики, приходящие из C++, часто по привычке считают слайс аналогом std::vector, а программисты на Java проводят параллели с ArrayList.
Однако слайс в Go устроен принципиально иначе. Слайс — это вовсе не динамический массив сам по себе. Это легковесный дескриптор, «смотровое окно», смотрящее на непрерывный базовый массив (backing array) в памяти. Глубокое понимание физики этого окна спасает от скрытых утечек памяти и непредсказуемой перезаписи данных при конкурентной обработке.
Структура слайса под капотом (SliceHeader)
На уровне рантайма Go (runtime/slice.go) любой срез — это компактная 24-байтная структура ровно из трех полей:
type slice struct {
array unsafe.Pointer // Машинный указатель на первый элемент базового массива
len int // Логическая длина (количество доступных элементов)
cap int // Физическая вместимость выделенного массива
}
(Исторически эта структура также была экспортирована в пакете reflect как SliceHeader, но начиная с Go 1.20 она объявлена устаревшей (deprecated) в пользу типобезопасных функций unsafe.Slice и unsafe.SliceData).
На современных 64-битных процессорах каждое из этих полей занимает ровно 8 байт. Это означает фундаментальный факт: любой слайс в Go всегда весит ровно 24 байта, независимо от того, пуст он или содержит миллион элементов:
Data: машинный указатель на первый адресуемый элемент базового массива в памяти.Len(Length): логическая длина среза — сколько элементов доступно для чтения и записи прямо сейчас. Возвращается встроенной функциейlen(s).Cap(Capacity): физическая емкость — сколько элементов физически выделено в непрерывной памяти базового массива, начиная от указателяDataи до конца массива. Возвращается встроенной функциейcap(s).
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph SliceHeader["Слайс - 24 байта"]
direction TB
Data["Data - указатель"]:::process
Len["Len - 3"]:::process
Cap["Cap - 5"]:::process
end
subgraph Array["Скрытый базовый массив в памяти"]
direction LR
A0["10"]:::process
A1["20"]:::process
A2["30"]:::process
A3["40"]:::process
A4["50"]:::process
end
Data -->|"Указывает на A0"| A0
Len -.->|"Доступные элементы"| A2
Cap -.->|"Зарезервировано памяти"| A4
Операция среза (Slicing)
Слайс не зря называется срезом: он может в один миг «вырезать» диапазон элементов из существующего массива или другого среза с помощью оператора array[start:end].
arr := [5]int{10, 20, 30, 40, 50} // Это жесткий базовый массив
// Создаем слайс, который смотрит на элементы с индексами 1 и 2
s := arr[1:3]
Что произошло в памяти компьютера?
- Поле
Dataсрезаsуказывает на адрес элементаarr[1](значение20). - Поле
Lenравно 2 (результат3 - 1). - Поле
Capравно 4. Почему? Потому что начиная от ячейкиarr[1]и до самого конца массиваarrфизически выделено 4 элемента (20, 30, 40, 50).
Инициализация: Nil vs Empty Slice
Слайс можно объявить «с нуля», без явного предварительного выделения массива. Здесь кроется один из излюбленных вопросов технических собеседований: «В чем разница между nil-слайсом и пустым слайсом?»
var s1 []int // Подход 1: Nil-слайс
s2 := []int{} // Подход 2: Пустой слайс
s3 := make([]int, 0) // Подход 3 (аналогичен 2)
С точки зрения базовых функций len() или append(), разница незаметна. Но с точки зрения рантайма и выделения памяти различия фундаментальны.
1. Nil-слайс (var s1 []int)
Это эталонный, канонический способ объявления пустого среза в Go:
Data = nilLen = 0Cap = 0
Под такой срез не выделяется ни одного байта в куче. Он абсолютно бесплатен для рантайма. Проверка s1 == nil возвращает true. Встроенная функция append превосходно умеет работать с nil-слайсами: она самостоятельно запросит память у аллокатора при первой необходимости.
2. Пустой слайс (s2 := []int{})
Это слайс нулевой длины, который не равен nil:
Data = 0x...(Указывает на специальный адрес в памяти —zerobase)Len = 0Cap = 0
В недрах рантайма Go существует глобальная статическая переменная zerobase. Все пустые аллокации в программе (слайсы нулевой вместимости, пустые структуры struct{}) ссылаются на этот единственный адрес в памяти, чтобы не нагружать аллокатор кучи.
Однако литерал []int{} менее идиоматичен. Главное практическое отличие проявляется при сериализации: например, пакет encoding/json при маршалинге nil-слайса выдаст JSON-значение null, тогда как для пустого слайса он сгенерирует валидный пустой массив [].
Функция make и преаллокация
Если вам заранее известен объем данных (например, число строк из запроса к БД или размер конвертируемого списка), всегда используйте встроенную функцию make:
// Создаст слайс: Len = 5, Cap = 5, заполненный нулями
s1 := make([]int, 5)
// Создаст слайс: Len = 0, Cap = 100
s2 := make([]int, 0, 100)
Второй паттерн (Len=0, Cap=100) — золотое правило инженера высоконагруженных систем. Мы единовременно резервируем непрерывный блок памяти в куче под 100 элементов. Последующие вызовы append будут лишь увеличивать счетчик Len (простая инкрементация регистра в CPU) и без задержек записывать элементы в предварительно выделенную память. Это полностью избавляет рантайм от дорогостоящих повторных аллокаций памяти (realloc) и снижает нагрузку на Garbage Collector.
Передача слайса в функцию
Вспомним незыблемое правило языка Go: все аргументы всегда передаются по значению.
Слайс подчиняется этому правилу беспрекословно. При передаче слайса в функцию компилятор копирует его 24-байтовую структуру SliceHeader.
func modify(s []int) {
s[0] = 999 // Изменит оригинал! (указатель Data смотрит на общую память)
s = append(s, 42) // А вот это изменение длины оригинал не увидит!
}
func main() {
nums := []int{1, 2, 3}
modify(nums)
fmt.Println(nums) // Выведет [999 2 3]. Элемента 42 здесь нет!
}
Почему это произошло?
Функция modify получила изолированную копию дескриптора (24 байта). Инструкция s[0] = 999 переходит по скопированному указателю Data прямо в базовый массив в памяти и мутирует его ячейку — поэтому вызывающий код видит 999.
Однако когда внутри функции вызывается append(s, 42), встроенная функция возвращает новый заголовок слайса с Len = 4. Этот новый заголовок перезаписывает локальную копию s внутри стекового фрейма modify. Внешний слайс nums в функции main не имеет ни малейшего представления об этих манипуляциях: его поле Len как было равно 3, так и осталось!
Итог
- Слайс — это окно: Слайс не хранит данные самостоятельно, а выступает 24-байтовым дескриптором
SliceHeader(Data,Len,Cap) над скрытым базовым массивом. - Семантика передачи: Передача среза копирует только его заголовок. Модификация существующих индексов меняет общий массив, но операция
appendвнутри функции не изменяет длину среза снаружи без явногоreturn. - Nil против Empty: Предпочитайте
var s[]T(nil-слайс). Он не требует аллокаций и полностью поддерживается функциейappend. Помните про разницу в JSON:nullпротив[]. - Преаллокация через make: Вызов
make([]T, 0, cap)выделяет память один раз и избавляетappendот повторных перевыделений и копирования (а небольшой неубегающийmakeс константной емкостью компилятор вообще размещает на стеке).
Мы освоили фундамент срезов, однако намеренно оставили за кадром самые тонкие моменты: что происходит, когда емкость Cap исчерпана? По какому алгоритму функция append удваивает память? Что такое скрытая утечка памяти при срезах и как один забытый байт может удерживать в куче мегабайтный массив?
Все эти низкоуровневые вопросы мы детально препарируем в следующей статье: 17. Slice под капотом. len, cap, append и realloc.