Срезы и массивы в Go: в чём разница и где подвох
Массив в Go — значение фиксированного размера, срез — обёртка над массивом с длиной и ёмкостью. Путаница между ними порождает баги, которые компилятор не ловит: мутация через один срез меняет данные в другом, append неожиданно выделяет новый массив, а передача массива в функцию копирует мегабайты. Разбираем устройство, типичные ловушки и правила выбора.

Что лежит под капотом: массив
Массив в Go — это значение. Не указатель, не ссылка, а цельный блок памяти, размер которого зашит в тип. [3]int и [4]int — два разных типа, присвоить один другому нельзя. При передаче в функцию массив копируется целиком: для [10_000]int это 80 КБ на каждый вызов.
Именно поэтому массивы в прикладном коде Go встречаются редко. Они нужны, когда размер известен на этапе компиляции и копирование не страшно: хеш-суммы ([32]byte), координаты, фиксированные буферы.
Срез: три поля поверх массива
Срез (slice) — структура из трёх машинных слов:
- указатель на элемент нижележащего массива;
- длина (len) — сколько элементов доступно;
- ёмкость (cap) — сколько элементов помещается до конца нижележащего массива.
На 64-битной платформе заголовок среза занимает 24 байта — независимо от того, ссылается он на десять элементов или на миллион. Передача среза в функцию копирует только этот заголовок, а не данные.
Срез — это окно в массив. Два среза могут смотреть в один и тот же массив, и запись через одно окно видна в другом. Это не баг, это модель владения, которую нужно держать в голове.
Как выглядит в памяти
Пример на Go 1.22+:
package main
import "fmt"
func main() {
arr := [5]int{10, 20, 30, 40, 50}
// Срез s смотрит на arr[1]..arr[3]
s := arr[1:4]
fmt.Println(len(s), cap(s)) // 3 4
// len = 3 элемента (20,30,40)
// cap = 4, потому что от arr[1] до конца массива четыре ячейки
s[0] = 999
fmt.Println(arr) // [10 999 30 40 50] — массив изменился
}
Проверка: после запуска go run main.go первый элемент среза и второй элемент массива показывают 999. Если видите другое — убедитесь, что версия Go не ниже 1.18.
Таблица различий
| Свойство | Массив | Срез |
|---|---|---|
| Тип | [N]T — размер входит в тип | []T — размер не входит |
| Размер в памяти | N × sizeof(T) | 24 байта (заголовок) + нижележащий массив |
| Передача в функцию | Копия всех элементов | Копия заголовка (24 байта), данные общие |
Сравнение == | Поэлементное, если элементы сравнимы | Только с nil; для содержимого — slices.Equal (Go 1.21+) |
| Литерал | [3]int{1, 2, 3} | []int{1, 2, 3} |
| Изменение длины | Невозможно | append, повторная нарезка |
| Нулевое значение | Все элементы — zero value типа | nil (len 0, cap 0, указатель nil) |
Три ловушки, на которых горят
1. Общий нижележащий массив
Два среза, полученных нарезкой одного массива или среза, делят память. Запись через один меняет данные в другом — без какого-либо предупреждения компилятора.
package main
import "fmt"
func main() {
orig := []int{1, 2, 3, 4, 5}
a := orig[0:3] // [1 2 3]
b := orig[1:4] // [2 3 4]
a[2] = 999
fmt.Println(b) // [2 999 4] — b тоже изменился
}
Если нужна независимая копия — явный copy или, начиная с Go 1.21, slices.Clone.
2. append и скрытое переаллоцирование
Когда cap среза хватает, append пишет в тот же массив. Когда не хватает — выделяет новый, копирует данные и возвращает срез с другим указателем. Старый срез по-прежнему смотрит в старый массив.
package main
import "fmt"
func main() {
s := make([]int, 3, 3) // len=3, cap=3
s[0], s[1], s[2] = 1, 2, 3
// cap исчерпан — append выделит новый массив
s2 := append(s, 4)
s2[0] = 999
fmt.Println(s[0]) // 1 — старый массив не тронут
fmt.Println(s2[0]) // 999 — новый массив
}
Правило: всегда используйте возвращённое значение append. Линтер go vet не ловит все случаи — привычка важнее.
3. Утечка памяти через маленький срез большого массива
Функция читает файл в []byte на мегабайт, берёт срез первых 10 байт и возвращает его. Нижележащий массив на мегабайт останется в памяти, пока жив этот крошечный срез. Решение — copy или slices.Clone нужного фрагмента в новый срез.
Когда всё-таки нужен массив
Массивы — не рудимент. Они полезны, когда:
- размер фиксирован и важен для корректности: SHA-256 — всегда
[32]byte, IPv4 —[4]byte; - структура с массивом должна быть сравнимой через
==(срезы так сравнивать нельзя); - нужен value-тип для использования как ключа map:
map[[2]int]stringработает,map[[]int]string— ошибка компиляции; - массив небольшой и копирование дешевле, чем косвенный доступ через указатель.
Как выбирать: make, литерал или var
// Известно содержимое — литерал
colors := []string{"red", "green", "blue"}
// Известна только длина — make
buffer := make([]byte, 0, 4096) // len=0, cap=4096: растём через append без лишних аллокаций
// Нулевой срез (nil) — достаточно var
var result []int
// len=0, cap=0, == nil
// append к nil-срезу работает корректно
Совет: если знаете верхнюю границу количества элементов, передайте её в cap через make. Это сокращает число аллокаций при append с O(log n) до одной.
Сноска. Нижележащий массив (underlying array, иногда backing array) — непрерывный блок памяти, на который ссылается заголовок среза. Сам по себе он не виден в коде, если только вы не создали его явно через [N]T.
Что делать дальше
Убедитесь, что понимаете поведение append при разных соотношениях len и cap — это источник большинства неочевидных багов. Следующий шаг — разобраться с конструкцией s[:n:n] (трёхиндексная нарезка, three-index slice), которая ограничивает cap и защищает от случайной перезаписи чужих данных через append. Документация по этому механизму — в спецификации языка, раздел «Slice expressions». В проде полезно прогнать go vet и линтер sliceheader из набора golangci-lint, чтобы поймать подозрительную работу с заголовками срезов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: The Go Programming Language Specification — Slice expressions, Go Blog — Go Slices: usage and internals
Частые вопросы
Можно ли передать массив в функцию без копирования?
nil-срез и пустой срез — это одно и то же?
Как понять, что append выделил новый массив?
Что будет, если у меня другая версия Go — код из статьи сработает?
Почему срезы нельзя сравнивать через ==?
Стратегия роста среза при append — она фиксирована?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.