Create your own
Lesson illustration

Массивы, срезы и map: выбор и применение

Добрый день. В прошлом уроке вы восстановили функции: несколько возвращаемых значений, callback-функции и разделение логики по небольшим ответственностям. Теперь функциям нужны реальные наборы данных. В backend-разработке это будут списки задач, результаты SQL-запросов, наборы идентификаторов, настройки и кэши данных по ключу.

Сегодня вы разберёте три базовых контейнера Go и научитесь выбирать подходящий:

  • массив — последовательность строго известного размера;
  • срез (slice) — последовательность переменной длины;
  • map — соответствие ключей значениям.

Главная цель — не просто запомнить синтаксис, а уметь объяснить, почему в конкретном коде нужен именно этот тип.


Модель выбора: порядок, размер или ключ

Все три типа хранят несколько значений, но отвечают на разные вопросы.

Если нужно…Обычно выбирайтеПример
Хранить ровно фиксированное число элементовМассивТри уровня приоритета, 32 байта хеша
Хранить упорядоченный список, который может расти или сокращатьсяСрезЗадачи проекта, полученные из БД
Находить значение по уникальному ключуmapПользователь по userID, счётчик статусов
Хранить только уникальные значенияmap[T]struct{}Набор ID участников проекта

В прикладном Go-коде чаще всего встречаются срезы и map. Массивы важны не потому, что вы будете постоянно их создавать, а потому, что срезы опираются на массивы внутри. Также массив подходит, когда размер является частью контракта программы.


Массивы: размер входит в тип

Массив записывается как [N]T, где N — число элементов, а T — их тип:

var retryDelays [3]int

retryDelays[0] = 1
retryDelays[1] = 3
retryDelays[2] = 5

fmt.Println(retryDelays) // [1 3 5]

Индекс начинается с нуля. Для массива из трёх элементов допустимы только индексы 0, 1 и 2.

Размер массива — не просто свойство значения, а часть его типа:

var short [2]string
var long [3]string

// short = long // compilation error: [2]string and [3]string are different types

Можно объявить массив литералом:

priorities := [3]string{"low", "medium", "high"}

Или разрешить компилятору посчитать элементы:

priorities := [...]string{"low", "medium", "high"}

В обоих случаях типом будет [3]string.

Массивы копируются целиком

Массив — значение. Присваивание создаёт независимую копию всех элементов:

original := [3]string{"todo", "in_progress", "done"}
copyOfOriginal := original

copyOfOriginal[0] = "backlog"

fmt.Println(original)       // [todo in_progress done]
fmt.Println(copyOfOriginal) // [backlog in_progress done]

Это иногда полезно: например, при работе с небольшой неизменяемой таблицей фиксированного размера. Но для передаваемых между слоями приложения коллекций задач массив обычно слишком негибок: количество задач заранее неизвестно. Поэтому API и прикладной код почти всегда принимают []T, а не [N]T.

Go Slices: usage and internals

Прочитайте материал Go Slices: usage and internals из официального Go Blog, Andrew Gerrand. Он связывает три идеи, которые легко спутать на старте: массив как значение, срез как описание части массива и рост среза через append.

В разделе Arrays прочитайте основу про массивы: обратите внимание, что длина входит в тип и присваивание копирует массив. Затем в разделе Slices найдите абзац, который начинается словами тип среза, и дочитайте раздел до подраздела Slice internals. Зафиксируйте синтаксис []T, создание через литерал и make, а также полуоткрытые границы в выражении s[low:high]. В подразделе Slice internals изучите представление среза. После него прочитайте фрагмент от изменения через общий массив: он объясняет, почему два среза могут неожиданно видеть изменения друг друга. В разделе Growing slices (the copy and append functions) прочитайте от рост среза до примеров со встроенным append. Главный практический вывод: результат append нужно присваивать обратно.


Срезы: рабочая коллекция backend-кода

Срез имеет тип []T, без фиксированной длины:

taskTitles := []string{
	"Design API",
	"Write tests",
	"Deploy demo",
}

