Глава 6. Массивы, слайсы и карты

294 просмотров
0 лайков
0 в избранном

Введение

Одиночные переменные быстро заканчиваются: реальные программы работают с наборами данных — списками пользователей, строками файла, счётчиками по ключам. В 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

Практика

  1. Напиши функцию unique(nums []int) []int, которая возвращает слайс без повторов, сохраняя порядок первого появления. Используй карту как множество увиденных значений.
  2. Реализуй wordCount(text []string) map[string]int — подсчёт частоты слов. Проверь работу на слайсе с повторами.
  3. Сделай функцию removeAt(s []int, i int) []int, удаляющую элемент по индексу через append со срезами. Что произойдёт при выходе индекса за границы?
  4. Напиши topN(counts map[string]int, n int) []string: верни n ключей с наибольшими значениями. Переложи данные в слайс и отсортируй через slices.SortFunc.
  5. Продемонстрируй разницу между срезом и slices.Clone: измени элемент в подслайсе и в клоне, покажи, что оригинал меняется в первом случае и не меняется во втором.

Итог

Мы прошли три способа хранить наборы данных. Массивы имеют фиксированную длину и на практике встречаются редко, но лежат в основе слайсов. Слайс — это тройка из указателя, длины и ёмкости поверх массива; понимание этой тройки объясняет поведение append (рост ёмкости и переезд в новый массив), срезов (разделяемая память) и необходимость copy для независимых копий. Карты дают быстрый доступ по ключу, требуют инициализации перед записью и безопасно читаются через идиому comma-ok. Наконец, Go 1.21 добавил удобные пакеты slices и maps и встроенные min, max, clear, которые убирают рутину. Слайсы и карты — самые частые структуры данных в реальном Go-коде: счётчики, буферы, индексы, отчёты строятся именно на них.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!