До сих пор мы работали с одиночными значениями: число, строка, булево. Но настоящие программы почти всегда имеют дело с наборами данных: список заказов, сколько раз встретилось каждое слово, текст, который ввёл пользователь. В Go для этого есть три типа, которые вы будете использовать каждый день: срезы (упорядоченный список), карты (соответствие ключ → значение) и строки.
У каждого есть ловушка, на которую натыкается почти каждый новичок: срез меняется «сам по себе», обход карты каждый раз идёт в другом порядке, а len("Привет") выдаёт 12. Всё это перестаёт удивлять, когда видишь, как эти типы устроены, поэтому начнём с устройства.
Начнём с самого простого типа — массива. Массив в 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)}
Ёмкость растёт не по одному, а скачками: 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 и 2fmt.Println(s[:2]) // [10 20] — с началаfmt.Println(s[3:]) // [40 50] — до конца
Границы можно опускать: s[:2] — от начала, s[2:] — до конца, s[:] — весь срез целиком. Половинно-открытый интервал (high не включается) поначалу непривычен, но у него приятное свойство: длина результата ровно равна high - low.
А вот и обещанная ловушка. Когда вы делаете подсрез, новый срез не копирует данные — он смотрит на тот же самый бэкинг-массив, что и исходный. Значит, изменив элемент через один срез, вы меняете его и для другого:
Мы тронули 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 mainimport "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, и запись в неё вызовет панику.
Здесь прячется тонкость. Если запросить отсутствующий ключ, карта не паникует и не возвращает ошибку — она отдаёт нулевое значение типа. Для 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]) }}
Вывод всегда один и тот же:
молоко: 70сыр: 300хлеб: 40
Это рабочий приём: если нужен стабильный порядок, соберите ключи в срез, отсортируйте его (sort.Strings) и обходите уже по нему. Сама карта порядок не хранит, она нужна для быстрого поиска. Разработчики Go специально сделали порядок обхода непредсказуемым, чтобы никто не начал на него полагаться. В новом коде вместо sort.Strings(keys) часто пишут slices.Sort(keys) из пакета slices; в браузерном интерпретаторе этот пакет недоступен, поэтому здесь sort.
Карты особенно удобны для подсчёта. Нулевое значение отсутствующего ключа здесь помогает: counts[w]++ для нового слова начинает с 0, и проверка «есть ли ключ» не нужна.
package mainimport "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, меняет ту же самую карту, что и у вызывающего.
Строка в 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; rune — псевдоним int32. Один видимый символ может состоять из нескольких рун: например, буква и комбинируемый знак ударения. Поэтому число рун и число видимых символов не всегда совпадают. Литерал руны пишется в одинарных кавычках: '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: Г (U+0413)байт 2: о (U+043E)байт 4: у (U+0443)
Индексы идут 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] — в символах.
Большая часть работы с текстом — это не байты и руны, а готовые функции пакета strings: обрезать пробелы, разбить на слова, поменять регистр. Они корректно работают с UTF-8, в том числе с кириллицей.
package mainimport ( "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 И МИРtrue4[a b c] 3a + 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 := 0for _, n := range src { if n > 0 { positive = append(positive, n) sum += n }}dst := make([]int, len(src))copy(dst, src)dst[0] = 99fmt.Println(positive, sum)fmt.Println(src, dst)
Для пустого среза отбор даёт пустой результат и сумму 0: range не выполняет ни одного шага. А вот dst[0] = 99 для пустого входа упадёт с выходом за границы, поэтому перед ним нужна проверка len(dst) > 0. И ещё: присваивание dst := src не создаёт независимую копию — проверьте сами, заменив make + copy на него и посмотрев, что станет с src.
Дальше — структуры и методы: соберём связанные поля в собственный тип и добавим операции над ним. Срезы и карты понадобятся для списков таких значений, а comma-ok — чтобы отличать отсутствующую запись от записи с нулевым значением.