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

ОСНОВЫ ЯЗЫКА: GO Срезы и map

Обязательно

Срезы и map в Go: как устроены и где грабли

Go

Чем массив отличается от среза, что такое len и cap, как работает append и почему два среза могут делить один массив. Плюс map: чтение с ok и range.

1. Срез - окно из трёх полей над массивом

1 / 5

Массив фиксирован, срез гибкий. Сам срез не копия, а шапка: указатель на массив-хранилище, длина и ёмкость.

  • len - сколько элементов видно сейчас.
  • cap - сколько места есть от начала среза до конца массива.
  • make([]int, 2, 5) показывает два элемента из пяти доступных.
массив-хранилище видимые 2 запас ещё 3 len = 2 cap = 5

2. append расширяет окно или выделяет новый массив

2 / 5

Пока есть запас, append пишет в старый массив. Когда len == cap, создаётся новый массив с копией элементов.

  • Результат обязательно присваивают: s = append(s, 7).
  • В запасе cap не меняется, после переаллокации вырастает.
  • Заранее известный размер резервируют через make([]T, 0, n).
len 2, cap 3 len 3, cap 3 новый массивlen 4, cap 6 старый массив

3. Нарезка делит память, copy разделяет

3 / 5

Под-срез - новое окно над тем же массивом. Изменение через один срез видно через другой, а append в под-срез может затереть соседний элемент.

  • part := s[1:3] не создаёт новый массив.
  • part[0] = 999 меняет оригинал.
  • Для независимой копии используют make + copy или полный срез s[low:high:max].
s[10 999 30 40] part = s[1:3]part[0] = 999 copy(dst, src)независимая память

4. map: чтение через «запятая ok»

4 / 5

Map возвращает нулевое значение даже при отсутствии ключа. Форма v, ok := m[key] отличает настоящий ноль от отсутствия.

  • ok == true означает, что ключ найден.
  • delete(m, key) безопасен и при отсутствии.
  • Порядок range по map не определён; для стабильного сортируют ключи отдельно.
m["alice"] = 30v = 30, ok = true m["zzz"] = 0ok = false v, ok := m[key] Порядок ключей не гарантирован

5. nil-срез рабочий, nil-map писать нельзя

5 / 5

var s []int имеет длину ноль и позволяет append. var m map[string]int читается, но запись вызывает panic.

  • Nil-срез: len == 0, append сам выделяет массив.
  • Nil-map: чтение даёт нулевое значение, запись падает.
  • Перед записью map создают через make или литерал.
var s []intappend(s, 1) -> [1] var m map[string]intm["x"] = 0 make(map[...]) m["x"] = 1 -> panic

Почти любая программа хранит списки и таблицы соответствий: список заказов, словарь «код страны → название». В Go для этого есть два рабочих инструмента — slice (срез) и map. Они выглядят просто, но у срезов есть пара особенностей, на которых спотыкаются почти все новички. Разберём с самого начала.

Массив: фиксированный размер

Сначала про массив (array) — потому что срез построен на нём. Массив в Go — это последовательность элементов фиксированной длины, и длина — часть типа.

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

[3]int и [4]int — это разные типы. Нельзя присвоить один другому, нельзя передать [4]int в функцию, которая ждёт [3]int. Из-за этой жёсткости массивы в обычном коде встречаются редко. Чаще нужен список, который умеет расти, — и тут на сцену выходит срез.

Срез (slice): окно в массив

Срез — это гибкий, изменяемый по размеру взгляд на последовательность элементов. Объявляется как тип без числа в скобках: []int, []string.

live example

package main

import "fmt"

func main() {
	s := []int{10, 20, 30} // литерал среза
	fmt.Println(s)         // [10 20 30]
	fmt.Println(len(s))    // 3
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Теперь главное, без чего дальше ничего не понять. Массив здесь ровно один — никаких «верхних» и «нижних» массивов не существует. Элементы лежат в обычном массиве в памяти, назовём его массив-хранилище. А срез — это не второй массив и не копия данных, а маленькая шапка из трёх полей, которая живёт рядом:

  • указатель — где лежит массив-хранилище (срез говорит: «смотри сюда»),
  • len — сколько элементов срезу видно от этого места,
  • cap — сколько элементов вообще помещается от начала среза до конца массива-хранилища.

То есть срез — это видимое окно над одним массивом, и окно можно двигать и расширять. Проще всего прочувствовать на make, который позволяет задать len и cap явно:

live example

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 — запас ещё есть
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Функция make([]T, len, cap) создаёт массив-хранилище нужного размера и окно над ним. Третий аргумент cap необязателен; если его опустить, ёмкость равна длине.

Короткая формула: срез — это «окно» (указатель + len + cap) над единственным массивом-хранилищем.

append: как срез растёт

Добавляют элементы встроенной функцией append. Она возвращает срез — и результат обязательно надо присвоить обратно:

live example

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)
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Что происходит внутри. Если в массиве-хранилище ещё есть запас (len < cap), append просто записывает элемент в свободную ячейку и расширяет окно: len увеличивается, cap не меняется. Если запаса нет (len == cap), Go выделяет новый, больший массив-хранилище, копирует туда старые элементы, дописывает новый — и возвращает срез, окно которого смотрит уже на новый массив. Старый массив остаётся лежать без хозяина, пока его не уберёт сборщик мусора.