У среза есть:

  • длина — число элементов, доступных сейчас через len;
  • ёмкость — сколько элементов может поместиться в текущем базовом массиве до необходимости выделить новый, через cap.
tasks := make([]string, 0, 5)

fmt.Println(len(tasks)) // 0
fmt.Println(cap(tasks)) // 5

Здесь tasks пока пуст, но программа заранее зарезервировала место для пяти строк. Это бывает уместно, если размер примерно известен: например, вы собираете список результатов из входного набора. Однако не стоит добавлять make(..., 0, N) везде без причины: обычный литерал или var часто лучше читается.

Slice header: почему срез — не массив

Концептуально срез содержит небольшой заголовок из трёх частей:

  1. указатель на первый доступный элемент базового массива;
  2. длину;
  3. ёмкость.
Заголовок среза `[]byte` содержит ссылку на базовый массив, длину 5 и ёмкость 5; сам срез не хранит пять байтов внутри себя, а описывает участок массива.

Поэтому присваивание среза не копирует все его элементы. Копируется заголовок, и обе переменные первоначально смотрят на общие данные:

allTasks := []string{
	"Design API",
	"Write tests",
	"Deploy demo",
}

todayTasks := allTasks[:2]
todayTasks[1] = "Write table-driven tests"

fmt.Println(allTasks)
// [Design API Write table-driven tests Deploy demo]

Выражение allTasks[:2] создаёт срез с элементами по индексам 0 и 1. Правая граница не включается. В общем виде в s[low:high] доступны элементы с индексами от low включительно до high исключительно.

Это удобно и эффективно, но требует дисциплины: если вы передаёте срез в функцию и меняете items[i], вызывающий код обычно увидит это изменение.

func markFirstTask(tasks []string) {
	if len(tasks) == 0 {
		return
	}

	tasks[0] = "[urgent] " + tasks[0]
}

Функция получает копию заголовка среза, но элементы остаются общими с исходным срезом.

Добавление элементов через append

Чтобы добавить элемент в конец среза, используйте append:

tasks := []string{"Design API", "Write tests"}

tasks = append(tasks, "Deploy demo")
tasks = append(tasks, "Prepare README")

fmt.Println(tasks)
// [Design API Write tests Deploy demo Prepare README]

Обязательное правило: сохраняйте результат append.

tasks = append(tasks, "Deploy demo")

Когда свободной ёмкости недостаточно, Go выделяет новый, более крупный базовый массив, копирует туда старые элементы и возвращает новый заголовок среза. Если не присвоить его обратно, переменная останется со старым заголовком и новая длина будет потеряна.

Чтобы добавить содержимое одного среза в другой, используйте ...:

baseTasks := []string{"Design API"}
extraTasks := []string{"Write tests", "Deploy demo"}

baseTasks = append(baseTasks, extraTasks...)

Когда нужно именно копирование

Если полученный фрагмент должен жить независимо от большого исходного списка, создайте новый срез и скопируйте данные:

allTasks := []string{
	"Design API",
	"Write tests",
	"Deploy demo",
	"Prepare README",
}

selected := allTasks[:2]

independent := make([]string, len(selected))
copy(independent, selected)

independent[0] = "Different task"

fmt.Println(allTasks[0])    // Design API
fmt.Println(independent[0]) // Different task

copy копирует минимум из длины источника и назначения и возвращает число скопированных элементов. В данном примере independent имеет собственный базовый массив, поэтому изменение не затрагивает allTasks.

Нулевое значение среза

Нулевое значение среза — nil:

var taskIDs []int64

fmt.Println(taskIDs == nil) // true
fmt.Println(len(taskIDs))   // 0

taskIDs = append(taskIDs, 42)
taskIDs = append(taskIDs, 99)

Для nil-среза безопасны len, cap, range и append. Это позволяет накапливать результаты без лишней инициализации:

func activeTaskTitles(tasks []string) []string {
	var active []string

	for _, task := range tasks {
		if task != "done" {
			active = append(active, task)
		}
	}

	return active
}

Позже, при работе с JSON API, станет важным различие между nil и пустым, но не-nil срезом. Пока достаточно помнить: для обхода и добавления элементов оба варианта работают корректно.


