Введение
Одиночные переменные быстро заканчиваются: реальные программы работают с наборами данных — списками пользователей, строками файла, счётчиками по ключам. В Go для этого есть три инструмента: массивы фиксированной длины, слайсы — гибкие динамические списки, и карты (map) — ассоциативные массивы «ключ — значение». На практике 95% времени ты будешь работать со слайсами и картами, а массивы понадобятся редко — но понять массивы важно, потому что слайс устроен поверх массива.
В этой главе мы разберём, чем массив отличается от слайса, что такое длина
(len) и ёмкость (cap), как работает append
и почему он иногда «отвязывает» слайс от исходного массива, как делать срезы и
копии, как читать из карты безопасно с помощью comma-ok, а в конце познакомимся
с современными помощниками из Go 1.21: пакетами slices и
maps и встроенными min, max,
clear.
Массивы: фиксированная длина
Массив объявляется с указанием длины в квадратных скобках:
[5]int — это «пять int-ов». Длина — часть типа, поэтому
[5]int и [6]int — разные типы, и массив
нельзя увеличить. При создании все элементы получают нулевое значение типа.
Именно из-за жёсткой длины массивы редко используют напрямую.
package main
import "fmt"
func main() {
var x [5]int // [0 0 0 0 0]
fmt.Println(x)
fmt.Println(len(x)) // 5
x[2] = 777
fmt.Println(x[2]) // 777
}
Ещё одна особенность, которая удивляет новичков: массив в Go — это значение, а не ссылка. При присваивании или передаче в функцию массив копируется целиком, со всеми элементами. Изменение копии не затрагивает оригинал. Для больших массивов это дорого — ещё одна причина, почему на практике берут слайсы.
a := [3]int{1, 2, 3}
b := a // ПОЛНАЯ копия
b[0] = 100
fmt.Println(a) // [1 2 3] — оригинал не тронут
fmt.Println(b) // [100 2 3]
Слайсы: динамические списки
Слайс — это гибкая «вьюшка» поверх массива. У него нет фиксированной длины в
типе: []int — это слайс int-ов любой длины. Внутри слайс — маленькая
структура из трёх полей: указатель на элемент массива, где начинается слайс,
длина (сколько элементов видно, len) и ёмкость (сколько элементов
помещается в подлежащем массиве до конца, cap). Понимание этой
тройки объясняет всё «странное» поведение слайсов.
package main
import "fmt"
func main() {
// Литерал слайса — без числа в скобках.
nums := []int{10, 20, 30}
fmt.Println(nums) // [10 20 30]
fmt.Println(len(nums)) // 3
fmt.Println(cap(nums)) // 3
fmt.Println(nums[0]) // доступ по индексу: 10
nums[1] = 99
fmt.Println(nums) // [10 99 30]
}
Наглядная модель слайса — «окно» на массив. Представь длинную ленту ячеек
(это подлежащий массив), а слайс — рамку, показывающую часть ленты. Рамка знает
три вещи: с какой ячейки начинается (указатель), сколько ячеек показывает
(len) и сколько ещё ячеек есть справа до конца ленты
(cap). Когда несколько слайсов смотрят на одну ленту, изменение
через одно окно видно через другое — это ключ к пониманию того, почему срезы
«делят» данные, а append иногда ведёт себя неожиданно.
Создание слайса через make
Функция make создаёт слайс с заданными длиной и (необязательно)
ёмкостью: make([]T, len) или make([]T, len, cap).
Первое число — сколько элементов уже есть (заполнены нулями), второе — сколько
зарезервировано в памяти. Задавать ёмкость заранее полезно, когда ты знаешь,
сколько элементов добавишь: это избавляет от лишних перевыделений при
append.
package main
import "fmt"
func main() {
// Длина 3, ёмкость 5: три нуля видны, ещё два места в запасе.
s := make([]int, 3, 5)
fmt.Println(s, len(s), cap(s)) // [0 0 0] 3 5
s[0] = 7
s[1] = 10
s[2] = 15
fmt.Println(s) // [7 10 15]
}
append и рост ёмкости
append добавляет элементы в конец слайса и возвращает новый
слайс — обязательно присваивай результат обратно. Пока в подлежащем массиве
есть запас (len < cap), append дописывает в тот же
массив. Когда запас кончается, Go выделяет новый массив побольше (обычно
удваивает ёмкость), копирует туда данные и возвращает слайс, указывающий уже на
новый массив. Вот почему результат нужно присваивать: старая переменная могла бы
указывать на старый массив.
package main
import "fmt"
func main() {
s := make([]string, 0, 2)
fmt.Println(len(s), cap(s)) // 0 2
s = append(s, "утро")
s = append(s, "день")
fmt.Println(len(s), cap(s)) // 2 2 — запас исчерпан
s = append(s, "вечер") // ёмкости не хватило -> новый массив
fmt.Println(len(s), cap(s)) // 3 4 — ёмкость выросла
fmt.Println(s) // [утро день вечер]
}
Добавление слайса к слайсу
Чтобы дописать в конец один слайс к другому, применяют то же троеточие, что
и у variadic-функций: append(a, b...) раскрывает b в
отдельные элементы. Без троеточия компилятор будет ждать элементы того же типа,
что и элементы слайса, а не другой слайс.
a := []int{1, 2, 3}
b := []int{4, 5, 6}
a = append(a, b...) // раскрываем b
fmt.Println(a) // [1 2 3 4 5 6]
Срезы слайса
Из слайса можно вырезать подслайс синтаксисом s[low:high]:
элементы с индекса low включительно до high
исключительно. Любую границу можно опустить: s[:2] — с начала,
s[3:] — до конца, s[:] — весь. Важнейший момент:
подслайс делит тот же подлежащий массив с оригиналом. Изменишь
элемент через один — увидишь через другой.
package main
import "fmt"
func main() {
week := []string{"пн", "вт", "ср", "чт", "пт"}
fmt.Println(week[1:3]) // [вт ср]
fmt.Println(week[:2]) // [пн вт]
fmt.Println(week[3:]) // [чт пт]
mid := week[1:3]
mid[0] = "ВТОРНИК" // меняем общий массив
fmt.Println(week) // [пн ВТОРНИК ср чт пт]
}
Обход слайсов и карт через range
Перебирать элементы удобнее всего циклом for ... range. Для
слайса он на каждой итерации отдаёт индекс и копию элемента; для карты —
ключ и значение. Раз элемент копируется, менять его прямо в цикле бессмысленно —
надо обращаться по индексу. И ещё: порядок обхода карты не
детерминирован, Go намеренно рандомизирует его, чтобы код не полагался
на случайный порядок. Нужен стабильный вывод — собери ключи в слайс и отсортируй.
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Println(i, v) // индекс и копия значения
}
// Если индекс не нужен — заменяем его на _.
sum := 0
for _, v := range nums {
sum += v
}
fmt.Println(sum) // 60
Независимая копия через copy
Раз срезы делят память, иногда нужна настоящая независимая копия. Её делает
встроенная copy(dst, src): копирует элементы из src в
dst и возвращает число скопированных (минимум из длин). Приёмник
надо заранее создать нужной длины через make.
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)
dst[0] = 100
fmt.Println(n) // 3
fmt.Println(src) // [1 2 3] — оригинал цел
fmt.Println(dst) // [100 2 3]
Удаление элемента из слайса
Отдельной функции удаления по индексу в базовом языке нет — используют
append со срезами: берут часть до элемента и приклеивают часть
после него. Приём стоит знать, хотя в Go 1.21 появился более читаемый
slices.Delete.
s := []string{"пн", "вт", "ср", "чт", "пт"}
i := 2 // удаляем "ср"
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // [пн вт чт пт]
Многомерные слайсы
Слайс может хранить элементы любого типа, в том числе другие слайсы. Так
получаются двумерные структуры — матрицы, сетки, таблицы. Важно понимать, что
это не «прямоугольник» в памяти, а слайс слайсов: каждая строка — самостоятельный
слайс, и строки могут быть разной длины. Создают такие структуры обычно циклом,
инициализируя каждую строку через make.
// Сетка 3x3, заполненная нулями.
grid := make([][]int, 3)
for i := range grid {
grid[i] = make([]int, 3)
}
grid[1][2] = 7
fmt.Println(grid) // [[0 0 0] [0 0 7] [0 0 0]]
Карты: ключ — значение
Карта (map) — это ассоциативный массив: по ключу мгновенно находится значение.
Тип пишется как map[K]V. Карту создают литералом или через
make. Важно: нулевое значение карты — nil, и в
nil-карту нельзя писать (будет паника), поэтому перед записью карту
всегда инициализируют.
package main
import "fmt"
func main() {
// Литерал карты.
ages := map[string]int{
"Аня": 25,
"Боря": 30,
}
ages["Витя"] = 40 // добавление
ages["Аня"] = 26 // обновление
fmt.Println(ages["Аня"]) // 26
fmt.Println(len(ages)) // 3
// Через make, когда данных ещё нет.
scores := make(map[string]int)
scores["тест"] = 100
fmt.Println(scores)
}
Чтение, удаление и comma-ok
Чтение из карты по несуществующему ключу не ошибка — вернётся нулевое
значение типа. Но как отличить «ключа нет» от «ключ есть, а значение равно нулю»?
Для этого есть идиома comma-ok: чтение с двумя переменными,
где вторая — булев флаг «ключ существует». Удаляют пары встроенной
delete.
package main
import "fmt"
func main() {
stock := map[string]int{"хлеб": 0, "молоко": 5}
// Без comma-ok неясно: 0 — это "нет ключа" или "ноль на складе"?
if qty, ok := stock["хлеб"]; ok {
fmt.Println("хлеб есть в каталоге, остаток:", qty) // остаток: 0
}
if _, ok := stock["сыр"]; !ok {
fmt.Println("сыра нет в каталоге")
}
delete(stock, "молоко")
fmt.Println(stock) // map[хлеб:0]
}
Очень частый практический приём — использовать карту как множество
(set): значения нам не важны, важен сам факт наличия ключа. В качестве значения
берут пустую структуру struct{}, которая не занимает памяти, и
проверяют членство через comma-ok.
// Множество посещённых ID: struct{} не тратит память на значение.
seen := make(map[int]struct{})
seen[42] = struct{}{}
seen[7] = struct{}{}
if _, ok := seen[42]; ok {
fmt.Println("42 уже видели")
}
fmt.Println(len(seen)) // 2
Пакеты slices и maps (Go 1.21)
Раньше сортировку, поиск и копирование писали руками или тащили из
сторонних библиотек. В Go 1.21 в стандартную библиотеку добавили пакеты
slices и maps, реализованные на дженериках, — они
работают с любым типом элементов и проверяются на этапе компиляции. Самое
употребительное: slices.Sort, slices.Contains,
slices.Index, slices.SortFunc и для карт —
maps.Clone, maps.Equal.
package main
import (
"fmt"
"slices"
)
func main() {
nums := []int{5, 2, 8, 1, 9}
slices.Sort(nums) // сортировка на месте
fmt.Println(nums) // [1 2 5 8 9]
fmt.Println(slices.Contains(nums, 8)) // true
fmt.Println(slices.Index(nums, 8)) // 3
// SortFunc: свой компаратор возвращает отрицательное/0/положительное.
words := []string{"груша", "ёж", "кот", "абрикос"}
slices.SortFunc(words, func(a, b string) int {
return len(a) - len(b) // по длине
})
fmt.Println(words) // [ёж кот груша абрикос]
}
package main
import (
"fmt"
"maps"
)
func main() {
ages := map[string]int{"Аня": 30, "Боря": 25}
// Clone: поверхностная копия, иначе обе переменные ссылались бы на один map.
clone := maps.Clone(ages)
clone["Аня"] = 99
fmt.Println(ages["Аня"]) // 30 — оригинал цел
fmt.Println(clone["Аня"]) // 99
same := map[string]int{"Аня": 30, "Боря": 25}
fmt.Println(maps.Equal(ages, same)) // true — поэлементное сравнение
}
Встроенные min, max и clear (Go 1.21)
Ещё три новинки Go 1.21 — встроенные функции, не требующие импорта.
min и max принимают два и более аргумента любого
упорядочиваемого типа (числа, строки) и возвращают наименьший/наибольший.
Не путай их с slices.Min/slices.Max, которые берут
один слайс. clear очищает коллекцию: из карты удаляет все пары
(после этого len == 0), а у слайса обнуляет все элементы, но длину
не меняет.
package main
import "fmt"
func main() {
fmt.Println(min(3, 7)) // 3
fmt.Println(max(5, 2, 8, 1)) // 8
// Ограничить значение диапазоном [lo, hi]: clamp(x) = max(lo, min(x, hi)).
lo, hi := 0, 100
clamp := func(x int) int { return max(lo, min(x, hi)) }
fmt.Println(clamp(-20)) // 0
fmt.Println(clamp(150)) // 100
m := map[string]int{"a": 1, "b": 2}
clear(m)
fmt.Println(len(m)) // 0 — все пары удалены
}
Кейс из реального проекта
Соберём типичный сценарий из бэкенда: агрегация логов доступа. На вход
приходит слайс записей о запросах, нужно посчитать число запросов по каждому
IP-адресу, отфильтровать «шумные» адреса и вернуть отсортированный отчёт.
Здесь работают все инструменты главы: карта как счётчик, comma-ok для
безопасного чтения, слайс для сбора результата, slices.SortFunc
для сортировки и append с раскрытием для склейки.
package main
import (
"fmt"
"slices"
)
type Entry struct {
IP string
Path string
}
type Stat struct {
IP string
Count int
}
// aggregate считает запросы по IP и возвращает отчёт, отсортированный по
// убыванию количества; адреса с числом запросов меньше minHits отбрасываются.
func aggregate(entries []Entry, minHits int) []Stat {
counts := make(map[string]int)
for _, e := range entries {
counts[e.IP]++ // nil-безопасно, ведь counts создан через make
}
var report []Stat
for ip, c := range counts {
if c >= minHits {
report = append(report, Stat{IP: ip, Count: c})
}
}
// Сортируем по убыванию Count, при равенстве — по IP для стабильности.
slices.SortFunc(report, func(a, b Stat) int {
if a.Count != b.Count {
return b.Count - a.Count
}
return slices.IndexFunc([]string{a.IP}, func(s string) int { return 0 }) -
slices.IndexFunc([]string{b.IP}, func(s string) int { return 0 })
})
return report
}
func main() {
entries := []Entry{
{"10.0.0.1", "/api"},
{"10.0.0.2", "/login"},
{"10.0.0.1", "/api"},
{"10.0.0.1", "/health"},
{"10.0.0.3", "/api"},
}
for _, s := range aggregate(entries, 2) {
fmt.Printf("%s: %d\n", s.IP, s.Count)
}
// 10.0.0.1: 3
}
Карта здесь — идеальный счётчик: инкремент counts[e.IP]++
работает, даже если ключа ещё не было (читается нулевое значение), потому что
карта создана через make. Дальше мы перекладываем данные в слайс,
чтобы отсортировать, — карты неупорядочены, и полагаться на порядок обхода
нельзя. Это стандартный паттерн «посчитать в карте, отсортировать в слайсе».
Типичные ошибки
append без присваивания результата
Как НЕ надо: писать append(s, x) и ждать, что s
изменится сам. append возвращает новый слайс (возможно, с другим
подлежащим массивом), и если не присвоить результат обратно, добавление
«потеряется». Всегда пиши s = append(s, x).
// Плохо: результат игнорируется, s не растёт.
append(s, 42)
// Хорошо:
s = append(s, 42)
Неожиданное разделение массива при срезе
Как НЕ надо: считать, что sub := s[1:3] — независимая копия.
Подслайс делит память с оригиналом, и изменение через один видно через другой;
хуже того, append в подслайс может затереть элементы оригинала,
если хватает ёмкости. Когда нужна изоляция — делай copy или
slices.Clone.
// Неожиданность: append в подслайс перезаписал элемент оригинала.
s := []int{1, 2, 3, 4}
sub := s[0:2] // len 2, cap 4
sub = append(sub, 99) // пишет в s[2], ведь ёмкости хватает
fmt.Println(s) // [1 2 99 4]
Запись в nil-карту
Как НЕ надо: объявить var m map[string]int и сразу писать
m["k"] = 1. Нулевое значение карты — nil, а запись в
nil-карту роняет программу паникой. Читать из nil-карты
можно (вернётся нуль), а вот писать — нет. Инициализируй через make
или литерал перед первой записью.
// Плохо: паника "assignment to entry in nil map".
var m map[string]int
m["k"] = 1
// Хорошо:
m := make(map[string]int)
m["k"] = 1
Практика
- Напиши функцию
unique(nums []int) []int, которая возвращает слайс без повторов, сохраняя порядок первого появления. Используй карту как множество увиденных значений. - Реализуй
wordCount(text []string) map[string]int— подсчёт частоты слов. Проверь работу на слайсе с повторами. - Сделай функцию
removeAt(s []int, i int) []int, удаляющую элемент по индексу черезappendсо срезами. Что произойдёт при выходе индекса за границы? - Напиши
topN(counts map[string]int, n int) []string: верниnключей с наибольшими значениями. Переложи данные в слайс и отсортируй черезslices.SortFunc. - Продемонстрируй разницу между срезом и
slices.Clone: измени элемент в подслайсе и в клоне, покажи, что оригинал меняется в первом случае и не меняется во втором.
Итог
Мы прошли три способа хранить наборы данных. Массивы имеют фиксированную длину
и на практике встречаются редко, но лежат в основе слайсов. Слайс — это тройка
из указателя, длины и ёмкости поверх массива; понимание этой тройки объясняет
поведение append (рост ёмкости и переезд в новый массив), срезов
(разделяемая память) и необходимость copy для независимых копий.
Карты дают быстрый доступ по ключу, требуют инициализации перед записью и
безопасно читаются через идиому comma-ok. Наконец, Go 1.21 добавил удобные
пакеты slices и maps и встроенные min,
max, clear, которые убирают рутину. Слайсы и карты —
самые частые структуры данных в реальном Go-коде: счётчики, буферы, индексы,
отчёты строятся именно на них.
Комментарии 0
Пока нет комментариев. Станьте первым!