$ sudo teach IT
МОДУЛЬ 3 · УРОК 3.1

Массивы и срезы (slices)

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

До сих пор мы работали с отдельными переменными: одно число, одна строка, один результат. Но в реальной жизни данные приходят группами. Список оценок студента, корзина товаров в магазине, имена участников чата — всё это коллекции однотипных элементов. Создавать для каждого элемента отдельную переменную (grade1, grade2, grade3...) было бы безумием. Именно для этого существуют массивы и срезы. Сегодня мы начинаем Модуль 3 — Структуры данных, и первая тема — самые важные коллекции в Go.

📦 Массив — коллекция фиксированного размера

Массив (array) — это набор элементов одного типа, размер которого задаётся при создании и больше не меняется. Представьте коробку с ячейками: вы заранее решили, что ячеек будет пять, и больше туда не поместится.

Объявляется массив так:

package main

import "fmt"

func main() {
    // Массив из 5 целых чисел
    var grades [5]int

    // Заполняем элементы по индексу (нумерация с 0!)
    grades[0] = 5
    grades[1] = 4
    grades[2] = 3
    grades[3] = 5
    grades[4] = 4

    fmt.Println(grades) // [5 4 3 5 4]
}

Разберём по частям. Запись [5]int означает: массив из 5 элементов типа int. Число в квадратных скобках — это размер. Тип элементов идёт сразу после. Каждый элемент доступен по индексу: grades[0] — первый элемент, grades[4] — последний.

💡 Подсказка: индексация в Go (и почти во всех языках программирования) начинается с нуля. Первый элемент — это [0], второй — [1], и так далее. Массив из 5 элементов имеет индексы от 0 до 4. Попытка обратиться к grades[5] приведёт к ошибке.

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

// Короткая форма — объявление + инициализация
grades := [5]int{5, 4, 3, 5, 4}
fmt.Println(grades) // [5 4 3 5 4]

// Можно указать только часть элементов — остальные будут нулями
partial := [5]int{5, 4}
fmt.Println(partial) // [5 4 0 0 0]

// Массив строк
days := [3]string{"Понедельник", "Вторник", "Среда"}
fmt.Println(days) // [Понедельник Вторник Среда]

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

// Компилятор сам определит размер (будет [4]string)
fruits := [...]string{"яблоко", "банан", "вишня", "груша"}
fmt.Println(fruits)    // [яблоко банан вишня груша]
fmt.Println(len(fruits)) // 4

Функция len() возвращает длину массива — количество элементов в нём.

Важно: размер массива — это часть его типа. [3]int и [5]int — это разные типы! Вы не можете присвоить массив из 3 элементов переменной, которая ожидает массив из 5. Именно эта негибкость привела к появлению срезов.

🤔 Проблема массивов

Массивы в Go используются редко. Почему? Потому что в реальных программах вы почти никогда не знаете заранее, сколько элементов будет. Представьте: вы загружаете список товаров из базы данных. Сегодня их 50, завтра 200, через месяц 1000. Массив с фиксированным размером тут не подходит.

Кроме того, при передаче массива в функцию Go создаёт его полную копию. Если массив большой — это медленно и тратит память. Для решения всех этих проблем в Go есть срезы (slices) — гибкие, динамические коллекции, с которыми вы будете работать постоянно.

🔪 Срез (slice) — динамическая коллекция

Срез — это как массив, но без фиксированного размера. Он может расти по мере необходимости. В 99% случаев в Go вы будете использовать именно срезы, а не массивы.

Самый простой способ создать срез:

package main

import "fmt"

func main() {
    // Объявляем срез (без числа в скобках!)
    grades := []int{5, 4, 3, 5, 4}

    fmt.Println(grades)      // [5 4 3 5 4]
    fmt.Println(len(grades)) // 5

    // Доступ к элементам — точно как в массиве
    fmt.Println(grades[0]) // 5
    fmt.Println(grades[2]) // 3

    // Изменение элемента
    grades[2] = 4
    fmt.Println(grades) // [5 4 4 5 4]
}

Обратите внимание: срез объявляется как []int — пустые квадратные скобки, без числа. Именно отсутствие числа отличает срез от массива. [5]int — массив, []int — срез.

Пустой срез можно объявить и потом наполнять:

// Пустой срез
var numbers []int
fmt.Println(numbers)      // []
fmt.Println(len(numbers)) // 0

🏗 Создание среза через make

Встроенная функция make позволяет создать срез заданной длины, заполненный нулевыми значениями:

package main

import "fmt"

