JavaRush /Курси /Go SELF /Повторення: слайси, мапи та типові помилки

Повторення: слайси, мапи та типові помилки

Go SELF
Рівень 20 , Лекція 0
Відкрита

1. Слайси: як вони влаштовані й де ламаються

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

Корисна думка на день: слайси й мапи — це не просто «контейнери», а контейнери з правилами. Якщо ви тримаєте ці правила в голові, то пишете код швидше й налагоджуєте його спокійніше. Якщо ні — починаються запитання на кшталт: «чому в мене змінився початковий слайс?», «чому вивід мапи щоразу різний?», «чому append інколи ламає дані?».

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

Слайс у Go варто уявляти не як «список», а як вікно, через яке ми дивимося на частину масиву. Це вікно описується трьома числами: де починаються дані (вказівник), скільки елементів ми вважаємо «видимими» (len) і скільки можемо «доростити» без переїзду (cap). Саме через цю модель append інколи безпечний, а інколи — як ремонт у квартирі: ніби трохи підклеїли, а в результаті доводиться переїжджати.

Слайси можуть ділити один масив, перекриватися й впливати один на одного. Внутрішній устрій слайса як «pointer/len/cap» — ключ до розуміння майже всіх дивностей із append, підслайсами та копіюванням. Це той самий випадок, коли одна проста картинка економить вам години налагодження.

Можна уявити так:

flowchart LR
    A[Базовий масив] --> B[Заголовок слайса]
    B --> P[вказівник → початок]
    B --> L[len]
    B --> C[cap]

nil-слайс і порожній слайс: однакові за довжиною, різні за змістом

Тема nil проти порожнього слайса здається дрібницею, доки ви не починаєте писати акуратні перевірки й не стикаєтеся з питанням: «чому == nil повертає false?». У 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{} // порожній

	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{}
(порожній)
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]) — це надзвичайно зручна річ… доки ви не починаєте їх змінювати. Головна ідея: підслайс майже завжди ділить базовий масив із початковим слайсом. Це означає, що запис в елементи підслайса змінює початковий масив. Це очікувано, коли ви робите 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. Ця поведінка повністю логічна, якщо пам’ятати про заголовок із cap.

Повний вираз слайса s[a:b:c]: офіційний спосіб поставити паркан

Повний вираз слайса виглядає як магічний трюк, але за змістом це просто точніший опис вікна: ви задаєте не лише 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 копія робиться не присвоєнням, адже воно копіює лише заголовок, а створенням нового слайса та викликом 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?». Бо це буде другий заголовок на той самий масив, тобто знову aliasing, тільки вже більш прихований.

Видалення зі слайса та «хвіст»: чому інколи варто очищати

Видалення елементів із середини слайса — тема, де легко помилитися на межах, а ще легше — забути, що базовий масив може й далі тримати посилання на «видалені» елементи за межами 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-мапу не можна змінювати (запис викличе panic), а по-друге, порядок обходу range по map не є контрактом.

Це не особливість конкретної реалізації, а свідоме правило: код не має спиратися на порядок, інакше ви отримаєте нестабільну поведінку. У реальній розробці нестабільний вивід — це біль: тести починають поводитися нестабільно, дифи в логах стають беззмістовними, а користувачі ставлять запитання на кшталт «чому список стрибає місцями?».

nil-мапа: читати можна, писати не можна

nil-мапа — це як порожній зошит, на який ви дивитеся крізь вітрину: читати можна, а писати — ні. Зате range по nil-мапі безпечний — просто не буде ітерацій.

package main

import "fmt"

func main() {
	var m map[string]int // nil-мапа

	fmt.Println(m["a"]) // 0 (нульове значення), але ключа немає

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

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

Тут особливо важливо не плутати нульове значення, отримане з мапи, та відсутність ключа. Тому наступна частина — про ok-ідіому.

v, ok := m[k]: як відрізнити «немає ключа» від zero value

Якщо значення типу V має зрозуміле нульове значення (наприклад, 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
}

У прикладному коді це рятує від неприємних багів, коли програма «ніби бачить значення», але насправді ключ відсутній, а ви просто отримали нульове значення.

Порядок 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 повертає новий слайс, і якщо ви не збережете результат, то продовжите працювати зі старим заголовком: зі старою довжиною і, можливо, уже зі зміненими даними. Звичка виробляється майже механічно: побачили append — запитали себе: «Куди я зберіг результат?».

Помилка №2: думати, що sub := base[a:b] — це копія.
Підслайс — це майже завжди «вид» на ті самі дані. Проблема проявляється не тоді, коли ви читаєте sub, а тоді, коли ви пишете в sub або робите append(sub, ...). У результаті може змінитися base, і ви шукатимете, хто зіпсував дані, хоча це зробили ви самі — просто через спільний базовий масив. Якщо потрібно відокремитися — використовуйте copy або обмежте cap через base[a:b:c].

Помилка №3: записувати в nil-мапу.
var m map[K]V створює nil-мапу. Читати можна, 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, значить ключа не було.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