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]
}
Для закріплення — компактна табличка:
| Властивість | (nil) |
(порожній) |
|---|---|---|
|
|
|
|
0 ітерацій | 0 ітерацій |
|
працює | працює |
|
|
|
| Зміст | «не задано» | «задано, але порожньо» |
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, значить ключа не було.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