Массив и слайс в Go
Если скалярные типы — это отдельные переменные, то массивы и слайсы — это способ работать с последовательностями данных. В большинстве языков это одна сущность. В Go это два принципиально разных инструмента с разной семантикой, и понимание разницы между ними — один из первых маркеров опытного Go-разработчика.
Массив — значимый тип фиксированного размера
Массив в Go — это значимый тип (value type). Это означает, что при присвоении или передаче в функцию массив копируется целиком.
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 — это разные типы, и компилятор не позволит их смешивать:
var a [3]int
var b [4]int
// a = b // Ошибка компиляции: cannot use b (type [4]int) as type [3]int
Из этого следует важное следствие: размер массива должен быть известен на этапе компиляции. Передать в функцию "массив любого размера" невозможно — именно поэтому в реальном коде массивы используются редко, а слайсы — повсеместно.
Копирование при присвоении
a := [3]int{1, 2, 3}
b := a // полная копия всех данных
b[0] = 99
fmt.Println(a) // [1 2 3] — не изменился
fmt.Println(b) // [99 2 3]
То же самое происходит при передаче в функцию:
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) поверх массива. Под капотом это структура из трёх полей:
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.
s := []int{1, 2, 3}
fmt.Println(len(s)) // 3 — текущая длина
fmt.Println(cap(s)) // 3 — ёмкость
Именно потому, что слайс — это заголовок, передача слайса в функцию дешева: копируется только 24 байта заголовка, underlying array не трогается.
Срезы (slicing) — окно в тот же массив
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. Изменение через один слайс видно через другой:
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:
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:
s := make([]int, 3) // len=3, cap=3, все элементы 0
s := make([]int, 3, 10) // len=3, cap=10 — зарезервировано место
Второй вариант полезен, когда заранее известно примерное количество элементов. Это позволяет избежать лишних реаллокаций при append.
append — как работает рост слайса
append добавляет элементы в конец слайса и возвращает новый заголовок. Здесь начинается самое интересное.
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]
Всегда присваивайте результат append обратно в переменную — это не опциональность стиля, а требование корректности. append может вернуть слайс с новым указателем Data, и если вы проигнорируете возвращаемое значение, работаете со старым заголовком.
Алгоритм роста
Когда len == cap, места нет и append делает следующее:
- Выделяет новый underlying array большего размера
- Копирует все существующие данные
- Добавляет новый элемент
- Возвращает заголовок с новым
Data, новымLenи новымCap
Исторически стратегия роста была "удвоение до 1024, потом ×1.25". Начиная с Go 1.18 алгоритм стал более плавным — переход к замедленному росту начинается раньше, формула зависит от текущего размера. Точные значения можно посмотреть в runtime/slice.go, но суть одна: Go всегда выделяет с запасом, чтобы не реаллоцировать при каждом append.
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 без реаллокации слайс всё ещё смотрит в тот же массив. Это порождает неочевидный баг:
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 дочернего слайса:
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)):
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.
Полное клонирование слайса
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 корректно обрабатывает перекрывающиеся регионы того же слайса:
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 пустой слайс
Эта разница маленькая, но на собеседованиях спрашивают часто:
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-сериализации они ведут себя по-разному.
import "encoding/json"
var s []int // nil
e := []int{} // пустой
js, _ := json.Marshal(s) // null
je, _ := json.Marshal(e) // []
Если API должен возвращать пустой массив [], а не null — используйте make([]int, 0) или []int{}.
Производительность: когда что использовать
Понимание внутреннего устройства слайса позволяет принимать осознанные решения:
Предаллоцируйте, если знаете размер. Каждая реаллокация — это аллокация памяти + копирование всех данных:
// Плохо — 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 — весь массив остаётся в памяти:
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 никогда не будет реаллоцировать.
Практика
Что выведет код?
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
b[0] = 99
fmt.Println(a[1])
Что выведет этот код?
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))
}
Напиши функцию reverse, которая принимает []int и возвращает новый слайс с элементами в обратном порядке. Исходный слайс не изменяй.
Исправь код: append к b не должен влиять на a. Сейчас b смотрит в тот же underlying array и при append перезаписывает a[3].
Задачи: Массив и слайс
Задача 1: Разворот слайса
Уровень: Лёгкая
Что проверяет: базовая работа со слайсами, индексация
Условие: Напиши функцию reverse(s []int) []int которая возвращает новый слайс с элементами в обратном порядке. Исходный слайс изменять нельзя.
Примеры:
reverse([]int{1, 2, 3, 4, 5}) → [5 4 3 2 1]
reverse([]int{1}) → [1]
reverse([]int{}) → []
Решение:
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
Условие: Что выведет код? Объясни каждый вывод.
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) // ?
}
Ожидаемый ответ:
[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
Решение:
// 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 которая удаляет дубликаты из слайса сохраняя порядок первого появления элементов. Реализуй без создания дополнительного слайса для результата — модифицируй исходный на месте.
Примеры:
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 как множество уже встреченных элементов. Для модификации на месте — два указателя: один читает, другой пишет.
Решение:
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).
// Модифицируем исходный слайс на месте — не создаём новый.