map: доступ к данным по ключу

map связывает ключ со значением. Например, ID пользователя можно связать с именем:

owners := map[int64]string{
	42: "Anna",
	77: "Mikhail",
}

Тип записывается как map[KeyType]ValueType:

map[int64]string
map[string]int
map[string]bool

В отличие от среза, у map нет числовых индексов и гарантированного порядка. Ключом может быть сравнимый тип: например, string, int, int64, указатель, массив или подходящая структура. Срез, map и функция ключами быть не могут.

scores := make(map[string]int)

scores["anna"] = 10
scores["mikhail"] = 15
scores["anna"] = 20 // обновляет прежнее значение

fmt.Println(scores["anna"]) // 20

На практике map стоит выбирать, если вопрос звучит так: «как быстро найти данные по конкретному ключу?» Например:

  • имя пользователя по userID;
  • настройка по имени;
  • число задач по статусу;
  • набор разрешённых ID;
  • временный кэш результатов вычисления.

Изучаем Go. Урок №18. Мапы (maps) #golang

Посмотрите фрагменты видео «Изучаем Go. Урок №18. Мапы (maps) #golang» от ThisIsIT. Видео закрепит синтаксис создания map, чтения, записи и проверку наличия ключа — последняя особенно важна в обработчиках и прикладной логике.

Начните с создания map: обратите внимание на разницу между var, make и литералом, а также на то, почему make и литерал подходят для записи. Далее посмотрите запись и обновление, затем проверку ключа. Зафиксируйте двухзначное получение value, ok := m[key]: оно отличает отсутствующий ключ от реально сохранённого нулевого значения. Завершите удалением и обходом map. Не рассчитывайте на порядок результатов range.

Инициализация и nil map

Как и срез, map имеет нулевое значение nil:

var taskCount map[string]int

fmt.Println(taskCount == nil) // true
fmt.Println(taskCount["done"]) // 0

Чтение из nil map безопасно и возвращает нулевое значение типа элемента. Но записывать в неё нельзя:

var taskCount map[string]int

// taskCount["done"]++
// panic: assignment to entry in nil map

Для записи и обновления используйте make или литерал:

taskCount := make(map[string]int)

taskCount["todo"]++
taskCount["in_progress"]++
taskCount["in_progress"]++

fmt.Println(taskCount["in_progress"]) // 2

Здесь taskCount["todo"] до первого увеличения логически равно 0, поэтому ++ сразу создаёт запись со значением 1. Это делает map[string]int естественным инструментом для подсчёта.

Можно заранее передать make ожидаемый размер:

taskCount := make(map[string]int, 4)

Число 4 — лишь подсказка для начального выделения памяти. Это не ограничение: в map можно добавить и пятую, и сотую запись. В отличие от среза, у map нет cap.

Проверяйте наличие ключа

Если ключ отсутствует, обращение к map вернёт нулевое значение типа элемента:

owners := map[int64]string{
	42: "Anna",
}

fmt.Println(owners[77]) // пустая строка

Но пустая строка может быть и реальным сохранённым значением. Поэтому используйте двухзначную форму доступа:

ownerName, ok := owners[77]
if !ok {
	fmt.Println("Owner not found")
	return
}

fmt.Println(ownerName)

Это продолжает тему прошлого урока: выражение возвращает несколько связанных значений. Первое — данные, второе — признак их наличия.

Удаление и обход

Удаление выполняется встроенной функцией delete:

owners := map[int64]string{
	42: "Anna",
	77: "Mikhail",
}

delete(owners, 77)
delete(owners, 999) // безопасно, даже если ключа нет

Для обхода используйте range:

for id, name := range owners {
	fmt.Printf("Owner %d: %s\n", id, name)
}

Однако порядок обхода map не гарантирован. Нельзя использовать его для выдачи API, где нужен стабильный порядок задач, либо для тестов, сравнивающих строки вывода. Если порядок важен, обычно данные переносят в срез и сортируют; сортировку вы примените позднее при реализации REST-выдачи задач.


Собираем типы в небольшую программу

