Почти любая программа хранит списки и таблицы соответствий: список заказов, словарь «код страны → название». В Go для этого есть два рабочих инструмента — slice (срез) и map. Они выглядят просто, но у срезов есть пара особенностей, на которых спотыкаются почти все новички. Разберём с самого начала.
Массив: фиксированный размер
Сначала про массив (array) — потому что срез построен на нём. Массив в Go — это последовательность элементов фиксированной длины, и длина — часть типа.
var a [3]int // массив из 3 int, по умолчанию [0 0 0]
a[0] = 10
fmt.Println(a) // [10 0 0]
fmt.Println(len(a)) // 3
[3]int и [4]int — это разные типы. Нельзя присвоить один другому, нельзя передать [4]int в функцию, которая ждёт [3]int. Из-за этой жёсткости массивы в обычном коде встречаются редко. Чаще нужен список, который умеет расти, — и тут на сцену выходит срез.
Срез (slice): окно в массивспросят на собеседовании
Срез — это гибкий, изменяемый по размеру взгляд на последовательность элементов. Объявляется как тип без числа в скобках: []int, []string.
s := []int{10, 20, 30} // литерал среза
fmt.Println(s) // [10 20 30]
fmt.Println(len(s)) // 3
Важно понять, что срез внутри — это не сами данные, а маленькая структура из трёх полей:
- указатель на нижний массив (где реально лежат элементы),
- len — длина (сколько элементов сейчас видно),
- cap — ёмкость (сколько элементов помещается от начала среза до конца нижнего массива).
s := make([]int, 2, 5) // длина 2, ёмкость 5
fmt.Println(s) // [0 0]
fmt.Println(len(s)) // 2
fmt.Println(cap(s)) // 5
Функция make([]T, len, cap) создаёт срез с заранее выделенной ёмкостью. Третий аргумент cap необязателен; если его опустить, ёмкость равна длине.
Короткая формула: срез — это «окно» (указатель + len + cap) поверх массива в памяти.
append: как срез растётспросят на собеседовании
Добавляют элементы встроенной функцией append. Она возвращает срез — и результат обязательно надо присвоить обратно:
s := []int{1, 2}
s = append(s, 3) // [1 2 3]
s = append(s, 4, 5) // [1 2 3 4 5] — можно несколько сразу
Что происходит внутри. Если в нижнем массиве ещё есть запас (len < cap), append просто записывает элемент в свободную ячейку и увеличивает len. Если запаса нет (len == cap), Go выделяет новый, больший массив, копирует туда старые элементы, дописывает новый — и возвращает срез, указывающий уже на новый массив.
Поэтому забыть s = ... — частая ошибка: без присваивания вы потеряете и новый элемент, и (после переаллокации) вообще ничего не добавите к видимому срезу.
Если заранее известно, сколько элементов будет, выделите ёмкость сразу — меньше переаллокаций:
ids := make([]int, 0, 100) // длина 0, но место под 100 уже есть
for i := 0; i < 100; i++ {
ids = append(ids, i) // ни одной переаллокации
}
Нарезка: s[low:high]
Из среза (или массива) можно вырезать под-срез синтаксисом s[low:high]. Берутся элементы с индекса low включительно до high не включительно.
s := []int{10, 20, 30, 40, 50}
fmt.Println(s[1:3]) // [20 30]
fmt.Println(s[:2]) // [10 20] — low по умолчанию 0
fmt.Println(s[2:]) // [30 40 50] — high по умолчанию len
И вот ключевой момент, ведущий к самой частой грабле.
Грабля: срезы делят общий нижний массивспросят на собеседовании
Нарезка не копирует данные. Под-срез указывает на тот же самый массив, что и оригинал. Значит, запись через один срез видна через другой:
s := []int{10, 20, 30, 40}
part := s[1:3] // [20 30], но это окно в тот же массив
part[0] = 999
fmt.Println(s) // [10 999 30 40] — изменился и оригинал!
То же самое коварно проявляется с append. Если у под-среза есть запас ёмкости, append пишет в общий массив и затирает соседние элементы оригинала:
s := []int{10, 20, 30, 40}
part := s[0:2] // len 2, но cap 4 — запас есть
part = append(part, 999) // пишет в s[2], не выделяя новый массив
fmt.Println(s) // [10 20 999 40] — оригинал испорчен
Когда нужна независимая копия — не нарезайте, а копируйте через copy:
src := []int{10, 20, 30}
dst := make([]int, len(src))
copy(dst, src) // dst — отдельный массив
dst[0] = 999
fmt.Println(src) // [10 20 30] — не затронут
Короткая формула: нарезка делит память, copy — разделяет её.
map: таблица соответствий
map — это словарь «ключ → значение» (в других языках hash map, dictionary). Тип записывается как map[KeyType]ValueType.
m := map[string]int{
"alice": 30,
"bob": 25,
}
m["carol"] = 28 // добавить или перезаписать
fmt.Println(m["bob"]) // 25
Создать пустую map можно через make или литералом map[string]int{}. Просто var m map[string]int даёт nil-map — из неё можно читать, но запись в неё вызовет панику (об этом ниже).
Чтение с проверкой: «запятая ok»
Если запросить отсутствующий ключ, map вернёт нулевое значение типа, а не ошибку:
m := map[string]int{"alice": 30}
fmt.Println(m["zzz"]) // 0 — но был ли такой ключ?
Чтобы отличить «лежит ноль» от «ключа нет», используют форму с двумя значениями — её называют «запятая ok»:
v, ok := m["zzz"]
if ok {
fmt.Println("нашли:", v)
} else {
fmt.Println("ключа нет") // сюда
}
Удаление
delete(m, "alice") // удалить ключ; если ключа нет — ничего не делает
range: перебор срезов и map
Цикл for ... range перебирает оба типа. Для среза он даёт индекс и значение:
s := []string{"a", "b", "c"}
for i, v := range s {
fmt.Println(i, v) // 0 a / 1 b / 2 c
}
Если индекс не нужен — замените его на _:
for _, v := range s {
fmt.Println(v)
}
Для map range даёт ключ и значение:
m := map[string]int{"alice": 30, "bob": 25}
for key, value := range m {
fmt.Println(key, value)
}
Важно: порядок обхода map не определён и меняется от запуска к запуску — Go делает это намеренно. Если нужен стабильный порядок, соберите ключи в срез и отсортируйте его отдельно.
Нулевые значения: nil-срез и nil-mapспросят на собеседовании
У среза и map, объявленных без make и без литерала, нулевое значение — nil. Ведут они себя по-разному, и это стоит запомнить.
nil-срез полностью рабочий для чтения и для append:
var s []int // nil
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
s = append(s, 1) // работает! append сам выделит массив
Поэтому отдельно инициализировать срез перед append обычно не нужно — можно начинать прямо с var s []T.
nil-map можно читать, но нельзя писать — запись паникует:
var m map[string]int // nil
fmt.Println(m["x"]) // 0 — читать можно
m["x"] = 1 // panic: assignment to entry in nil map
Поэтому map перед записью всегда создавайте через make или литерал:
m := make(map[string]int) // теперь можно писать
m["x"] = 1
Короткая формула: nil-срез писать можно (через append), nil-map — нельзя.
Глубже: полный срез, пакеты slices и maps, структуры внутри mapрасширенное
Грабля с append в под-срез лечится не только копированием. У выражения нарезки есть третий индекс — полный срез s[low:high:max], который ограничивает ёмкость: part := s[0:2:2] даёт len 2, cap 2, и следующий append вынужден выделить новый массив, не трогая s. Так отдают наружу кусок буфера, не рискуя, что получатель допишет в вашу память.
С Go 1.21 типовые операции над коллекциями лежат в стандартных пакетах slices и maps, и писать их циклом больше не нужно (итераторы, maps.Keys и slices.Sorted в примере ниже появились в Go 1.23):
живой пример
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
s := []int{3, 1, 2}
slices.Sort(s)
fmt.Println(s, slices.Contains(s, 2)) // [1 2 3] true
m := map[string]int{"b": 2, "a": 1}
for _, k := range slices.Sorted(maps.Keys(m)) { // стабильный порядок обхода
fmt.Println(k, m[k])
}
clear(m) // удалить все ключи
fmt.Println(len(m)) // 0
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Там же slices.Clone для независимой копии, slices.Index, slices.Reverse, slices.Equal (сами срезы через == сравнивать нельзя) и maps.Clone.
Последняя грабля — map, в которой лежат структуры. Элемент map не адресуем: &m[k] не соберётся, а m[k].Count++ компилятор отвергнет с ошибкой cannot assign to struct field. Значение достают в переменную, меняют и кладут обратно — либо хранят в map указатели на структуры, тогда m[k].Count++ работает.
Глубже: что внутри среза и map и сколько стоит вставкарасширенноеспросят на собеседовании
Срез это маленькая структура из трёх слов: указатель на нижний массив, длина и ёмкость, 24 байта на 64-битной машине. Именно её копирует вызов функции, и отсюда два правила, которые путают: запись в элемент s[0] = 1 внутри функции видна снаружи, потому что указатель тот же, а append внутри функции снаружи не виден, потому что новую длину и, возможно, новый массив получила только копия заголовка. Поэтому append всегда возвращают наружу.
Расти срез умеет только переаллокацией. До 256 элементов ёмкость удваивается, дальше множитель плавно снижается от двух к 1,25, и итог округляется до ближайшего размерного класса аллокатора, поэтому ряд выглядит неровно: после 512 идут 848, 1280, 1792, 2560:
живой пример
package main
import "fmt"
func main() {
var s []int
last := -1
for i := 0; i < 2000; i++ {
s = append(s, i)
if cap(s) != last {
last = cap(s)
fmt.Print(last, " ")
}
}
fmt.Println()
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда стоимость операций: добавить в конец в среднем дёшево, потому что копирование случается редко, а вставить в начало или середину всегда дорого, элементы правее сдвигаются: slices.Insert(s, 0, v) это копирование всего среза. Нужна частая вставка в начало, значит, нужна другая структура, например кольцевой буфер или связный список.
map в Go это хеш-таблица, и значение типа map[K]V это указатель на неё, поэтому map, переданная в функцию, меняется для всех. С Go 1.24 внутри «швейцарская таблица» (Swiss Table): группы по восемь слотов с байтом-меткой у каждого, по которому ключ отсеивают до сравнения; раньше были бакеты по восемь пар с цепочкой переполнения. Когда таблица заполняется, рантайм выделяет большую и переносит записи постепенно, по мере обращений, чтобы не было одной долгой паузы. Три следствия, которые спрашивают. Порядок обхода range намеренно случайный и разный от запуска к запуску, чтобы код не зависел от него. Ключом может быть только сравнимый тип: срез, map и функция не подходят, структура из сравнимых полей подходит. Одновременная запись из двух горутин роняет программу с fatal error: concurrent map writes, и это не panic, recover её не перехватит, поэтому общую map закрывают мьютексом.
Коротко
- Массив — фиксированной длины, длина — часть типа; в обычном коде редок.
- Срез — это указатель + len + cap поверх массива; гибкий и растущий.
appendвозвращает срез — всегда присваивайте результат обратно; при нехватке cap он выделяет новый массив.- Нарезка
s[low:high]не копирует — под-срез делит память с оригиналом, запись иappendмогут затронуть соседние данные. Нужна независимость —copy. - map — словарь «ключ → значение»; читайте через «запятая ok», чтобы отличить отсутствие ключа от нулевого значения; удаляйте через
delete. rangeдаёт индекс+значение для среза и ключ+значение для map; порядок обхода map не определён.- nil-срез годен для чтения и
append; в nil-map писать нельзя — создавайте черезmake. - Полный срез
s[low:high:max]ограничивает ёмкость и защищает исходный массив от чужогоappend. - Пакеты
slicesиmaps(Go 1.21+): сортировка, поиск, копия,slices.Sorted(maps.Keys(m))для стабильного обхода; поле структуры в map напрямую не изменить. - Срез это три слова: указатель, len, cap;
appendвнутри функции снаружи не виден, запись в элемент видна. Рост: вдвое до 256, дальше множитель плавно падает к 1,25; вставка в начало это копирование всего среза. - map это указатель на хеш-таблицу (Swiss Table с Go 1.24): порядок обхода случайный, ключ только сравнимого типа, одновременная запись это
fatal error, который не ловитсяrecover.
Что почитать дальше
- Синтаксис и типы — переменные, базовые типы и нулевые значения, на которых всё это стоит.
- Структуры и методы — как из срезов и map собирать составные данные.
- Указатели — почему срез передаётся «по ссылке на массив», а массив копируется целиком.