Рост видно прямо в выводе — запустите и сравните len и cap до и после выхода за запас:

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Поэтому забыть s = ... — частая ошибка: без присваивания вы потеряете и новый элемент, и (после переаллокации) вообще ничего не добавите к видимому срезу.

Если заранее известно, сколько элементов будет, выделите ёмкость сразу — меньше переаллокаций:

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Нарезка: s[low:high]

Из среза (или массива) можно вырезать под-срез синтаксисом s[low:high]. Берутся элементы с индекса low включительно до high не включительно:

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Нарезка не создаёт нового массива — это новое окно над тем же самым массивом-хранилищем. И вот ключевой момент, ведущий к самой частой грабле.

Грабля: срезы делят общий массив-хранилище

Раз под-срез — окно над тем же массивом, запись через один срез видна через другой:

live example

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] — изменился и оригинал!
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

То же самое коварно проявляется с append. Если у под-среза есть запас ёмкости, append пишет в общий массив и затирает соседние элементы оригинала:

live example

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] — оригинал испорчен
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Когда нужна независимая копия — не нарезайте, а копируйте через copy:

live example

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]
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Короткая формула: нарезка делит память, copy — разделяет её.

map: таблица соответствий

map — это словарь «ключ → значение» (в других языках hash map, dictionary). Тип записывается как map[KeyType]ValueType.

live example

package main

import "fmt"

func main() {
	m := map[string]int{
		"alice": 30,
		"bob":   25,
	}
	m["carol"] = 28      // добавить или перезаписать
	fmt.Println(m["bob"]) // 25
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Создать пустую map можно через make или литералом map[string]int{}. Просто var m map[string]int даёт nil-map — из неё можно читать, но запись в неё вызовет panic (об этом ниже).

Чтение с проверкой: «запятая ok»

Если запросить отсутствующий ключ, map вернёт нулевое значение типа, а не ошибку. Чтобы отличить «лежит ноль» от «ключа нет», используют форму с двумя значениями — её называют «запятая ok»:

live example

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("ключа нет") // сюда
	}
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Удаление

live example

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]
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

range: перебор срезов и map

Цикл for ... range перебирает оба типа. Для среза он даёт индекс и значение:

live example

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
	}
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Если индекс не нужен — замените его на _:

live example

package main

import "fmt"

func main() {
	for _, v := range []string{"a", "b", "c"} {
		fmt.Println(v)
	}
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Для map range даёт ключ и значение:

live example

package main

import "fmt"

func main() {
	m := map[string]int{"alice": 30, "bob": 25}
	for key, value := range m {
		fmt.Println(key, value)
	}
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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

Нулевые значения: nil-срез и nil-map

У среза и map, объявленных без make и без литерала, нулевое значение — nil. Ведут они себя по-разному, и это стоит запомнить.

nil-срез полностью рабочий для чтения и для append:

live example

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]
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Поэтому отдельно инициализировать срез перед append обычно не нужно — можно начинать прямо с var s []T.

nil-map можно читать, но нельзя писать — запись вызывает panic:

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Строку с записью мы оставили закомментированной, чтобы пример запускался кнопкой; убедиться в panic легко, сняв комментарий. Поэтому map перед записью всегда создавайте через make или литерал:

live example

package main

import "fmt"

func main() {
	m := make(map[string]int) // теперь можно писать
	m["x"] = 1
	fmt.Println(m) // map[x:1]
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Короткая формула: 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):

live example

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
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Там же 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:

live example

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()
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Отсюда стоимость операций: добавить в конец в среднем дёшево, потому что копирование случается редко, а вставить в начало или середину всегда дорого, элементы правее сдвигаются: 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 напрямую не изменить.

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

  • Синтаксис и типы — переменные, базовые типы и нулевые значения, на которых всё это стоит.