До сих пор мы работали с одиночными значениями: число, строка, булево. Но настоящие программы почти всегда имеют дело с коллекциями — списком пользователей, счётчиком слов, набором настроек. В Go для этого есть три рабочие лошадки: срезы (упорядоченный список), карты (соответствие ключ → значение) и строки (последовательность байтов с текстом внутри).
Эти три типа — то, что вы будете трогать руками каждый день. Здесь много маленьких ловушек, на которые натыкаются почти все новички: срез внезапно меняется «сам по себе», итерация по карте каждый раз в другом порядке, а len строки с кириллицей выдаёт не то число, что ожидалось. Разберёмся, почему так, — и тогда эти грабли перестанут быть неожиданностью.
Начнём с самого простого типа — массива. Массив в Go — это последовательность элементов одного типа и фиксированной длины, которая зашита в сам тип.
var nums [3]int // [0 0 0] — длина 3, часть типаnums[0] = 10days := [3]string{"пн", "вт", "ср"}
Ключевое слово здесь — «фиксированная». Длина массива — это часть его типа: [3]int и [4]int — два разных типа, их нельзя присвоить друг другу. Размер нельзя поменять во время работы программы. Из-за этого массивы на практике встречаются редко: чаще нужно что-то, что умеет расти. Поэтому массивы почти всегда прячутся под капотом, а в коде вы работаете со срезами — о них и пойдёт основной разговор.
Одна деталь, которая ещё аукнется: массив в Go — значение. Если присвоить массив другой переменной или передать в функцию, копируется весь массив целиком. Срезы, как мы увидим, ведут себя иначе — и именно в этом источник самых частых сюрпризов.
Срез (slice) — это гибкий «вид» на последовательность элементов. В отличие от массива, длина среза не зашита в тип: []int — это срез целых чисел, и в нём может быть хоть ноль, хоть миллион элементов.
primes := []int{2, 3, 5, 7, 11} // литерал среза — без числа в скобкахfmt.Println(primes[0], primes[4]) // 2 11fmt.Println(len(primes)) // 5
Обратите внимание на разницу в записи: [3]int (с числом) — массив, []int (пусто в скобках) — срез. Одна цифра решает всё.
Чтобы понять поведение срезов, держите в голове простую картинку. Срез — это маленькая структура из трёх полей:
указатель на начало данных в каком-то массиве (его называют бэкинг-массив, backing array);
длина (len) — сколько элементов сейчас видно;
ёмкость (cap) — сколько элементов помещается от начала среза до конца бэкинг-массива.
Сам срез данные не хранит — он лишь смотрит на участок массива, который живёт где-то в памяти. Длина — это сколько элементов вы видите прямо сейчас. Ёмкость — это запас: сколько ещё можно дописать, не выделяя новый массив. Эта тройка (указатель, len, cap) и объясняет почти всё «странное» поведение, которое мы дальше разберём.
Срез можно создать литералом, как выше, а можно функцией 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 обратно, потому что срез может «переехать» на новый бэкинг-массив.
Давайте посмотрим вживую, как растёт ёмкость по мере добавления:
package mainimport "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 и 2fmt.Println(s[:2]) // [10 20] — с началаfmt.Println(s[3:]) // [40 50] — до конца
Границы можно опускать: s[:2] — от начала, s[2:] — до конца, s[:] — весь срез целиком. Половинно-открытый интервал (high не включается) поначалу непривычен, но у него приятное свойство: длина результата ровно равна high - low.
А вот и обещанная ловушка. Когда вы делаете подсрез, новый срез не копирует данные — он смотрит на тот же самый бэкинг-массив, что и исходный. Значит, изменив элемент через один срез, вы меняете его и для другого:
package mainimport "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, и запись в неё вызовет панику.
Здесь прячется тонкость. Если запросить отсутствующий ключ, карта не паникует и не возвращает ошибку — она отдаёт нулевое значение типа. Для int это 0. И тогда непонятно: ключа нет — или он есть, но значение и правда ноль?
Чтобы это различать, есть форма с двумя возвращаемыми значениями — её называют comma-ok:
package mainimport "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 mainimport ( "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 сделала порядок случайным специально, чтобы никто случайно не начал на него полагаться.
Строка в Go — это неизменяемая последовательность байтов. Чаще всего эти байты кодируют текст в UTF-8. И вот тут начинается то, что путает почти всех.
В UTF-8 символ может занимать разное число байтов: латинская буква и цифра — один байт, а кириллица, например, — два. Поэтому len(строки) возвращает число байтов, а не символов:
package mainimport "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 mainimport "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(s) — срез байтов. Длина равна len(s). Это «сырое» представление, удобно для работы с UTF-8 на низком уровне.
[]rune(s) — срез рун (символов). Длина равна числу символов. Удобно, когда нужен доступ к i-му символу или подсчёт символов.
package mainimport "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 или литерал.
Срезы, карты и строки — это рабочий инструментарий, к которому вы будете возвращаться постоянно. Главная мысль главы: срез и строка — это «виды» на данные в памяти, поэтому копирование и общий бэкинг-массив требуют внимания, а карта — про быстрый поиск без гарантии порядка.
Дальше эти коллекции естественно соединяются с другими темами: как передавать их в функции (срез и карта передаются «по ссылке на данные», в отличие от массива), как навешивать поведение на собственные типы в главе про структуры и методы, и как аккуратно обрабатывать ситуации «ключа нет» в разделе про ошибки.