JavaRush /Курсы /Go SELF /Повтор: слайсы/мапы и типовые ошибки

Повтор: слайсы/мапы и типовые ошибки

Go SELF
20 уровень , 0 лекция
Открыта

1. Вспоминаем слайсы: как они устроены и где ломаются

Когда вы только начинаете, легко попасть в ловушку: «ну оно же вывело правильный ответ на двух тестах — значит, всё ок». Слайсы и мапы особенно коварны тем, что их ошибки часто не проявляются сразу: баг может появиться только на других данных, в другой ветке кода или после небольшого рефакторинга. Повторение здесь нужно не ради скучной теории, а чтобы вы научились предсказывать поведение программы.

Полезная мысль дня: слайсы и мапы — это не просто «контейнеры», а контейнеры с правилами. Если вы эти правила держите в голове, вы пишете код быстрее и отлаживаете его спокойнее. Если не держите — начинаются шаманские танцы: «почему у меня поменялся исходный слайс?», «почему вывод мапы каждый раз разный?», «почему append иногда ломает данные?».

Модель слайса: «окно» в массив

Слайс в Go стоит представлять не как «список», а как окно, через которое мы смотрим на кусок массива. Это окно описывается тремя числами: где начинаются данные (указатель), сколько элементов мы считаем «видимыми» (len) и сколько можем «дорастить» без переезда (cap). Именно из-за этой модели append иногда безопасен, а иногда — как ремонт в квартире: вроде «чуть-чуть подклею», а в итоге переезд.

Слайсы могут делить один массив, перекрываться и влиять друг на друга. Внутреннее устройство слайса как «pointer/len/cap» — ключ к пониманию почти всех странностей с append, под‑слайсами и копированием. Это тот самый случай, когда одна простая картинка экономит вам часы отладки.

Можно представить так:

flowchart LR
    A[Underlying array] --> B[Slice header]
    B --> P[pointer -> start index]
    B --> L[len]
    B --> C[cap]

nil-слайс и пустой слайс: одинаковые по длине, разные по смыслу

Тема nil vs empty кажется мелочью, пока вы не начинаете писать аккуратные проверки и не сталкиваетесь с «почему == nil ложно?». В Go допустимы оба варианта: var s []int (это nil) и s := []int{} (это пустой, но не nil). В большинстве прикладных задач они ведут себя одинаково: len будет 0, range ничего не сделает, append будет работать. Но иногда различие важно как сигнал смысла: «значение отсутствует» против «значение есть, но пустое».

Посмотрим маленький пример и заодно закрепим, что append умеет работать с nil-слайсом (и это нормально):

package main

import "fmt"

func main() {
	var a []int  // nil
	b := []int{} // empty

	fmt.Println(a == nil, len(a), cap(a)) // true 0 0
	fmt.Println(b == nil, len(b), cap(b)) // false 0 0

	a = append(a, 10)
	b = append(b, 10)

	fmt.Println(a) // [10]
	fmt.Println(b) // [10]
}

Для закрепления — компактная табличка:

Свойство
var s []T
(nil)
s := []T{}
(empty)
len(s)
0
0
range s
0 итераций 0 итераций
append(s, x)
работает работает
s == nil
true
false
Смысл «не задано» «задано, но пусто»

append: почему результат нужно сохранять

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

Вам не нужно запоминать внутренние алгоритмы роста, но нужно запомнить поведенческий контракт: «после append старый слайс может стать не тем, чем был». Иногда он будет указывать на тот же массив, иногда — на новый. И именно поэтому код, который «вроде работал», начинает ломаться после маленького изменения, например добавления ещё одного append где-то рядом.

Мини‑пример на внимательность:

package main

import "fmt"

func main() {
	s := make([]int, 0, 2)
	s = append(s, 1)
	s = append(s, 2)

	t := append(s, 3) // важный момент: сохраняем в t
	fmt.Println("s:", s) // s: [1 2]
	fmt.Println("t:", t) // t: [1 2 3]
}

Если написать просто append(s, 3), то слайс s мог бы остаться длины 2, а мог бы «как-то» поменяться — и вы бы получили трудно объяснимую картину.

Под‑слайсы и aliasing: «почему изменился исходник?!»

Под‑слайсы (например, sub := base[a:b]) — это удобнейшая вещь… пока вы не начинаете их изменять. Главная идея: под‑слайс почти всегда делит underlying array с базовым слайсом. Это означает, что запись в элементы под‑слайса меняет исходный массив. Это ожидаемо, когда вы делаете sub[0] = 99, но гораздо менее ожидаемо, когда вы делаете append(sub, ...) и внезапно меняется «хвост» base.

Вот пример, который полезно прогнать глазами много раз:

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}

	sub := base[1:3]      // [2 3]
	sub = append(sub, 99) // может затронуть base

	fmt.Println("base:", base)
	fmt.Println("sub :", sub)
}

Почему «может»? Потому что всё упирается в cap(sub). Если cap позволяет «дописать» в тот же массив, Go допишет туда — и перезапишет элементы базового массива после sub-окна. Это поведение полностью логично, если помнить про header с cap.

