← назад к разделу

Почти любая программа хранит списки и таблицы соответствий: список заказов, словарь «код страны → название». В 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

У необъявленных через make/литерал срезов и map нулевое значение — 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 — нельзя.

Коротко

  • Массив — фиксированной длины, длина — часть типа; в обычном коде редок.
  • Срез — это указатель + len + cap поверх массива; гибкий и растущий.
  • append возвращает срез — всегда присваивайте результат обратно; при нехватке cap он выделяет новый массив.
  • Нарезка s[low:high] не копирует — под-срез делит память с оригиналом, запись и append могут затронуть соседние данные. Нужна независимость — copy.
  • map — словарь «ключ → значение»; читайте через «запятая ok», чтобы отличить отсутствие ключа от нулевого значения; удаляйте через delete.
  • range даёт индекс+значение для среза и ключ+значение для map; порядок обхода map не определён.
  • nil-срез годен для чтения и append; в nil-map писать нельзя — создавайте через make.

Что почитать дальше

  • Синтаксис и типы — переменные, базовые типы и нулевые значения, на которых всё это стоит.
  • Структуры и методы — как из срезов и map собирать составные данные.
  • Указатели — почему срез передаётся «по ссылке на массив», а массив копируется целиком.