---
title: "Срезы, карты, строки"
description: "До сих пор мы работали с одиночными значениями: число, строка, булево. Но настоящие программы почти всегда имеют дело с наборами данных: список заказов, сколько раз встретилось…"
canonical: https://graphlms.ru/go-basics/book/collections
course: "Основы Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Срезы, карты, строки

> Источник: https://graphlms.ru/go-basics/book/collections — курс «Основы Go», GraphLMS.

## О чём глава

До сих пор мы работали с одиночными значениями: число, строка, булево. Но настоящие программы почти всегда имеют дело с наборами данных: список заказов, сколько раз встретилось каждое слово, текст, который ввёл пользователь. В Go для этого есть три типа, которые вы будете использовать каждый день: **срезы** (упорядоченный список), **карты** (соответствие ключ → значение) и **строки**.

У каждого есть ловушка, на которую натыкается почти каждый новичок: срез меняется «сам по себе», обход карты каждый раз идёт в другом порядке, а `len("Привет")` выдаёт 12. Всё это перестаёт удивлять, когда видишь, как эти типы устроены, поэтому начнём с устройства.

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

Начнём с самого простого типа — массива. Массив в Go — это последовательность элементов *одного* типа и *фиксированной* длины, которая зашита в сам тип.

```go
var nums [3]int        // [0 0 0] — длина 3, часть типа
nums[0] = 10
days := [3]string{"пн", "вт", "ср"}
```

Главное слово здесь — «фиксированная». Длина массива — часть его типа: `[3]int` и `[4]int` — два *разных* типа, их нельзя присвоить друг другу. Размер нельзя поменять во время работы программы, а программам обычно нужен список, который умеет расти. Поэтому массивы в коде встречаются редко: они служат хранилищем для срезов, а работаете вы со срезами.

Одна деталь, которая ещё аукнется: массив в Go — *значение*. Если присвоить массив другой переменной или передать в функцию, копируется весь массив целиком. Срезы, как мы увидим, ведут себя иначе — и именно в этом источник самых частых сюрпризов.

## Срезы: список, который умеет расти

Срез (slice) — это гибкий «вид» на последовательность элементов. В отличие от массива, длина среза не зашита в тип: `[]int` — это срез целых чисел, и в нём может быть хоть ноль, хоть миллион элементов.

```go
primes := []int{2, 3, 5, 7, 11}   // литерал среза — без числа в скобках
fmt.Println(primes[0], primes[4]) // 2 11
fmt.Println(len(primes))          // 5
```

Обратите внимание на разницу в записи: `[3]int` (с числом) — массив, `[]int` (пусто в скобках) — срез.

### Что такое срез на самом деле

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

- **указатель** на начало данных в каком-то массиве (его называют *бэкинг-массив*, backing array);
- **длина** (`len`) — сколько элементов сейчас видно;
- **ёмкость** (`cap`) — сколько элементов помещается от начала среза до конца бэкинг-массива.

Сам срез данные не хранит — он лишь *смотрит* на участок массива, который живёт где-то в памяти. Длина — это сколько элементов вы видите прямо сейчас. Ёмкость — это запас: сколько ещё можно дописать, не выделяя новый массив. Эта тройка (указатель, len, cap) и объясняет почти всё «странное» поведение, которое мы дальше разберём.

### make и нулевой срез

Срез можно создать литералом, как выше, а можно функцией `make` — когда нужно заранее задать длину и (по желанию) ёмкость:

```go
a := make([]int, 3)      // len=3, cap=3 → [0 0 0]
b := make([]int, 0, 10)  // len=0, cap=10 → пустой, но с запасом на 10
```

Зачем нужен `make([]int, 0, 10)`? Если вы знаете, что собираетесь добавить примерно десять элементов, выгодно сразу попросить запас — тогда не придётся несколько раз перевыделять память по дороге. Это частая микро-оптимизация.

Отдельно стоит *нулевой* срез — переменная типа `[]int` без инициализации. Он равен `nil`, его `len` и `cap` равны нулю, но к нему уже можно применять `append`. Это удобно: не нужно отдельно «создавать» срез перед тем, как начать в него добавлять.

### append и рост ёмкости

`append` — главный инструмент работы со срезами. Он добавляет элементы в конец и возвращает *новый* срез. Важнейшее правило: **всегда присваивайте результат `append` обратно**, потому что срез может «переехать» на новый бэкинг-массив.