В следующем примере каждый контейнер выражает свою роль:

  • [3]string хранит фиксированный набор обязательных этапов;
  • []string хранит список задач, который может расти;
  • map[string]int считает задачи по статусам.
package main

import "fmt"

func countStatuses(statuses []string) map[string]int {
	counts := make(map[string]int, len(statuses))

	for _, status := range statuses {
		counts[status]++
	}

	return counts
}

func main() {
	requiredStages := [3]string{
		"design",
		"implementation",
		"review",
	}

	taskTitles := []string{
		"Design API",
		"Write unit tests",
		"Prepare README",
	}

	// Берём первые две задачи. Это новый заголовок среза,
	// но элементы пока общие с taskTitles.
	todayTasks := taskTitles[:2]
	todayTasks[1] = "Write table-driven tests"

	// Список задач может расти.
	taskTitles = append(taskTitles, "Deploy demo")

	statuses := []string{
		"in_progress",
		"done",
		"in_progress",
		"todo",
	}

	statusCounts := countStatuses(statuses)

	fmt.Println("Required stages:", requiredStages)
	fmt.Println("All tasks:", taskTitles)
	fmt.Println("Tasks for today:", todayTasks)

	if doneCount, ok := statusCounts["done"]; ok {
		fmt.Println("Done tasks:", doneCount)
	}

	fmt.Println("In progress:", statusCounts["in_progress"])
}

Запустите программу из корня модуля:

go fmt ./...
go test ./...
go run .

Обратите внимание на два решения в коде:

  1. requiredStages — массив, потому что здесь зафиксирован контракт из трёх этапов.
  2. taskTitles — срез, потому что число задач меняется.
  3. statusCountsmap, потому что статус выступает ключом, а количество — значением.

В реальном сервисе задача вскоре станет структурой с полями ID, Title, Status и AssigneeID. Но выбор контейнера останется тем же: обычно вы получите []Task, а для частого поиска построите map[int64]Task или map[int64]*Task, исходя из нужной семантики.


Типичные ошибки, которые полезно узнавать на ревью

Ожидать, что срез копирует элементы при присваивании.

a := []int{1, 2, 3}
b := a
b[0] = 99

fmt.Println(a) // [99 2 3]

Если нужна независимая коллекция, используйте copy.

Забыть присвоить результат append.

tasks := []string{"Design API"}

append(tasks, "Write tests") // результат проигнорирован
fmt.Println(tasks)           // [Design API]

Правильно:

tasks = append(tasks, "Write tests")

Писать в неинициализированную map.

var counts map[string]int

// counts["todo"] = 1 // panic

Правильно:

counts := make(map[string]int)
counts["todo"] = 1

Считать нулевое значение доказательством отсутствия ключа.

priority, ok := map[string]int{
	"normal": 0,
}["normal"]

fmt.Println(priority, ok) // 0 true

Значение 0 само по себе ничего не говорит о наличии ключа; этот факт даёт только ok.

Полагаться на порядок range по map.

for status := range statusCounts {
	fmt.Println(status)
}

Порядок может отличаться между запусками. Для упорядоченной выдачи используйте срез.


Итоги

Теперь у вас есть практическая схема выбора базовых коллекций Go:

  • массив [N]T имеет фиксированный размер, причём размер входит в тип; присваивание копирует элементы;
  • срез []T подходит для упорядоченных данных переменной длины; его заголовок ссылается на базовый массив;
  • срезы могут разделять элементы после присваивания или срезания, поэтому копирование нужно делать явно через copy;
  • append может вернуть новый срез, поэтому его результат всегда нужно сохранять;
  • map map[K]V хранит значения по ключу, требует инициализации перед записью и не гарантирует порядок обхода;
  • value, ok := m[key] отличает отсутствующий ключ от нулевого значения;
  • map[T]struct{} выражает множество уникальных значений.

В следующем уроке вы перейдёте от простых строк и чисел к структурам, методам и указателям. Это позволит описывать задачу как полноценную доменную сущность, а не как набор несвязанных значений.

Can't find a good explanation? Sign up and we'll make it for you

Sign up