func main() {
    // make(тип, длина)
    scores := make([]int, 5)
    fmt.Println(scores)      // [0 0 0 0 0]
    fmt.Println(len(scores)) // 5

    // Теперь можно записать значения по индексу
    scores[0] = 95
    scores[1] = 87
    scores[2] = 92
    fmt.Println(scores) // [95 87 92 0 0]
}

make принимает тип среза и его начальную длину. Все элементы получают нулевое значение: 0 для чисел, пустую строку для строк, false для булевых.

У make есть и третий аргумент — ёмкость (capacity). Но сначала разберём, что это такое.

📏 Длина (len) и ёмкость (cap)

У каждого среза есть два свойства:

len (длина) — сколько элементов сейчас в срезе.

cap (ёмкость) — сколько элементов может вместить срез без выделения новой памяти.

Представьте себе записную книжку. Вы записали 3 заметки (длина = 3), но в книжке 10 страниц (ёмкость = 10). Пока заметок меньше 10, новые добавляются мгновенно. Когда страницы закончатся, придётся купить новую книжку побольше — Go сделает это автоматически.

package main

import "fmt"

func main() {
    // make(тип, длина, ёмкость)
    s := make([]int, 3, 10)
    fmt.Println(s)       // [0 0 0]
    fmt.Println(len(s))  // 3
    fmt.Println(cap(s))  // 10
}

Здесь мы создали срез длиной 3 и ёмкостью 10. Сейчас в нём 3 элемента, но внутри зарезервировано место под 10. На начальном этапе вам не нужно думать о ёмкости — Go сам её увеличивает. Просто знайте, что она существует.

➕ Добавление элементов: append

Главное преимущество среза перед массивом — можно добавлять элементы. Для этого используется встроенная функция append:

package main

import "fmt"

func main() {
    // Начинаем с пустого среза
    var fruits []string

    // Добавляем элементы по одному
    fruits = append(fruits, "яблоко")
    fruits = append(fruits, "банан")
    fruits = append(fruits, "вишня")

    fmt.Println(fruits)      // [яблоко банан вишня]
    fmt.Println(len(fruits)) // 3

    // Можно добавить сразу несколько
    fruits = append(fruits, "груша", "апельсин", "киви")
    fmt.Println(fruits) // [яблоко банан вишня груша апельсин киви]
    fmt.Println(len(fruits)) // 6
}

💡 Подсказка: обратите внимание, что append не изменяет исходный срез, а возвращает новый. Поэтому мы всегда пишем fruits = append(fruits, ...) — результат присваиваем обратно в ту же переменную. Если забыть присвоение, элемент не добавится!

Можно также добавить один срез к другому с помощью оператора ... (троеточие):

a := []int{1, 2, 3}
b := []int{4, 5, 6}

// Добавляем все элементы b к a
a = append(a, b...)
fmt.Println(a) // [1 2 3 4 5 6]

Троеточие ... после b означает: «распакуй этот срез и передай каждый элемент как отдельный аргумент». Без троеточия код не скомпилируется, потому что append ожидает отдельные элементы, а не срез целиком.

✂️ Срез среза (slicing)

Из любого среза (или массива) можно вырезать подмножество элементов. Для этого используется синтаксис [начало:конец]:

package main

import "fmt"

func main() {
    numbers := []int{10, 20, 30, 40, 50, 60, 70}

    // Элементы с индекса 1 до 4 (не включая 4)
    part := numbers[1:4]
    fmt.Println(part) // [20 30 40]

    // С начала до индекса 3
    first := numbers[:3]
    fmt.Println(first) // [10 20 30]

    // С индекса 4 до конца
    last := numbers[4:]
    fmt.Println(last) // [50 60 70]

    // Полная копия по ссылке
    all := numbers[:]
    fmt.Println(all) // [10 20 30 40 50 60 70]
}

Правило простое: [a:b] берёт элементы от индекса a (включительно) до индекса b (не включая). Если a не указан, он равен 0. Если b не указан, он равен длине среза.

💡 Подсказка: срез среза не создаёт копию данных. Новый срез ссылается на тот же участок памяти. Если вы измените элемент в part, он изменится и в numbers. Это важно помнить! Если нужна независимая копия, используйте функцию copy.

numbers := []int{10, 20, 30, 40, 50}
part := numbers[1:3] // [20 30]

// Изменяем элемент в part
part[0] = 999

// Изменился и в numbers!
fmt.Println(numbers) // [10 999 30 40 50]
fmt.Println(part)    // [999 30]

📋 Копирование среза: copy

Когда нужна независимая копия среза, используйте функцию copy:

package main

import "fmt"