Посмотрим, как растёт ёмкость по мере добавления:

```go play
package main

import "fmt"

func main() {
	s := make([]int, 0)
	fmt.Printf("старт: len=%d cap=%d\n", len(s), cap(s))

	prevCap := cap(s)
	for i := 1; i <= 8; i++ {
		s = append(s, i)
		if cap(s) != prevCap {
			fmt.Printf("после %d: len=%d cap=%d (рост!)\n", i, len(s), cap(s))
			prevCap = cap(s)
		}
	}
	fmt.Println("итог:", s)
}
```

Вывод на Go 1.25:

```text
старт: len=0 cap=0
после 1: len=1 cap=1 (рост!)
после 2: len=2 cap=2 (рост!)
после 3: len=3 cap=4 (рост!)
после 5: len=5 cap=8 (рост!)
итог: [1 2 3 4 5 6 7 8]
```

Ёмкость растёт не по одному, а скачками: 1, 2, 4, 8. Когда запаса не хватает, Go выделяет массив побольше (у маленьких срезов — вдвое), копирует туда старые элементы и дописывает новый. Отсюда правило «присваивай результат обратно»: после такого переезда старая переменная смотрела бы на старый массив без новых данных. Точные числа ёмкости — деталь реализации, у больших срезов множитель меньше двух, закладываться на них не стоит.

### Выражения среза

От уже существующего среза (или массива) можно взять «подсрез» с помощью выражения `s[low:high]`. Берутся элементы с индекса `low` включительно до `high` *не* включительно:

```go
s := []int{10, 20, 30, 40, 50}
fmt.Println(s[1:3]) // [20 30] — индексы 1 и 2
fmt.Println(s[:2])  // [10 20] — с начала
fmt.Println(s[3:])  // [40 50] — до конца
```

Границы можно опускать: `s[:2]` — от начала, `s[2:]` — до конца, `s[:]` — весь срез целиком. Половинно-открытый интервал (high не включается) поначалу непривычен, но у него приятное свойство: длина результата ровно равна `high - low`.

### Общий бэкинг-массив — главные грабли

А вот и обещанная ловушка. Когда вы делаете подсрез, **новый срез не копирует данные** — он смотрит на *тот же самый* бэкинг-массив, что и исходный. Значит, изменив элемент через один срез, вы меняете его и для другого:

```go play
package main

import "fmt"

func main() {
	base := []int{10, 20, 30, 40, 50}
	view := base[1:4] // смотрит на 20, 30, 40 в том же массиве

	fmt.Println("view до:", view) // [20 30 40]
	view[0] = 999                 // меняем view[0]...

	fmt.Println("view после:", view) // [999 30 40]
	fmt.Println("base после:", base) // [10 999 30 40 50] — изменился тоже
}
```

Мы тронули `view[0]`, а изменилось и `base`: `view[0]` и `base[1]` — одна и та же ячейка общего массива. Так срезы и задуманы, но если про это не знать, отладка может занять час.

Тот же механизм делает коварным и `append`. Если у среза *есть* запас ёмкости, `append` пишет прямо в общий массив и может затереть данные «соседа». Если запаса нет — `append` выделяет новый массив, и связь рвётся. Поэтому поведение `append` к подсрезу зависит от ёмкости, и предсказать его на глаз тяжело. Практическое правило: если нужна *независимая* копия данных, делайте её явно через `make` + `copy`:

```go
dst := make([]int, len(src))
copy(dst, src) // dst — отдельный массив, никак не связан с src
```

Встроенная функция `copy(dst, src)` копирует элементы и возвращает их количество (минимум из двух длин). Обратите внимание: `dst` создан нужной длины. `copy` в срез нулевой длины ничего не скопирует.

### Срез в функции: элемент меняется, длина — нет

Из [главы о функциях](/go-basics/book/functions) вы знаете, что аргумент передаётся копией. Срез тоже копируется, но копируется только тройка «указатель, длина, ёмкость». Указатель в копии смотрит на тот же массив. Отсюда два разных результата:

```go play
package main

import "fmt"

func setFirst(s []int) {
	s[0] = 100 // пишем в общий массив — вызывающий увидит
}

func addOne(s []int) {
	s = append(s, 4) // меняем длину только у локальной копии
}

func main() {
	nums := []int{1, 2, 3}
	setFirst(nums)
	addOne(nums)
	fmt.Println(nums, len(nums))
}
```

Вывод:

```text
[100 2 3] 3
```