Full slice expression s[a:b:c]: официальный способ поставить забор

Full slice expression выглядит как магический трюк, но по смыслу это просто более точное описание окна: вы задаёте не только len, но и максимальную ёмкость (cap) для полученного под‑слайса. Идея простая: «вот вам видимая часть a:b, и пожалуйста, не лезьте append-ом дальше c в исходный массив».

С практической точки зрения это хороший инструмент защиты от случайного aliasing: если вы собираетесь делать append к под‑слайсу и не хотите менять исходник, ограничьте cap, чтобы append был вынужден выделить новый массив.

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}

	sub := base[1:3:3]    // len=2, cap=2
	sub = append(sub, 99) // cap не хватит -> новый массив

	fmt.Println("base:", base) // base: [1 2 3 4]
	fmt.Println("sub :", sub)  // sub : [2 3 99]
}

copy как граница владения: «теперь это моё»

Когда вы понимаете, что слайсы могут делить данные, следующий логичный шаг — научиться делать явную копию, когда это нужно. В Go копия делается не присваиванием (оно копирует только header), а созданием нового слайса и copy. Это полезно, когда вы хотите сохранить «снимок» данных, который не изменится, даже если исходный слайс потом будут модифицировать.

Классическая форма:

package main

import "fmt"

func main() {
	orig := []string{"a", "b", "c"}

	clone := make([]string, len(orig))
	copy(clone, orig)

	orig[0] = "X"
	fmt.Println(orig)  // [X b c]
	fmt.Println(clone) // [a b c]
}

В этом месте у многих появляется вопрос: «а почему нельзя просто clone := orig?». Потому что это будет второй header на тот же массив, то есть снова aliasing, только более скрытый.

Удаление из слайса и «хвост»: почему иногда стоит чистить

Удаление элементов из середины слайса — тема, где легко ошибиться на границах, а ещё легче — забыть, что underlying array может всё ещё держать ссылки на «удалённые» элементы за пределами len. На уровне «модель в голове» полезно помнить: длина уменьшилась, но память массива может хранить старые значения, пока вы их не занулите (важно для слайсов указателей, строк, других слайсов и т.п.).

В современных версиях Go стандартный пакет slices берёт часть этой работы на себя: некоторые операции (например slices.Delete) очищают «хвост», чтобы случайно не удерживать память.

Но ключевой навык остаётся прежним: если функция возвращает новый слайс — вы должны его использовать.

package main

import (
	"fmt"
	"slices"
)

func main() {
	s := []int{10, 20, 30, 40}
	s = slices.Delete(s, 1, 3) // удалили элементы с индексами 1..2

	fmt.Println(s) // [10 40]
}

Если написать slices.Delete(s, 1, 3) и не присвоить результат, вы получите «странный» слайс: длина старая, содержимое частично сдвинуто — и дальше начинается классический монолог «ну Go точно издевается».

3. Мапы: модель и практические правила

Что гарантируется, а что — нет

Мапа в Go — это структура «ключ → значение», и она отлично подходит для быстрых проверок наличия, подсчётов, set‑паттернов и прочих практичных вещей. Но у мапы есть два правила, которые важно держать в голове постоянно: во‑первых, nil‑map нельзя модифицировать (запись вызовет panic), а во‑вторых, порядок обхода range по map не является контрактом.

Это не «особенность конкретной реализации», а сознательное правило: код не должен опираться на порядок, иначе вы получите нестабильное поведение. В реальной разработке нестабильный вывод — это боль: тесты начинают флапать, диффы в логах становятся бессмысленными, а пользователи задают вопросы из серии «почему список прыгает местами?».

nil‑map: читать можно, писать нельзя

nil‑map — это как пустая тетрадка, на которую вы смотрите через витрину: читать заголовки можно (их всё равно нет), но писать нельзя. Зато range по nil‑map безопасен — просто не будет итераций.

package main

import "fmt"

func main() {
	var m map[string]int // nil map

	fmt.Println(m["a"]) // 0 (zero value), но ключа нет

	// m["a"] = 1 // panic: assignment to entry in nil map

	m = make(map[string]int)
	m["a"] = 1
	fmt.Println(m["a"]) // 1
}

Здесь особенно важно не путаться с «zero value из мапы» и «ключ отсутствует». Поэтому следующая часть — про ok‑идиому.

v, ok := m[k]: как отличить «нет ключа» от zero value

Если значение типа V имеет разумный zero value (например, 0 для int), то простое чтение m[k] не позволяет понять: там реально было 0, или ключа не было. Именно поэтому идиома v, ok := m[k] — не просто «фишка языка», а реальная защита от ошибок логики.

package main

import "fmt"

func main() {
	m := map[string]int{"a": 0}

	v1, ok1 := m["a"]
	v2, ok2 := m["b"]

	fmt.Println(v1, ok1) // 0 true
	fmt.Println(v2, ok2) // 0 false
}

В прикладном коде это спасает от неприятных багов, когда программа «как будто видит значение», но на самом деле ключ отсутствует, а вы просто получили zero value.