func main() {
    original := []int{10, 20, 30, 40, 50}

    // Создаём новый срез такой же длины
    duplicate := make([]int, len(original))

    // Копируем элементы
    copy(duplicate, original)

    // Теперь изменение одного не влияет на другой
    duplicate[0] = 999
    fmt.Println(original)  // [10 20 30 40 50] — без изменений
    fmt.Println(duplicate) // [999 20 30 40 50]
}

Функция copy(куда, откуда) копирует элементы из одного среза в другой. Она возвращает количество скопированных элементов. Важно: целевой срез (duplicate) должен быть уже создан и иметь достаточную длину — copy не расширяет его автоматически.

src := []int{1, 2, 3, 4, 5}

// Срез длины 3 — скопируются только первые 3 элемента
dst := make([]int, 3)
copied := copy(dst, src)

fmt.Println(dst)    // [1 2 3]
fmt.Println(copied) // 3

🔄 Итерация через for range

Самый удобный способ пройтись по всем элементам среза — цикл for range. На каждой итерации он даёт два значения: индекс элемента и его значение.

package main

import "fmt"

func main() {
    fruits := []string{"яблоко", "банан", "вишня", "груша"}

    for index, value := range fruits {
        fmt.Printf("Индекс: %d, Значение: %s\n", index, value)
    }
}

// Вывод:
// Индекс: 0, Значение: яблоко
// Индекс: 1, Значение: банан
// Индекс: 2, Значение: вишня
// Индекс: 3, Значение: груша

Переменные index и value создаются автоматически на каждом шаге цикла. Вы можете назвать их как угодно: i, v, idx, fruit — на ваш вкус.

Если индекс не нужен, его можно пропустить с помощью символа подчёркивания _:

// Только значения, без индекса
for _, fruit := range fruits {
    fmt.Println(fruit)
}

// Только индексы, без значений
for i := range fruits {
    fmt.Println(i) // 0, 1, 2, 3
}

Символ _ (пустой идентификатор) говорит Go: «Я знаю, что здесь есть значение, но мне оно не нужно». Без него Go выдаст ошибку «переменная объявлена, но не используется».

Конечно, можно пройтись по срезу и обычным for:

// Классический for — тоже работает
for i := 0; i < len(fruits); i++ {
    fmt.Println(fruits[i])
}

Но for range короче, безопаснее (не выйдете за границы массива) и читается лучше. Используйте его по умолчанию.

🧰 Полезные операции со срезами

Несколько типичных приёмов, которые пригодятся на практике:

Удаление элемента по индексу:

package main

import "fmt"

func main() {
    nums := []int{10, 20, 30, 40, 50}

    // Удаляем элемент с индексом 2 (значение 30)
    indexToRemove := 2
    nums = append(nums[:indexToRemove], nums[indexToRemove+1:]...)

    fmt.Println(nums) // [10 20 40 50]
}

Здесь мы берём всё до удаляемого элемента (nums[:2] = [10, 20]) и всё после него (nums[3:] = [40, 50]), а затем соединяем через append.

Проверка, пуст ли срез:

var items []string

if len(items) == 0 {
    fmt.Println("Срез пустой")
}

Подсчёт суммы элементов:

numbers := []int{10, 20, 30, 40, 50}

sum := 0
for _, n := range numbers {
    sum += n
}
fmt.Println("Сумма:", sum) // Сумма: 150

Поиск максимального значения:

numbers := []int{42, 17, 93, 8, 56}

max := numbers[0]
for _, n := range numbers {
    if n > max {
        max = n
    }
}
fmt.Println("Максимум:", max) // Максимум: 93

🛠 Практика: сортировка оценок студента

Давайте соберём все знания воедино и напишем полноценную программу. Задача: у нас есть список оценок студента, нужно отсортировать их, посчитать среднюю оценку и вывести результат.

Для сортировки мы реализуем простейший алгоритм — пузырьковую сортировку. Он не самый быстрый, но зато наглядный и отлично подходит для обучения.

package main

import "fmt"