`setFirst` изменила элемент: запись прошла по общему указателю. `addOne` добавила элемент, но новая длина осталась в её локальной копии, а у `nums` в `main` длина по-прежнему 3. Поэтому функции, которые добавляют элементы, возвращают срез: `func addOne(s []int) []int`, и вызывают их как `nums = addOne(nums)` — так же, как сам `append`.

### Выход за границы

У среза длины `n` допустимы индексы от `0` до `n-1`. Обращение к `s[3]` у среза из трёх элементов остановит программу с сообщением `panic: runtime error: index out of range [3] with length 3`. Паника — аварийная остановка программы; подробнее о ней в [главе об ошибках](/go-basics/book/errors). Отрицательный индекс-константу вроде `s[-1]` компилятор не пропустит ещё при сборке.

Частая ошибка — взять `s[0]` у пустого среза. Если срез может оказаться пустым, сначала проверьте `len(s) > 0`. Для обхода всех элементов используйте `range`: он перебирает только существующие элементы и выйти за границу не может.

## Карты: соответствие ключ → значение

Карта (map) хранит пары «ключ → значение» и позволяет быстро находить значение по ключу. Если срез индексируется числами по порядку, то карта — произвольными ключами: строками, числами, чем угодно сравнимым.

Карту нужно *создать* перед использованием — через `make` или литералом. Просто объявленная переменная-карта равна `nil`, и запись в неё вызовет панику.

```go
ages := make(map[string]int)        // пустая карта string → int
ages["Аня"] = 30                    // добавили пару

scores := map[string]int{           // литерал сразу с данными
	"мат": 5,
	"физ": 4,
}
```

Тип `map[string]int` читается так: ключи — строки, значения — целые. Доступ по ключу — через те же квадратные скобки, что и у срезов: `ages["Аня"]`.

### comma-ok: есть ключ или нет?

Здесь прячется тонкость. Если запросить *отсутствующий* ключ, карта не паникует и не возвращает ошибку — она отдаёт *нулевое значение* типа. Для `int` это `0`. И тогда непонятно: ключа нет — или он есть, но значение и правда ноль?

Чтобы это различать, есть форма с двумя возвращаемыми значениями — её называют *comma-ok*:

```go play
package main

import "fmt"

func main() {
	stock := map[string]int{"яблоки": 5, "груши": 0}

	// Обычный доступ не отличает "нет ключа" от "значение 0":
	fmt.Println("бананы:", stock["бананы"]) // 0 — а есть ли они вообще?

	// comma-ok: второе значение говорит, был ли ключ
	n, ok := stock["груши"]
	fmt.Printf("груши: n=%d ok=%v\n", n, ok) // n=0 ok=true (ключ ЕСТЬ)

	n, ok = stock["бананы"]
	fmt.Printf("бананы: n=%d ok=%v\n", n, ok) // n=0 ok=false (ключа НЕТ)

	delete(stock, "яблоки") // удаляем пару по ключу
	_, ok = stock["яблоки"]
	fmt.Println("яблоки после delete, ok:", ok) // false
}
```

Запомните: `value, ok := m[key]` — `ok` равен `true`, если ключ есть, и `false`, если нет. Это единственный надёжный способ проверить наличие ключа. А `delete(m, key)` убирает пару; если ключа не было — ничего страшного не случится, `delete` просто ничего не сделает.

### Итерация и её непредсказуемый порядок

По карте можно пройтись циклом `range`, получая ключ и значение. Но есть важная особенность: **порядок обхода не определён и намеренно случаен**. Два запуска одной программы могут выдать пары в разном порядке.

```go play
package main

import (
	"fmt"
	"sort"
)

func main() {
	prices := map[string]int{"хлеб": 40, "молоко": 70, "сыр": 300}

	// Порядок range по карте НЕ гарантирован, поэтому собираем
	// ключи и сортируем — так вывод станет детерминированным.
	keys := make([]string, 0, len(prices))
	for k := range prices {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	for _, k := range keys {
		fmt.Printf("%s: %d\n", k, prices[k])
	}
}
```

Вывод всегда один и тот же:

```text
молоко: 70
сыр: 300
хлеб: 40
```

Это рабочий приём: если нужен стабильный порядок, соберите ключи в срез, отсортируйте его (`sort.Strings`) и обходите уже по нему. Сама карта порядок не хранит, она нужна для быстрого поиска. Разработчики Go специально сделали порядок обхода непредсказуемым, чтобы никто не начал на него полагаться. В новом коде вместо `sort.Strings(keys)` часто пишут `slices.Sort(keys)` из пакета `slices`; в браузерном интерпретаторе этот пакет недоступен, поэтому здесь `sort`.

