GraphLMS

Основы
Начать
Глава 06 · Основы~16 мин чтения

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

О чём глава

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

Эти три типа — то, что вы будете трогать руками каждый день. Здесь много маленьких ловушек, на которые натыкаются почти все новички: срез внезапно меняется «сам по себе», итерация по карте каждый раз в другом порядке, а len строки с кириллицей выдаёт не то число, что ожидалось. Разберёмся, почему так, — и тогда эти грабли перестанут быть неожиданностью.

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

Начнём с самого простого типа — массива. Массив в 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 выделяет массив побольше, копирует туда старые данные и продолжает. Это и есть причина правила «присваивай результат обратно»: после такого «переезда» старая переменная указывала бы на старый массив, а новые данные оказались бы в другом месте. Точные числа ёмкости — деталь реализации, на них не стоит закладываться, но сам факт скачкообразного роста полезно понимать.

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

От уже существующего среза (или массива) можно взять «подсрез» с помощью выражения 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 — потому что под капотом это одна и та же ячейка памяти. Это не баг, а прямое следствие того, что срез — лишь «вид» на массив. Но если про это не знать, отладка может занять час.

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

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

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

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

Карта (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])
	}
}

Этот пример заодно показывает рабочий приём: если нужен стабильный порядок вывода карты, соберите ключи в срез, отсортируйте его (sort.Strings) и обходите уже по нему. Сама по себе карта порядок не хранит — она про быстрый поиск, а не про последовательность. Команда Go сделала порядок случайным специально, чтобы никто случайно не начал на него полагаться.

Строки и руны: внутри живёт 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 не подходит — нужен другой подход.

Руна — это один символ

Чтобы говорить о символах, а не байтах, в Go есть тип rune. Руна — это один символ Unicode (по сути, целое число — его код). Литерал руны пишется в одинарных кавычках: '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, 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] — в символах.

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

  • Забыть присвоить результат append. Писать append(s, x) без s = ... бессмысленно: при росте ёмкости append возвращает новый срез, а старая переменная остаётся со старыми данными. Всегда 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 или литерал.

Что дальше

Срезы, карты и строки — это рабочий инструментарий, к которому вы будете возвращаться постоянно. Главная мысль главы: срез и строка — это «виды» на данные в памяти, поэтому копирование и общий бэкинг-массив требуют внимания, а карта — про быстрый поиск без гарантии порядка.

Дальше эти коллекции естественно соединяются с другими темами: как передавать их в функции (срез и карта передаются «по ссылке на данные», в отличие от массива), как навешивать поведение на собственные типы в главе про структуры и методы, и как аккуратно обрабатывать ситуации «ключа нет» в разделе про ошибки.