Порядок range по map и стабильный вывод: сортируем ключи

Когда вы делаете вывод данных из мапы (в консоль, в отчёт, в тест), почти всегда хочется, чтобы порядок был одинаковым. Go этого не гарантирует, поэтому мы делаем простой протокол: собрали ключи в слайс → отсортировали → печатаем по порядку. В Go есть удобные инструменты для сортировки слайсов; в стандартной библиотеке есть пакет slices с сортировками и утилитами.

Мини‑пример:

package main

import (
	"fmt"
	"slices"
)

func main() {
	m := map[string]int{"b": 2, "a": 1, "c": 3}

	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	slices.Sort(keys)

	for _, k := range keys {
		fmt.Println(k, m[k])
	}
}

Этот паттерн стоит воспринимать как «норму», а не как «когда-нибудь потом оптимизируем». Оптимизация здесь почти никогда не нужна: сортировка ключей обычно стоит дешевле, чем ваше время на разбор нестабильных результатов.

4. Мини‑приложение «Список покупок»

Соберём небольшой пример, который похож на реальные учебные консольные программы: есть список строк (слайс), есть подсчёт повторов (мапа), и есть несколько мест, где легко сделать типичные ошибки. Мы специально не используем структуры — они будут позже; пока работаем теми инструментами, которые уже есть.

Начнём с добавления покупок в слайс и параллельно будем считать, сколько раз встречается каждое слово (например, чтобы увидеть «что чаще всего покупают»).

package main

import (
	"fmt"
	"strings"
)

func addItem(items []string, counts map[string]int, raw string) []string {
	name := strings.ToLower(strings.TrimSpace(raw))
	if name == "" {
		return items
	}
	items = append(items, name)
	counts[name]++
	return items
}

func main() {
	items := make([]string, 0, 4)
	counts := make(map[string]int)

	items = addItem(items, counts, " Milk ")
	items = addItem(items, counts, "bread")
	items = addItem(items, counts, "milk")

	fmt.Println(items)  // [milk bread milk]
	fmt.Println(counts) // map[bread:1 milk:2] (порядок может быть разный)
}

В этом коде уже спрятаны два «важных повторения»: append возвращает новый слайс, поэтому items = addItem(...) — нормальная форма; а вывод мапы «как получится», поэтому для красивого и стабильного вывода нужна сортировка ключей.

Стабильная печать счётчика:

package main

import (
	"fmt"
	"slices"
)

func printCounts(counts map[string]int) {
	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	slices.Sort(keys)

	for _, k := range keys {
		fmt.Println(k, counts[k])
	}
}

Обратите внимание, что keys — это слайс, и здесь вы снова тренируете привычку «append всегда сохраняем».

5. Типичные ошибки

Ошибка №1: игнорировать результат append.
Это выглядит безобидно: «ну я же просто добавил элемент». Но append возвращает новый слайс, и если вы не сохраните его, вы продолжите работать со старым header: со старой длиной и потенциально уже с изменёнными данными. Привычка вырабатывается почти механически: увидели append — спросили себя «куда я сохранил результат?».

Ошибка №2: думать, что sub := base[a:b] — это копия.
Под‑слайс — это почти всегда «вид» на те же данные. Проблема проявляется не когда вы читаете sub, а когда вы пишете в sub или делаете append(sub, ...). В результате может поменяться base, и вы будете искать «кто испортил данные», хотя испортили их вы сами — просто через общий underlying array. Если нужно отделиться — используйте copy или ограничьте cap через base[a:b:c].

Ошибка №3: записывать в nil‑map.
var m map[K]V создаёт nil‑map. Читать можно, range делать можно, но запись приводит к panic. Это не потому что Go вредный, а потому что «нет выделенной памяти под таблицу». Лечится в одну строчку: m = make(map[K]V) перед первой записью.

Ошибка №4: полагаться на порядок range по map.
Иногда хочется: «ну у меня же три ключа, и они всегда выводятся одинаково… пока что». Это обманчивое ощущение. Порядок не гарантирован, и нельзя делать из него часть логики. Если вам нужен стабильный порядок для вывода или тестов, используйте протокол «ключи в слайс → сортировка → вывод».

Ошибка №5: путать «ключа нет» и «значение равно zero value».
Если вы пишете v := m[k] и дальше делаете выводы, то для int вы не отличите «ключ отсутствует» от «ключ есть и там 0». Это классика багов в счётчиках, флагах, статусах. В Go для этого есть стандартный ответ: v, ok := m[k]. Если ok == false, значит ключа не было.

1
Задача
Go SELF, 20 уровень, 0 лекция
Недоступна
Пустой список
Пустой список
1
Задача
Go SELF, 20 уровень, 0 лекция
Недоступна
Справочник цен
Справочник цен
1
Задача
Go SELF, 20 уровень, 0 лекция
Недоступна
Слова чата
Слова чата
1
Задача
Go SELF, 20 уровень, 0 лекция
Недоступна
Безопасный фрагмент
Безопасный фрагмент
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
bazhukovsi_second Уровень 70
5 мая 2026
Классная лекция.