### Пример: подсчёт слов

Карты особенно удобны для подсчёта. Нулевое значение отсутствующего ключа здесь помогает: `counts[w]++` для нового слова начинает с `0`, и проверка «есть ли ключ» не нужна.

```go play
package main

import "fmt"

func main() {
	words := []string{"go", "код", "go", "тест", "go", "код"}
	counts := make(map[string]int)
	for _, w := range words {
		counts[w]++
	}
	fmt.Println(counts["go"], counts["код"], counts["тест"], counts["java"])
	fmt.Println("разных слов:", len(counts))
}
```

Вывод:

```text
3 2 1 0
разных слов: 3
```

`len` для карты возвращает число пар. Как и срез, карта, переданная в функцию, не копирует данные: функция, которая делает `m[key] = value`, меняет ту же самую карту, что и у вызывающего.

## Строки и руны: декодирование UTF-8

Строка в Go — это неизменяемая последовательность *байтов*. Чаще всего эти байты кодируют текст в UTF-8. И вот тут начинается то, что путает почти всех.

В UTF-8 символ может занимать *разное* число байтов: латинская буква и цифра — один байт, а кириллица, например, — два. Поэтому `len(строки)` возвращает число **байтов**, а не символов:

```go play
package main

import "fmt"

func main() {
	s := "Go!"   // только ASCII
	r := "Привет" // кириллица

	fmt.Println(len(s)) // 3 — три байта, три символа, совпало
	fmt.Println(len(r)) // 12 — шесть букв, но двенадцать байтов
}
```

`len("Привет")` равно 12, потому что каждая кириллическая буква занимает по два байта. Если вам нужно именно число *символов*, `len` не подходит — нужен другой подход.

### Руна — это кодовая точка Unicode

Чтобы говорить о символах, а не байтах, в Go есть тип `rune`. Руна — это кодовая точка Unicode; `rune` — псевдоним `int32`. Один видимый символ может состоять из нескольких рун: например, буква и комбинируемый знак ударения. Поэтому число рун и число видимых символов не всегда совпадают. Литерал руны пишется в одинарных кавычках: `'A'`, `'Я'`, `'😀'`.

Когда вы проходите по строке циклом `range`, Go *сам* декодирует UTF-8 и выдаёт вам **руны**, а не байты. При этом индекс — это позиция в *байтах*, поэтому для многобайтовых символов он скачет:

```go play
package main

import "fmt"

func main() {
	for i, r := range "Гоу" {
		// %c печатает символ, %U — его Unicode-код
		fmt.Printf("байт %d: %c (%U)\n", i, r, r)
	}
}
```

Вывод:

```text
байт 0: Г (U+0413)
байт 2: о (U+043E)
байт 4: у (U+0443)
```

Индексы идут `0, 2, 4`, а не `0, 1, 2`: каждая кириллическая руна занимает два байта, и `range` показывает, с какого байта она начинается. Сама руна `r` — уже целый символ, его можно напечатать через `%c`.

### []byte против []rune

Строку можно превратить в срез двумя способами, и разница как раз про байты и символы:

- `[]byte(s)` — срез *байтов*. Длина равна `len(s)`. Это «сырое» представление, удобно для работы с UTF-8 на низком уровне.
- `[]rune(s)` — срез *рун* (символов). Длина равна числу декодированных кодовых точек. Удобно, когда нужен доступ к i-й руне или подсчёт рун.

```go play
package main

import "fmt"

func main() {
	s := "Привет"

	bytes := []byte(s)
	runes := []rune(s)

	fmt.Println("байтов:", len(bytes)) // 12
	fmt.Println("символов:", len(runes)) // 6 — вот сколько РЕАЛЬНО букв

	fmt.Printf("третий символ: %c\n", runes[2]) // и — по индексу символа
}
```

Посчитать руны в строке можно так: `len([]rune(s))`. Для обычного слова «Привет» это совпадает с числом букв; для составных символов и эмодзи совпадения может не быть. А `runes[2]` даёт именно *третий символ*, тогда как `s[2]` дал бы третий *байт* — кусок буквы, а не саму букву. Запомните разницу: индексация строки `s[i]` всегда работает в байтах, а `[]rune(s)[i]` — в символах.

