Массив и слайс в Go

Если скалярные типы — это отдельные переменные, то массивы и слайсы — это способ работать с последовательностями данных. В большинстве языков это одна сущность. В Go это два принципиально разных инструмента с разной семантикой, и понимание разницы между ними — один из первых маркеров опытного Go-разработчика.


Массив — значимый тип фиксированного размера

Массив в Go — это значимый тип (value type). Это означает, что при присвоении или передаче в функцию массив копируется целиком.

go
var a [3]int // [0 0 0] — zero value для каждого элемента b := [3]int{1, 2, 3} // литерал c := [...]int{1, 2, 3} // компилятор считает размер сам — тоже [3]int

Размер массива — часть его типа. [3]int и [4]int — это разные типы, и компилятор не позволит их смешивать:

go
var a [3]int var b [4]int // a = b // Ошибка компиляции: cannot use b (type [4]int) as type [3]int

Из этого следует важное следствие: размер массива должен быть известен на этапе компиляции. Передать в функцию "массив любого размера" невозможно — именно поэтому в реальном коде массивы используются редко, а слайсы — повсеместно.

Копирование при присвоении

go
a := [3]int{1, 2, 3} b := a // полная копия всех данных b[0] = 99 fmt.Println(a) // [1 2 3] — не изменился fmt.Println(b) // [99 2 3]

То же самое происходит при передаче в функцию:

