Почти любая программа хранит списки и таблицы соответствий: список заказов, словарь «код страны → название». В Go для этого есть два рабочих инструмента — slice (срез) и map. Они выглядят просто, но у срезов есть пара особенностей, на которых спотыкаются почти все новички. Разберём с самого начала.
Массив: фиксированный размер
Сначала про массив (array) — потому что срез построен на нём. Массив в Go — это последовательность элементов фиксированной длины, и длина — часть типа.
живой пример
package main
import "fmt"
func main() {
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.
живой пример
package main
import "fmt"
func main() {
s := []int{10, 20, 30} // литерал среза
fmt.Println(s) // [10 20 30]
fmt.Println(len(s)) // 3
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Теперь главное, без чего дальше ничего не понять. Массив здесь ровно один — никаких «верхних» и «нижних» массивов не существует. Элементы лежат в обычном массиве в памяти, назовём его массив-хранилище. А срез — это не второй массив и не копия данных, а маленькая шапка из трёх полей, которая живёт рядом:
- указатель — где лежит массив-хранилище (срез говорит: «смотри сюда»),
- len — сколько элементов срезу видно от этого места,
- cap — сколько элементов вообще помещается от начала среза до конца массива-хранилища.
То есть срез — это видимое окно над одним массивом, и окно можно двигать и расширять. Проще всего прочувствовать на make, который позволяет задать len и cap явно:
живой пример
package main
import "fmt"
func main() {
s := make([]int, 2, 5) // массив-хранилище на 5 ячеек, видно 2
fmt.Println(s) // [0 0] — видны только первые две
fmt.Println(len(s)) // 2
fmt.Println(cap(s)) // 5 — запас ещё есть
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Функция make([]T, len, cap) создаёт массив-хранилище нужного размера и окно над ним. Третий аргумент cap необязателен; если его опустить, ёмкость равна длине.
Короткая формула: срез — это «окно» (указатель + len + cap) над единственным массивом-хранилищем.
append: как срез растёт
Добавляют элементы встроенной функцией append. Она возвращает срез — и результат обязательно надо присвоить обратно:
живой пример
package main
import "fmt"
func main() {
s := []int{1, 2}
s = append(s, 3) // [1 2 3]
s = append(s, 4, 5) // [1 2 3 4 5] — можно несколько сразу
fmt.Println(s)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Что происходит внутри. Если в массиве-хранилище ещё есть запас (len < cap), append просто записывает элемент в свободную ячейку и расширяет окно: len увеличивается, cap не меняется. Если запаса нет (len == cap), Go выделяет новый, больший массив-хранилище, копирует туда старые элементы, дописывает новый — и возвращает срез, окно которого смотрит уже на новый массив. Старый массив остаётся лежать без хозяина, пока его не уберёт сборщик мусора.
Рост видно прямо в выводе — запустите и сравните len и cap до и после выхода за запас:
живой пример
package main
import "fmt"
func main() {
s := make([]int, 2, 3) // запас: 3 минус 2 = одна свободная ячейка
fmt.Println(len(s), cap(s)) // 2 3
s = append(s, 7) // влезла в запас: тот же массив, len вырос
fmt.Println(len(s), cap(s)) // 3 3
s = append(s, 9) // запаса нет: новый массив-хранилище, cap вырос
fmt.Println(len(s), cap(s)) // 4 6
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Поэтому забыть s = ... — частая ошибка: без присваивания вы потеряете и новый элемент, и (после переаллокации) вообще ничего не добавите к видимому срезу.
Если заранее известно, сколько элементов будет, выделите ёмкость сразу — меньше переаллокаций:
живой пример
package main
import "fmt"
func main() {
ids := make([]int, 0, 100) // длина 0, но место под 100 уже есть
for i := 0; i < 100; i++ {
ids = append(ids, i) // ни одной переаллокации
}
fmt.Println(len(ids), cap(ids)) // 100 100
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Нарезка: s[low:high]
Из среза (или массива) можно вырезать под-срез синтаксисом s[low:high]. Берутся элементы с индекса low включительно до high не включительно:
живой пример
package main
import "fmt"
func main() {
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
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Нарезка не создаёт нового массива — это новое окно над тем же самым массивом-хранилищем. И вот ключевой момент, ведущий к самой частой грабле.
Грабля: срезы делят общий массив-хранилище
Раз под-срез — окно над тем же массивом, запись через один срез видна через другой:
живой пример
package main
import "fmt"
func main() {
s := []int{10, 20, 30, 40}
part := s[1:3] // [20 30], но это окно в тот же массив
part[0] = 999
fmt.Println(s) // [10 999 30 40] — изменился и оригинал!
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
То же самое коварно проявляется с append. Если у под-среза есть запас ёмкости, append пишет в общий массив и затирает соседние элементы оригинала:
живой пример
package main
import "fmt"
func main() {
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:
живой пример
package main
import "fmt"
func main() {
src := []int{10, 20, 30}
dst := make([]int, len(src))
copy(dst, src) // dst — отдельный массив-хранилище
dst[0] = 999
fmt.Println(src) // [10 20 30] — не затронут
fmt.Println(dst) // [999 20 30]
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Короткая формула: нарезка делит память, copy — разделяет её.
map: таблица соответствий
map — это словарь «ключ → значение» (в других языках hash map, dictionary). Тип записывается как map[KeyType]ValueType.
живой пример
package main
import "fmt"
func main() {
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 — из неё можно читать, но запись в неё вызовет panic (об этом ниже).
Чтение с проверкой: «запятая ok»
Если запросить отсутствующий ключ, map вернёт нулевое значение типа, а не ошибку. Чтобы отличить «лежит ноль» от «ключа нет», используют форму с двумя значениями — её называют «запятая ok»:
живой пример
package main
import "fmt"
func main() {
m := map[string]int{"alice": 30}
fmt.Println(m["zzz"]) // 0 — но был ли такой ключ?
v, ok := m["zzz"]
if ok {
fmt.Println("нашли:", v)
} else {
fmt.Println("ключа нет") // сюда
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Удаление
живой пример
package main
import "fmt"
func main() {
m := map[string]int{"alice": 30, "bob": 25}
delete(m, "alice") // удалить ключ; если ключа нет — ничего не делает
fmt.Println(len(m), m) // 1 map[bob:25]
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
range: перебор срезов и map
Цикл for ... range перебирает оба типа. Для среза он даёт индекс и значение:
живой пример
package main
import "fmt"
func main() {
s := []string{"a", "b", "c"}
for i, v := range s {
fmt.Println(i, v) // 0 a / 1 b / 2 c
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если индекс не нужен — замените его на _:
живой пример
package main
import "fmt"
func main() {
for _, v := range []string{"a", "b", "c"} {
fmt.Println(v)
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Для map range даёт ключ и значение:
живой пример
package main
import "fmt"
func main() {
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:
живой пример
package main
import "fmt"
func main() {
var s []int // nil: ни массива, ни окна
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
s = append(s, 1) // работает! append сам выделит массив-хранилище
fmt.Println(s) // [1]
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Поэтому отдельно инициализировать срез перед append обычно не нужно — можно начинать прямо с var s []T.
nil-map можно читать, но нельзя писать — запись вызывает panic:
живой пример
package main
import "fmt"
func main() {
var m map[string]int // nil
fmt.Println(m["x"]) // 0 — читать можно
// m["x"] = 1 // раскомментируйте: panic: assignment to entry in nil map
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Строку с записью мы оставили закомментированной, чтобы пример запускался кнопкой; убедиться в panic легко, сняв комментарий. Поэтому map перед записью всегда создавайте через make или литерал:
живой пример
package main
import "fmt"
func main() {
m := make(map[string]int) // теперь можно писать
m["x"] = 1
fmt.Println(m) // map[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внутри функции снаружи не виден (копия шапки); рост ёмкости вдвое до 256 элементов, дальше медленнее. appendвозвращает срез — всегда присваивайте результат обратно; при нехватке cap он выделяет новый массив-хранилище.- Нарезка
s[low:high]не копирует — под-срез делит память с оригиналом, запись иappendмогут затронуть соседние данные. Нужна независимость —copy. - map — словарь «ключ → значение», внутри указатель на хеш-таблицу (Swiss Table с Go 1.24): порядок обхода случайный, ключ только сравнимого типа, одновременная запись конфликтов
panic; читайте через «запятая 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 напрямую не изменить.
Что почитать дальше
- Синтаксис и типы — переменные, базовые типы и нулевые значения, на которых всё это стоит.