### Пакет strings: повседневные операции

Большая часть работы с текстом — это не байты и руны, а готовые функции пакета `strings`: обрезать пробелы, разбить на слова, поменять регистр. Они корректно работают с UTF-8, в том числе с кириллицей.

```go play
package main

import (
	"fmt"
	"strings"
)

func main() {
	text := "  Привет,   Go и мир  "

	clean := strings.TrimSpace(text) // убрать пробелы по краям
	fmt.Printf("%q\n", clean)

	fmt.Println(strings.ToUpper(clean))          // верхний регистр
	fmt.Println(strings.Contains(clean, "Go"))   // есть ли подстрока
	fmt.Println(len(strings.Fields(clean)))      // слова между пробелами

	parts := strings.Split("a,b,c", ",")         // разрезать по разделителю
	fmt.Println(parts, len(parts))
	fmt.Println(strings.Join(parts, " + "))      // склеить обратно
}
```

Вывод:

```text
"Привет,   Go и мир"
ПРИВЕТ,   GO И МИР
true
4
[a b c] 3
a + b + c
```

`TrimSpace` убирает пробелы только по краям: три пробела внутри остались. `Fields` режет по любой группе пробелов, поэтому слов четыре, а запятая осталась частью слова `Привет,`. `Split` режет строго по разделителю. Посмотрите, чем они отличаются: `strings.Split("a  b", " ")` даст три части, средняя из которых пустая, а `strings.Fields("a  b")` — две.

Строки неизменяемы, поэтому каждая из этих функций возвращает *новую* строку, а исходная остаётся прежней. Склеивать много кусков через `+` в цикле расточительно: каждый раз создаётся новая строка. Для этого есть `strings.Builder`, он встретится в [главе о пакетах](/go-basics/book/packages-modules).

## Частые ошибки

- **Забыть присвоить результат `append`.** Строка `append(s, x)` без присваивания даже не скомпилируется: `append(s, x) (value of type []int) is not used`. А если присвоить результат другой переменной, исходная `s` не изменит длину. Пишите `s = append(s, x)`.
- **Думать, что подсрез — это копия.** `b := a[1:3]` смотрит на *тот же* бэкинг-массив. Изменение через `b` меняет и `a`. Нужна независимость — используйте `make` + `copy`.
- **Путать «нет ключа» и «значение 0».** `m[key]` для отсутствующего ключа вернёт нулевое значение без всякой ошибки. Чтобы проверить наличие — только comma-ok: `v, ok := m[key]`.
- **Считать `len(строки)` числом символов.** Для текста с кириллицей (и любых не-ASCII символов) `len` вернёт число байтов. Руны считайте через `len([]rune(s))`; видимые символы могут состоять из нескольких рун.
- **Писать в `nil`-карту.** Объявленная, но не созданная карта (`var m map[string]int`) равна `nil`; запись в неё паникует. Создавайте через `make` или литерал.

## Попробуйте сами

Из `[]int{3, -2, 0, 5}` соберите новый срез только положительных чисел и вычислите их сумму. Затем создайте независимую копию исходного среза, замените первый элемент копии на `99` и напечатайте оба среза. Проверьте тот же алгоритм отбора для пустого среза.

### Проверка

Отобраны `[3 5]`, сумма `8`. Исходные данные остались `[3 -2 0 5]`, копия стала `[99 -2 0 5]`.

```go
src := []int{3, -2, 0, 5}
positive := make([]int, 0)
sum := 0
for _, n := range src {
    if n > 0 {
        positive = append(positive, n)
        sum += n
    }
}
dst := make([]int, len(src))
copy(dst, src)
dst[0] = 99
fmt.Println(positive, sum)
fmt.Println(src, dst)
```

Для пустого среза отбор даёт пустой результат и сумму `0`: `range` не выполняет ни одного шага. А вот `dst[0] = 99` для пустого входа упадёт с выходом за границы, поэтому перед ним нужна проверка `len(dst) > 0`. И ещё: присваивание `dst := src` не создаёт независимую копию — проверьте сами, заменив `make` + `copy` на него и посмотрев, что станет с `src`.

## Что дальше

Дальше — [структуры и методы](/go-basics/book/structs): соберём связанные поля в собственный тип и добавим операции над ним. Срезы и карты понадобятся для списков таких значений, а `comma-ok` — чтобы отличать отсутствующую запись от записи с нулевым значением.