func main() {
    // Оценки студента (по 5-балльной шкале)
    grades := []int{3, 5, 2, 4, 5, 3, 4, 2, 5, 4}

    fmt.Println("Оценки до сортировки:", grades)
    fmt.Println("Количество оценок:", len(grades))

    // === Пузырьковая сортировка ===
    // Идея: проходим по списку и меняем местами соседние
    // элементы, если они стоят в неправильном порядке.
    // Повторяем, пока всё не встанет на свои места.

    n := len(grades)
    for i := 0; i < n-1; i++ {
        for j := 0; j < n-1-i; j++ {
            if grades[j] > grades[j+1] {
                // Меняем элементы местами
                grades[j], grades[j+1] = grades[j+1], grades[j]
            }
        }
    }

    fmt.Println("Оценки после сортировки:", grades)

    // === Подсчёт средней оценки ===
    sum := 0
    for _, g := range grades {
        sum += g
    }
    average := float64(sum) / float64(len(grades))

    fmt.Printf("Средняя оценка: %.2f\n", average)

    // === Подсчёт каждой оценки ===
    // Создаём срез-счётчик для оценок от 1 до 5
    // Индекс 0 не используем, чтобы оценка совпадала с индексом
    counts := make([]int, 6) // индексы 0-5

    for _, g := range grades {
        counts[g]++
    }

    fmt.Println("\nСтатистика оценок:")
    for grade := 1; grade <= 5; grade++ {
        if counts[grade] > 0 {
            fmt.Printf("  Оценка %d: %d раз(а)\n", grade, counts[grade])
        }
    }

    // === Лучшие и худшие оценки ===
    // После сортировки первый элемент — минимум, последний — максимум
    fmt.Printf("\nМинимальная оценка: %d\n", grades[0])
    fmt.Printf("Максимальная оценка: %d\n", grades[len(grades)-1])
}

Запустите эту программу:

go run main.go

Вывод будет таким:

Оценки до сортировки: [3 5 2 4 5 3 4 2 5 4]
Количество оценок: 10
Оценки после сортировки: [2 2 3 3 4 4 4 5 5 5]
Средняя оценка: 3.70

Статистика оценок:
  Оценка 2: 2 раз(а)
  Оценка 3: 2 раз(а)
  Оценка 4: 3 раз(а)
  Оценка 5: 3 раз(а)

Минимальная оценка: 2
Максимальная оценка: 5

Разберём ключевые моменты:

Пузырьковая сортировка: два вложенных цикла. Внешний (i) определяет, сколько проходов нужно. Внутренний (j) сравнивает соседние элементы и меняет их местами, если левый больше правого. С каждым проходом самый большой элемент «всплывает» в конец — отсюда и название «пузырьковая».

Обмен переменных: строка grades[j], grades[j+1] = grades[j+1], grades[j] — это красивая фишка Go. Можно менять значения двух переменных местами без временной переменной. В других языках пришлось бы писать temp = a; a = b; b = temp.

Средняя оценка: делим сумму на количество. Преобразование float64(sum) необходимо, потому что деление двух целых чисел в Go даёт целое число (без дробной части). Чтобы получить 3.70, а не 3, нужно привести хотя бы одно из чисел к float64.

Подсчёт оценок: трюк с counts[g]++ — используем значение оценки как индекс в срезе-счётчике. Оценка 5 увеличивает counts[5], оценка 3 — counts[3]. Это частый паттерн в программировании.

💡 Подсказка: в реальных проектах для сортировки используют пакет sort из стандартной библиотеки: sort.Ints(grades) — и готово. Но понимать, как сортировка работает изнутри, очень полезно. Мы вернёмся к пакету sort в будущих уроках.

⚖️ Массив vs Срез — когда что использовать

Подведём итог по различиям:

Массив ([N]T):

Фиксированный размер. Размер — часть типа. При передаче в функцию копируется. Используется редко.

Срез ([]T):

Динамический размер. Растёт через append. Передаётся по ссылке (лёгкий). Используется почти всегда.

Простое правило: если не уверены — используйте срез. В 99% случаев это правильный выбор. Массивы нужны только в специфичных ситуациях, например при работе с криптографией или когда размер гарантированно фиксирован (RGB-цвет из 3 компонентов, координаты точки).

📋 Итоги урока

  • Массив ([N]int) — фиксированный размер, задаётся при создании, изменить нельзя
  • Срез ([]int) — динамический размер, может расти и уменьшаться; используется в 99% случаев
  • make([]int, len, cap) — создаёт срез заданной длины и ёмкости
  • append(slice, elem) — добавляет элементы; результат нужно присваивать обратно
  • len() и cap() — длина и ёмкость среза
  • slice[a:b] — вырезает подсрез от индекса a до b (не включая)
  • copy(dst, src) — создаёт независимую копию данных
  • for index, value := range slice — самый удобный способ перебора элементов
  • Мы написали программу сортировки оценок студента с подсчётом статистики

В следующем уроке мы познакомимся с map — словарями (хэш-таблицами). Если срез хранит элементы по числовому индексу (0, 1, 2...), то map позволяет использовать любой ключ: строку, число, что угодно. Хотите найти телефон по имени, цену по названию товара, перевод по слову? Для этого и нужен map. Это одна из самых используемых структур данных в программировании!