Все главы учебника
Содержание учебника

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

19 мин чтенияКонтент v0.12.0

О чём глава

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

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

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

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

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

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

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

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

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

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 — когда нужно заранее задать длину и (по желанию) ёмкость:

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 обратно, потому что срез может «переехать» на новый бэкинг-массив.

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

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:

старт: 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 не включительно:

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.

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

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

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:

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

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

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

Из главы о функциях вы знаете, что аргумент передаётся копией. Срез тоже копируется, но копируется только тройка «указатель, длина, ёмкость». Указатель в копии смотрит на тот же массив. Отсюда два разных результата:

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

Вывод:

[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. Паника — аварийная остановка программы; подробнее о ней в главе об ошибках. Отрицательный индекс-константу вроде s[-1] компилятор не пропустит ещё при сборке.

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

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

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

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

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:

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

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

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

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

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

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

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

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

Вывод:

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

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

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

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

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

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

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

Вывод:

байт 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-й руне или подсчёт рун.
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, в том числе с кириллицей.

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, " + "))      // склеить обратно
}

Вывод:

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

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

Строки неизменяемы, поэтому каждая из этих функций возвращает новую строку, а исходная остаётся прежней. Склеивать много кусков через + в цикле расточительно: каждый раз создаётся новая строка. Для этого есть strings.Builder, он встретится в главе о пакетах.

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

  • Забыть присвоить результат 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].

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.

Что дальше

Дальше — структуры и методы: соберём связанные поля в собственный тип и добавим операции над ним. Срезы и карты понадобятся для списков таких значений, а comma-ok — чтобы отличать отсутствующую запись от записи с нулевым значением.

Продолжить

Остались вопросы?