go
func modify(arr [3]int) { arr[0] = 99 // изменяем копию } a := [3]int{1, 2, 3} modify(a) fmt.Println(a) // [1 2 3] — оригинал не тронут

Для больших массивов это дорого. Чтобы избежать копирования, передают указатель — *[3]int. Но на практике в таких случаях просто используют слайс.


Слайс — три поля и один массив

Слайс — это дескриптор (view) поверх массива. Под капотом это структура из трёх полей:

go
type SliceHeader struct { Data uintptr // указатель на первый элемент в underlying array Len int // количество элементов, доступных через слайс Cap int // общая ёмкость от Data до конца underlying array }

Когда вы пишете s := []int{1, 2, 3}, происходит следующее: где-то в памяти выделяется массив [3]int{1, 2, 3}, а s — это заголовок (16 или 24 байта), который указывает на его начало с Len=3 и Cap=3.

go
s := []int{1, 2, 3} fmt.Println(len(s)) // 3 — текущая длина fmt.Println(cap(s)) // 3 — ёмкость

Именно потому, что слайс — это заголовок, передача слайса в функцию дешева: копируется только 24 байта заголовка, underlying array не трогается.

Срезы (slicing) — окно в тот же массив

go
a := [6]int{1, 2, 3, 4, 5, 6} s1 := a[1:4] // элементы 1, 2, 3 → [2 3 4], len=3, cap=5 s2 := a[2:5] // элементы 2, 3, 4 → [3 4 5], len=3, cap=4

s1 и s2 — разные заголовки, но они смотрят в один и тот же массив a. Изменение через один слайс видно через другой:

go
a := [6]int{1, 2, 3, 4, 5, 6} s1 := a[1:4] // [2 3 4], len=3, cap=5 s2 := a[2:5] // [3 4 5], len=3, cap=4 s1[0] = 99 fmt.Println(a) // [1 99 3 4 5 6] — массив изменился fmt.Println(s2) // [3 4 5] — s2[0] = a[2] = 3, не затронут

Это критически важно понимать: слайс не владеет данными, он только ссылается на них.

len и cap — в чём разница

Len — сколько элементов доступно прямо сейчас. Cap — сколько элементов можно получить без новой аллокации, расширив слайс до конца underlying array:

go
a := [6]int{1, 2, 3, 4, 5, 6} s := a[1:3] // len=2, cap=5 fmt.Println(s) // [2 3] fmt.Println(s[:5]) // [2 3 4 5 6] — расширяем до cap // fmt.Println(s[:6]) // panic: runtime error: slice bounds out of range

Расширить слайс за пределы cap нельзя — это паника в рантайме.


make — создание слайса с заданным cap

Кроме литерального синтаксиса, слайсы создают через make:

go
s := make([]int, 3) // len=3, cap=3, все элементы 0 s := make([]int, 3, 10) // len=3, cap=10 — зарезервировано место

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


append — как работает рост слайса

append добавляет элементы в конец слайса и возвращает новый заголовок. Здесь начинается самое интересное.

go
s := []int{1, 2, 3} s = append(s, 4) fmt.Println(s) // [1 2 3 4]

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

Алгоритм роста

Когда len == cap, места нет и append делает следующее:

  1. Выделяет новый underlying array большего размера
  2. Копирует все существующие данные
  3. Добавляет новый элемент
  4. Возвращает заголовок с новым Data, новым Len и новым Cap

Исторически стратегия роста была "удвоение до 1024, потом ×1.25". Начиная с Go 1.18 алгоритм стал более плавным — переход к замедленному росту начинается раньше, формула зависит от текущего размера. Точные значения можно посмотреть в runtime/slice.go, но суть одна: Go всегда выделяет с запасом, чтобы не реаллоцировать при каждом append.

go
s := make([]int, 0) for i := 0; i < 8; i++ { s = append(s, i) fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) } // len=1 cap=1 // len=2 cap=2 // len=3 cap=4 ← реаллокация, cap удвоился // len=4 cap=4 // len=5 cap=8 ← реаллокация // len=6 cap=8 // len=7 cap=8 // len=8 cap=8

Опасность общего underlying array

После append без реаллокации слайс всё ещё смотрит в тот же массив. Это порождает неочевидный баг:

go
a := make([]int, 3, 6) // len=3, cap=6 a[0], a[1], a[2] = 1, 2, 3 b := a[:3] // b смотрит в тот же массив, cap=6 b = append(b, 99) // len < cap, реаллокации нет — пишем в a[3] fmt.Println(a[:4]) // [1 2 3 99] — сюрприз! a "не знает" об этом элементе, // но данные в памяти изменились

Чтобы избежать этого, используют трёхиндексный срез — он ограничивает cap дочернего слайса:

go
a := make([]int, 3, 6) // len=3, cap=6 a[0], a[1], a[2] = 1, 2, 3 b := a[0:3:3] // len=3, cap=3 — теперь любой append создаст новый массив b = append(b, 99) // реаллокация — a не затронут fmt.Println(a) // [1 2 3] — безопасно

copy — явное копирование данных

copy копирует элементы из одного слайса в другой и возвращает количество скопированных элементов — min(len(dst), len(src)):

go
src := []int{1, 2, 3, 4, 5} dst := make([]int, 3) n := copy(dst, src) fmt.Println(dst) // [1 2 3] — скопировалось 3 элемента (len(dst)) fmt.Println(n) // 3

copy всегда безопасен: источник и получатель могут перекрываться (копирование через memmove), а не через memcpy.

Полное клонирование слайса

go
original := []int{1, 2, 3, 4, 5} // Способ 1 — через make + copy clone := make([]int, len(original)) copy(clone, original) // Способ 2 — через append (идиоматично) clone2 := append([]int{}, original...) fmt.Println(clone2) // [1 2 3 4 5] clone[0] = 99 fmt.Println(original) // [1 2 3 4 5] — не изменился fmt.Println(clone) // [99 2 3 4 5]

Оба способа правильны. append([]int{}, original...) чуть короче, но создаёт слайс с len == cap. make + copy даёт больше контроля — можно задать cap с запасом.

copy для сдвига элементов

copy корректно обрабатывает перекрывающиеся регионы того же слайса:

go
s := []int{1, 2, 3, 4, 5} // Удаление элемента с индексом 2 (значение 3) copy(s[2:], s[3:]) // сдвигаем элементы влево s = s[:len(s)-1] // уменьшаем длину fmt.Println(s) // [1 2 4 5]

nil-слайс vs пустой слайс

Эта разница маленькая, но на собеседованиях спрашивают часто:

go
var s []int // nil-слайс: Data=nil, Len=0, Cap=0 e := []int{} // пустой слайс: Data=(непустой указатель), Len=0, Cap=0 m := make([]int, 0) // тоже пустой слайс fmt.Println(s == nil) // true fmt.Println(e == nil) // false fmt.Println(m == nil) // false // Поведение одинаково: fmt.Println(len(s), len(e)) // 0 0 s = append(s, 1) // append с nil-слайсом работает нормально

Практически важный момент: при JSON-сериализации они ведут себя по-разному.

go
import "encoding/json" var s []int // nil e := []int{} // пустой js, _ := json.Marshal(s) // null je, _ := json.Marshal(e) // []

Если API должен возвращать пустой массив [], а не null — используйте make([]int, 0) или []int{}.


Производительность: когда что использовать

Понимание внутреннего устройства слайса позволяет принимать осознанные решения:

Предаллоцируйте, если знаете размер. Каждая реаллокация — это аллокация памяти + копирование всех данных:

go
// Плохо — N реаллокаций result := []int{} for i := 0; i < 10000; i++ { result = append(result, i) } // Хорошо — 0 реаллокаций result := make([]int, 0, 10000) for i := 0; i < 10000; i++ { result = append(result, i) }

Помните об утечках памяти через слайсы. Если вы держите маленький слайс, который ссылается на большой underlying array — весь массив остаётся в памяти:

go
func getFirstTwo(data []int) []int { return data[:2] // ссылается на весь data, даже если data огромный } // Правильно — копируем только нужное func getFirstTwo(data []int) []int { result := make([]int, 2) copy(result, data[:2]) return result }

Вопросы на собеседовании

Q: Чем массив отличается от слайса в Go?
A: Массив — значимый тип с фиксированным размером, который является частью типа ([3]int[4]int). При присвоении копируется целиком. Слайс — дескриптор (заголовок из трёх полей: Data, Len, Cap), который ссылается на underlying array. При присвоении копируется только заголовок.

Q: Из чего состоит слайс внутри?
A: Из трёх полей: Data — указатель на первый элемент в underlying array, Len — текущее количество элементов, Cap — ёмкость от Data до конца underlying array. На 64-битной системе заголовок занимает 24 байта.

Q: Что такое len и cap? В чём разница?
A: len — количество элементов, доступных через слайс прямо сейчас. cap — сколько элементов можно взять без новой аллокации, расширив слайс до конца underlying array. Всегда len <= cap.

Q: Что делает append, если len == cap?
A: Выделяет новый underlying array большего размера (с запасом по стратегии Go runtime), копирует все существующие данные, добавляет новый элемент и возвращает слайс с новым Data, Len и Cap. Старый underlying array становится кандидатом для GC.

Q: Могут ли два слайса ссылаться на один underlying array? К чему это приводит?
A: Да. Срезы (a[1:3], a[2:5]) создают новые заголовки, но смотрят в тот же массив. Изменение через один слайс видно через другой. Это частый источник неочевидных багов при append без реаллокации.

Q: Почему результат append нужно обязательно присваивать обратно?
A: Потому что append может вернуть слайс с новым Data (если произошла реаллокация). Если игнорировать возвращаемое значение и работать со старым заголовком, вы потеряете добавленные элементы.

Q: Чем nil-слайс отличается от пустого слайса?
A: nil-слайс (var s []int) имеет Data=nil, Len=0, Cap=0. Пустой слайс ([]int{}) имеет ненулевой Data, но Len=0, Cap=0. Поведение идентично для len, cap и append. Разница проявляется при == nil (true vs false) и JSON-сериализации (null vs []).

Q: Что делает copy? Что вернёт copy(dst, src), если len(dst) != len(src)?
A: Копирует элементы из src в dst. Возвращает min(len(dst), len(src)) — количество скопированных элементов. Если dst меньше — копируется столько, сколько влезает. Если src меньше — копируется весь src.

Q: Как безопасно скопировать слайс?
A: Через make + copy: dst := make([]int, len(src)); copy(dst, src). Или идиоматично: dst := append([]int{}, src...). Простое присвоение dst := src копирует только заголовок — оба слайса смотрят в один массив.

Q: Что такое трёхиндексный срез a[low:high:max] и зачем он нужен?
A: Позволяет явно ограничить cap нового слайса значением max - low. Это защищает от случайной записи через append в элементы родительского слайса за пределами high.

Q: Как слайс может стать причиной утечки памяти?
A: Если маленький слайс — срез большого — живёт долго, весь underlying array остаётся в памяти, потому что GC видит ссылку через Data. Решение: скопировать нужные элементы в новый независимый слайс через copy.

Q: Как удалить элемент из слайса по индексу?
A: Два способа. С сохранением порядка: s = append(s[:i], s[i+1:]...) — сдвигает все элементы после i влево, O(n). Без сохранения порядка: s[i] = s[len(s)-1]; s = s[:len(s)-1] — заменяет удаляемый элемент последним, O(1).

Q: Почему предаллокация через make([]T, 0, n) повышает производительность?
A: Без неё каждая реаллокация при append — это аллокация новой памяти и копирование всех данных. При известном размере результата make([]T, 0, n) резервирует нужную память заранее, и append никогда не будет реаллоцировать.


Практика

Quiz+10 XP

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

go
a := []int{1, 2, 3, 4, 5} b := a[1:3] b[0] = 99 fmt.Println(a[1])
  • 1
  • 2
  • 99
  • panic
Predict+15 XP

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

go
package main import "fmt" func main() { s := make([]int, 3, 6) s[0], s[1], s[2] = 1, 2, 3 fmt.Println(s) fmt.Println(len(s)) fmt.Println(cap(s)) }
Задача+20 XP

Напиши функцию reverse, которая принимает []int и возвращает новый слайс с элементами в обратном порядке. Исходный слайс не изменяй.

Исправь код+25 XP

Исправь код: append к b не должен влиять на a. Сейчас b смотрит в тот же underlying array и при append перезаписывает a[3].


Задачи: Массив и слайс


Задача 1: Разворот слайса

Уровень: Лёгкая

Что проверяет: базовая работа со слайсами, индексация

Условие: Напиши функцию reverse(s []int) []int которая возвращает новый слайс с элементами в обратном порядке. Исходный слайс изменять нельзя.

Примеры:

text
reverse([]int{1, 2, 3, 4, 5}) → [5 4 3 2 1] reverse([]int{1}) → [1] reverse([]int{}) → []

Решение:

go
package main import "fmt" func reverse(s []int) []int { result := make([]int, len(s)) for i, v := range s { result[len(s)-1-i] = v } return result } func main() { fmt.Println(reverse([]int{1, 2, 3, 4, 5})) // [5 4 3 2 1] fmt.Println(reverse([]int{1})) // [1] fmt.Println(reverse([]int{})) // [] }

Задача 2: Ловушка общего underlying array

Уровень: Средняя

Что проверяет: понимание того что срезы разделяют underlying array

Условие: Что выведет код? Объясни каждый вывод.

go
package main import "fmt" func main() { original := []int{1, 2, 3, 4, 5} a := original[1:3] b := original[1:3] a[0] = 99 fmt.Println(original) // ? fmt.Println(a) // ? fmt.Println(b) // ? a = append(a, 999) fmt.Println(original) // ? fmt.Println(a) // ? fmt.Println(b) // ? }

Ожидаемый ответ:

text
[1 99 3 4 5] // original изменился — a смотрит в тот же массив [99 3] // a видит изменение [99 3] // b тоже видит — один underlying array [1 99 3 999 5] // append без реаллокации — пишет в original[3] [99 3 999] // a вырос [99 3] // b не знает о новом элементе — его len=2

Решение:

go
// a и b — разные заголовки (SliceHeader), но Data указывает // в один и тот же underlying array — original. // // a[0] = 99 меняет original[1] — все видят изменение. // // append(a, 999): len(a)=2, cap(a)=4 — места хватает. // Реаллокации нет. 999 пишется в original[3]. // original видит изменение, b — нет (len(b)=2, 999 за его пределами). // // Защита: трёхиндексный срез a := original[1:3:3] // ограничивает cap=2, и append создаст новый массив.

Задача 3: Удаление дублей с сохранением порядка

Уровень: Сложная

Что проверяет: эффективная работа со слайсами и map, алгоритмическое мышление

Условие: Напиши функцию unique(s []int) []int которая удаляет дубликаты из слайса сохраняя порядок первого появления элементов. Реализуй без создания дополнительного слайса для результата — модифицируй исходный на месте.

Примеры:

text
unique([]int{1, 2, 2, 3, 1, 4}) → [1 2 3 4] unique([]int{1, 1, 1, 1}) → [1] unique([]int{1, 2, 3}) → [1 2 3] unique([]int{}) → []

Подсказка: Используй map как множество уже встреченных элементов. Для модификации на месте — два указателя: один читает, другой пишет.

Решение:

go
package main import "fmt" func unique(s []int) []int { seen := make(map[int]struct{}) write := 0 // указатель записи for _, v := range s { if _, exists := seen[v]; !exists { seen[v] = struct{}{} s[write] = v // пишем на место write write++ } } return s[:write] // обрезаем до количества уникальных } func main() { fmt.Println(unique([]int{1, 2, 2, 3, 1, 4})) // [1 2 3 4] fmt.Println(unique([]int{1, 1, 1, 1})) // [1] fmt.Println(unique([]int{1, 2, 3})) // [1 2 3] fmt.Println(unique([]int{})) // [] } // Сложность: O(n) по времени, O(n) по памяти (map). // Модифицируем исходный слайс на месте — не создаём новый.