2. Слайсы в Go как структура данных
Слайс: элегантная анатомия динамических последовательностей
В предыдущей статье 1. Массивы и динамические массивы мы выяснили, что статические массивы — это физический фундамент аппаратной производительности, но их фиксированный размер непреодолимо сковывает разработку реальных сервисов. Для масштабируемых систем необходим механизм динамического управления емкостью.
В языке Go этот паттерн реализован на уровне ядра компилятора и рантайма в виде слайсов (срез, slice). Слайсы — это альфа и омега серверной разработки на Go. Почти любая операция ввода-вывода, манипуляция с байтовыми потоками или бизнес-коллекциями опирается на слайсы. Без глубокого понимания их внутреннего устройства невозможно писать ни высокопроизводительный, ни даже базово корректный конкурентный код.
Что такое слайс под капотом?
Многие разработчики, приходящие из сред со сборкой мусора на базе тяжелых классов (Java, C#, Python), ошибочно воспринимают слайс как сложный объект-коллекцию, живущий исключительно в куче с массой служебных дескрипторов. Это фундаментальное заблуждение.
В Go слайс — это предельно компактная структура ровно из трех машинных слов, передаваемая по значению. В исходном коде рантайма Go (в файле src/runtime/slice.go) слайс определяется структурой slice:
// Исходный код рантайма Go (runtime/slice.go)
type slice struct {
array unsafe.Pointer // Указатель на первый адресуемый элемент базового массива
len int // Текущее количество активных элементов
cap int // Полная вместимость базового массива от указателя array до его конца
}
На 64-битных архитектурах (x86-64, ARM64) размер этой структуры составляет ровно 24 байта (8 байт указатель array + 8 байт len + 8 байт cap).
flowchart TD
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 SliceDescriptor["Заголовок слайса (SliceHeader, 24 байта на стеке)"]
direction TB
ptr["array: unsafe.Pointer (8 байт)"]
len["len: 5 (8 байт)"]
cap["cap: 8 (8 байт)"]
end
subgraph BackingArray["Базовый статический массив (в непрерывной памяти)"]
direction LR
b0["arr[0]"] --- b1["arr[1]"] --- b2["arr[2]"] --- b3["arr[3]"] --- b4["arr[4]"] --- free5["[свободно]"] --- free6["[свободно]"] --- free7["[свободно]"]
end
ptr -->|"Указывает на arr[0]"| b0
Когда вы передаете слайс в функцию в качестве аргумента, компилятор копирует через регистры процессора только эти 24 байта. Базовый массив (backing array), на который ссылается поле array, не копируется. Обе стороны (вызывающая и вызываемая функции) оперируют своими локальными 24-байтными заголовками, но смотрят на одну и ту же физическую область памяти.
Операция Slicing: Магия нулевого копирования
Синтаксическая конструкция s[i:j] называется операцией срезки (slicing). Эта операция создает новый 24-байтный заголовок слайса, который ссылается на тот же самый базовый массив, но с измененным начальным указателем, скорректированной длиной и пересчитанной емкостью.
nums := []int{0, 1, 2, 3, 4, 5, 6} // len: 7, cap: 7
subNums := nums[2:5] // len: 3, cap: 5
Как это вычисляется аппаратно за строгое $O(1)$:
arrayнового слайса =arrayстарого +2 * размер_типа(смещение на 16 байт).len=5 - 2=3.cap=старый_cap - 2=5.
flowchart TD
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 BackingArray["Базовый массив в памяти (7 элементов int)"]
direction LR
A0["[0] = 0"] --- A1["[1] = 1"] --- A2["[2] = 2"] --- A3["[3] = 3"] --- A4["[4] = 4"] --- A5["[5] = 5"] --- A6["[6] = 6"]
end
S1["Слайс nums<br/>len: 7, cap: 7"] -.->|"array"| A0
S2["Слайс subNums (nums[2:5])<br/>len: 3, cap: 5"] -.->|"array"| A2
Функция append и алгоритм реаллокации
Встроенная функция append добавляет новые элементы в хвост слайса.
Если текущая длина len строго меньше вместимости cap, операция выполняется за $O(1)$: значение напрямую записывается в базовый массив по индексу len, после чего возвращается новый дескриптор слайса с инкрементированным полем len + 1.
Но если len == cap, свободное пространство в базовом массиве исчерпано. В этот момент рантайм Go передает управление внутренней системной функции growslice (src/runtime/slice.go). Она аллоцирует новый, более просторный блок памяти, копирует туда предшествующие элементы и дописывает новые.
flowchart TD
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
StartAppend["Вызов append(s, elem)"]:::warning --> CheckSpace{"len < cap?"}
CheckSpace -->|"Да"| FastPath["Fast Path (inlined):<br/>1. s.array[s.len] = elem<br/>2. s.len++<br/>3. Возврат нового заголовка (O(1))"]
CheckSpace -->|"Нет"| SlowPath["Slow Path (runtime.growslice):<br/>1. Расчет newcap по формуле<br/>2. roundupsize (выравнивание по сайз-классам)<br/>3. Аллокация новой памяти в куче<br/>4. memmove старых данных в новый буфер<br/>5. Запись elem, возврат нового слайса"]
Как растет слайс (Go 1.18+)
До исторической версии Go 1.18 эвристика роста была дискретной: слайс удваивался (cap * 2) при cap < 1024, а затем резко переключался на фиксированный шаг $+25\%$ (cap += cap / 4). Это создавало неприятную «ступеньку» в потреблении памяти на границе 1024 элементов (1023 удваивалось до 2046, а 1024 вырастало лишь до 1280).
В современных версиях Go (включая Go 1.22–1.24) алгоритм сглажен и описывается следующими правилами:
- Если требуемая вместимость (
cap) больше удвоенной текущей (doublecap), выделяем в точности запрошенную емкость. - Если текущая
cap < 256, удваиваем ее:newcap = doublecap. - Если текущая
cap >= 256, применяется формула сглаженного роста:newcap += (newcap + 3*256) / 4.
$$\text{newcap} = \text{newcap} + \frac{\text{newcap} + 3 \times 256}{4}$$
Это обеспечивает плавный переход фактора роста от 2.0 (для малых слайсов) к 1.25 (для гигантских слайсов).
Типичные ошибки и корнер-кейсы
1. The Append Trap (Ловушка передачи по значению)
flowchart TD
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 StateBefore["Состояние памяти до modify()"]
mHeader["main.s (len: 3, cap: 4)"]:::entry --> arr["Память: [1, 2, 3, _]"]
end
subgraph StateInside["Внутри modify() после append(s, 4) и s[0] = 99"]
modHeader["modify.s (len: 4, cap: 4)"]:::system --> arrModified["Память: [99, 2, 3, 4]"]
end
subgraph StateAfter["После выхода из modify() в main()"]
mHeaderAfter["main.s (len: 3, cap: 4)"] --> arrModified
end
2. Скрытое перезаписывание (Shadowing)
Прямое следствие совместного владения базовым массивом:
s := make([]int, 0, 5)
s = append(s, 1, 2, 3)
s1 := append(s, 4) // Создает новый слайс s1: len 4, cap 5
s2 := append(s, 5) // Создает новый слайс s2: len 4, cap 5
// s1 и s2 ссылаются на один массив!
// При создании s2 мы перезаписали четвертый элемент, который только что поставили для s1.
В таких случаях необходимо использовать полный синтаксис среза (Full Slice Expression): s[low:high:max]. Вызов s[0:3:3] жестко ограничит cap слайса тремя элементами (cap = max - low), и любой последующий append гарантированно вызовет реаллокацию в новый блок памяти, исключив повреждение соседних данных.
3. Nil vs Empty Slice
В Go существуют два принципиально разных состояния слайса нулевой длины:
var s1 []int // nil-слайс: len=0, cap=0, array=nil. Никаких аллокаций памяти!
s2 := make([]int, 0) // Пустой слайс: len=0, cap=0, array=указатель на zerobase.
s3 := []int{} // Аналогично s2: пустой ненулевой слайс.
С точки зрения len() и append() они ведут себя одинаково. append к nil-слайсу безопасно выделит память.
Однако колоссальная разница проявляется при сериализации:
encoding/jsonсериализуетnil-слайс как JSON-значениеnull.encoding/jsonсериализует пустой слайсs2/s3как пустой JSON-массив[].
Best practice: Всегда объявляйте слайсы через форму var s []T, если данные будут добавляться динамически. Это полностью избавляет рантайм от лишней работы аллокатора по связыванию дескриптора со специальным адресом zerobase (глобальной константой рантайма для объектов нулевого размера).
Итог
- Слайс — это не массив. Это легковесная 24-байтная структура (
array,len,cap), управляющая скрытым базовым непрерывным массивом в памяти. - Нулевое копирование: Срезка
s[i:j]не копирует данные массива. Она создает новый заголовок слайса, ссылающийся на ту же память. - Mechanical Sympathy: Слайсы обеспечивают ту же пространственную локальность (Spatial Locality) и попадание в кэш процессора, что и статические массивы. Это структура данных №1 (default choice) в Go.
- Контроль памяти: Всегда используйте
make([]T, 0, expectedCapacity), чтобы избежать множественных реаллокаций приappend.
Разобравшись с массивами и их динамической оберткой (слайсами), мы переходим к структурам, где элементы разбросаны по памяти случайным образом, но связаны между собой ссылками. В следующей статье мы разберем: 3. Связные списки.