1. Головна мантра: «У Go все передається за значенням»
Якщо вам колись здавалося, що вказівники в Go — це «магія, яка то працює, то кусається», то проблема зазвичай не у вказівниках. Проблема в очікуваннях: нам здається, що значення передали у функцію — і там усе саме змінилося. У Go базове правило інше: у функцію завжди потрапляє копія аргументу. Далі ми лише уточнюємо, що саме скопіювали: число, заголовок слайса, заголовок map чи адресу.
Найзручніше тримати в голові просту схему:
flowchart TD
A["Виклик f(x)"] --> B["Копіюється значення x"]
B --> C["Функція працює з копією"]
C --> D["Ззовні змінюється лише те, на що копія вказує (якщо вказує)"]
Це правило — фундамент. А вказівники лише допомагають зробити так, щоб копія вказувала назовні.
2. Вказівник у Go: адреса & і розіменування *
Коли кажуть «вказівник», хочеться одразу уявити «страшний C і витоки памʼяті». Але в Go вказівник — це значно простіша річ: значення, яке зберігає адресу іншого значення. Ви берете адресу за допомогою оператора &, а щоб дістатися до значення за адресою, використовуєте * — це розіменування. Такий самий символ ви вже бачили у вигляді &x під час роботи з Scan, просто тепер ви усвідомлено розумієте, що робите.
Синтаксис в оголошеннях і виразах навмисно схожий: p *int — «вказівник на int», а *p — «значення, на яке вказує p».
Міні-приклад: чому «просто передати int» не змінює змінну
Тут добре видно правило копії аргументу:
package main
import "fmt"
func inc(n int) {
n++
}
func main() {
x := 10
inc(x)
fmt.Println(x) // 10
}
inc збільшив копію x. Оригінал залишився без змін.
Міні-приклад: як змінюємо оригінал через *int
Тепер передамо у функцію не число, а адресу:
package main
import "fmt"
func inc(p *int) {
*p++
}
func main() {
x := 10
inc(&x)
fmt.Println(x) // 11
}
Ми передали копію вказівника, але цей вказівник указує на початковий x. Тому зміна через *p помітна ззовні.
3. Де вказівники справді корисні
Новачку дуже хочеться зробити все через вказівники, щоб «точно працювало». Це нормально: мозок шукає універсальний інструмент. Проблема в тому, що вказівники — інструмент точковий. Вони корисні тоді, коли ви можете чітко сформулювати, навіщо вам адреса, а не значення.
Потрібно змінити змінну ззовні функції
Це найчесніший випадок: є змінна, і функція має її оновити. Приклад — лічильник, залишок грошей, «наступний ID», накопичення статистики.
Трохи пізніше ми застосуємо це в нашому навчальному міні-застосунку — списку завдань — через nextID.
Потрібно вміти сказати «значення відсутнє»
У Go немає окремого типу на кшталт «nullable int» у базовій мові. Тому інколи використовують *int замість int. Тоді nil означає «значення відсутнє». Це особливо зручно, коли 0 — допустиме число, і ви не хочете плутати «0» та «не задано».
Невеликий приклад:
package main
import "fmt"
func printLimit(limit *int) {
if limit == nil {
fmt.Println("ліміт не встановлено") // ліміт не встановлено
return
}
fmt.Println("ліміт:", *limit)
}
func main() {
printLimit(nil)
x := 5
printLimit(&x) // ліміт: 5
}
Тут важливо розуміти, що nil — це частина контракту. Якщо контракт допускає nil, у функції має бути перевірка.
4. Де вказівники не потрібні: слайси та map уже «посилаються»
Тепер важливий момент, через який багато хто плутається: «але ж я не передавав вказівник, а воно змінилося». Зазвичай таке трапляється зі слайсами та map.
Слайс — це не масив, а «заголовок + дані»
Слайс усередині зберігає вказівник на масив даних, довжину та ємність. Два різні слайси можуть дивитися на один і той самий масив.
Це означає дві речі одночасно:
Перше: якщо функція змінює елемент слайса, це видно ззовні, бо елемент лежить у спільному масиві даних.
Друге: якщо функція змінює довжину слайса — через append, Delete і подібні операції, — вона має повернути новий слайс, бо змінюється заголовок (len/cap і, можливо, вказівник). Саме тому append повертає значення.
Міні-приклад: зміна елемента слайса видима без вказівників
package main
import "fmt"
func setFirst(s []int) {
s[0] = 99
}
func main() {
a := []int{1, 2, 3}
setFirst(a)
fmt.Println(a) // [99 2 3]
}
Ми передали копію заголовка, але заголовок указує на той самий масив даних.
Міні-приклад: append «змінює слайс», тому результат треба зберігати
package main
import "fmt"
func addOne(s []int) []int {
return append(s, 10)
}
func main() {
a := []int{1, 2}
a = addOne(a)
fmt.Println(a) // [1 2 10]
}
Якщо ви не збережете результат, то можете й далі жити зі старою довжиною або, гірше, зі старим заголовком.
Ця сама ідея працює й у функціях на кшталт slices.Delete: вони повертають новий слайс, тож ігнорувати результат — помилка.
5. new, make і nil: як створювати та зберігати «відсутність»
Коли в коді з’являються new, make або nil, у новачка часто вмикається режим «ну все, пішла магія». Насправді різниця дуже практична: це про різні речі й різні контракти.
new і make: коротко і по суті
new(T) виділяє пам’ять під значення типу T, записує туди zero value і повертає *T.
make створює готове до роботи значення для спеціальних «вбудованих контейнерів»: []T, map[K]V, chan T. Канали нам зараз не потрібні, але правило запам’ятаємо.
Порівняння зручно побачити прямо кодом:
package main
import "fmt"
func main() {
p := new(int) // *int, всередині 0
s := make([]int, 0, 2) // []int, готовий до append
m := make(map[string]int)
fmt.Println(*p) // 0
fmt.Println(len(s)) // 0
fmt.Println(len(m)) // 0
}
Якщо хочете коротку перевірку: new повертає вказівник, make — ні.
nil: одне слово, але кілька ролей
З nil історія така: слово одне, а застосувань кілька, і через це люди часто помиляються. nil може означати «немає вказівника», «немає map», «немає слайса». Але поведінка в цих випадках різна — і це нормально: контракти різні.
nil-вказівник: розіменовувати не можна
Якщо у вас var p *int = nil, то *p — це паніка. Тут нічого філософського: адреси немає — читати нічого.
nil-слайс: зазвичай безпечно
nil-слайс поводиться як порожній у сенсі len==0, і append працює. Він просто створить масив даних і поверне новий заголовок слайса.
nil-map: читати можна, писати не можна
Читання з nil-map дає zero value, а range по ній просто не виконається. А от запис (m["x"] = 1) викличе паніку, бо внутрішньої структури map немає.
У практиці це зводиться до дуже простого правила: map для запису потрібно ініціалізувати через make.
6. Пастки вказівників: «адреса є, але не та»
Помилки з вказівниками рідко виглядають як «я не зрозумів синтаксис *p». Частіше вони виглядають так: «чому в мене всі вказівники однакові?» або «чому я змінив через вказівник, а в слайсі нічого не змінилося?». Це вже не про синтаксис, а про час життя і те, на що саме ви взяли адресу.
Адреса однієї змінної, яку перевикористовують у циклі
У Go 1.25 уже виправили найпоширеніший випадок із for , v := range ... — там змінна зазвичай окрема для кожної ітерації. Але пастку все ще легко створити, якщо змінну оголошено поза циклом.
package main
import "fmt"
func main() {
nums := []int{10, 20, 30}
var v int
ptrs := make([]*int, 0, len(nums))
for _, n := range nums {
v = n
ptrs = append(ptrs, &v) // одна й та сама адреса
}
fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 30 30 30
}
Проблема не в append, не в range, а в тому, що &v завжди один і той самий.
Якщо вам справді потрібні адреси елементів слайса, найчастіше коректніше брати &nums[i]:
package main
import "fmt"
func main() {
nums := []int{10, 20, 30}
ptrs := make([]*int, 0, len(nums))
for i := range nums {
ptrs = append(ptrs, &nums[i])
}
fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 10 20 30
}
Вказівник на елемент слайса та append: «поїхали на новий масив»
У Go це типова ситуація: append може розширити масив даних і перенести елементи в новий. Тоді старі вказівники на елементи залишаться вказувати на старий масив. Слайс уже дивиться на новий, а ваш вказівник — на старий. Ніхто не винен, ви просто тримаєте адресу «до переїзду».
Покажемо це так, щоб переїзд точно стався: зробимо cap=1.
package main
import "fmt"
func main() {
s := make([]int, 1, 1)
s[0] = 10
p := &s[0]
s = append(s, 20) // cap не вистачає → новий масив
*p = 99
fmt.Println(s[0], *p) // 10 99
}
Це не випадковість і не «зламаний Go». Це наслідок будови слайсів: заголовок слайса вказує на масив даних і може почати вказувати на новий.
Чому не можна &m[k]
До елемента map не можна взяти адресу: &m[k] не компілюється. Причина проста й практична: елементи map можуть переїжджати всередині структури під час зростання чи перехешування, і «стабільної адреси елемента» як обіцянки мова не дає.
Якщо хочете оновити значення в map, робіть це через присвоєння:
package main
import "fmt"
func main() {
m := map[string]int{"a": 1}
x := m["a"]
x++
m["a"] = x
fmt.Println(m["a"]) // 2
}
7. «Семантика значень» на прикладі навчального міні-застосунку
Щоб усе це не залишилося лише теорією, зберемо невеликий фрагмент логіки для навчального списку завдань, який ми вже не раз використовували для практики введення, рядків, помилок, слайсів і map. Зараз фокус буде не на CLI й командах, а на тому, як передавати стан у функції та не плутатися.
Ми зберігатимемо задачі без структур — до них ми ще дійдемо в іншій частині курсу, — тому візьмемо мінімалістичний набір:
- titles map[int]string — заголовок задачі за ID
- done map[int]bool — статус виконання за ID
- nextID int — наступний ID, який видається
Чому map передаємо без вказівника і не робимо *map
Map сама по собі поводиться як посилальна структура: ви передаєте копію заголовка, але він указує на одну й ту саму внутрішню таблицю. Тому функція може спокійно писати в map, і це буде видно ззовні — без жодних *map.
Ось функція додавання задачі: вказівник нам потрібен лише для nextID, бо це звичайне число, і ми хочемо збільшити його назовні.
package main
import "fmt"
func addTask(nextID *int, titles map[int]string, done map[int]bool, title string) int {
id := *nextID
*nextID++
titles[id] = title
done[id] = false
return id
}
func main() {
titles := make(map[int]string)
done := make(map[int]bool)
nextID := 1
id := addTask(&nextID, titles, done, "купити молоко")
fmt.Println(id, titles[id], done[id]) // 1 купити молоко false
}
Зверніть увагу: map ми передаємо без вказівника, а nextID — з вказівником. Це й є «семантика значень» у дії: де потрібно змінити зовнішнє число — там адреса; де змінюємо спільну структуру за посиланням — там достатньо значення.
Слайс як стан: коли треба повертати новий
Тепер уявімо, що в нас є список ID у порядку додавання, щоб друкувати задачі в сталому порядку: order []int. Під час додавання ID ми робимо append. І тут знову спливає правило про заголовок слайса: якщо довжина змінюється, функція має повернути новий слайс.
package main
import "fmt"
func addToOrder(order []int, id int) []int {
return append(order, id)
}
func main() {
order := make([]int, 0, 2)
order = addToOrder(order, 1)
order = addToOrder(order, 2)
fmt.Println(order) // [1 2]
}
Якщо ви забудете order = ..., то програма може працювати лише в частині випадків, а потім раптово почати поводитися дивно. Саме тому навколо слайсів у Go так багато розмов про те, що результат append потрібно завжди зберігати. І це не занудство, а реальний захист від багів.
До речі, те саме стосується і функцій із пакета slices, зокрема Delete: вони повертають новий слайс, тому ігнорувати результат — помилка.
«Неочікуваний nil усередині слайса» як наслідок неправильного використання
Ще один корисний момент для читання чужого коду: якщо неправильно використовувати функції, що змінюють довжину слайса, у даних можуть з’явитися «неочікувані nil-значення» (nil для вказівників). У сучасних версіях Go стандартні функції для слайсів активно «зануляють хвіст» (clear tail), і тести можуть почати падати там, де раніше випадково проходили.
Навіть якщо ви поки що не використовуєте slices.Delete щодня, це підкреслює загальну ідею: старий слайс після операцій зміни довжини краще вважати «застарілим».
8. Типові помилки
Помилка № 1: очікувати, що передавання int дозволить змінити значення ззовні.
За замовчуванням аргументи у функцію передаються за значенням — створюється копія. Якщо всередині функції ви змінюєте параметр типу int, ззовні нічого не зміниться. Якщо за змістом вам потрібно змінити змінну в коді, який викликає функцію, — передавайте *int. Якщо ні — залишайте int і не ускладнюйте контракт.
Помилка № 2: «лікувати все вказівниками», включно зі слайсами та map.
Слайс і map уже поводяться як посилання на внутрішні дані: під час копіювання ви копіюєте заголовок, а не всі елементи. Зміна елементів слайса видима без вказівників, а от зміну довжини треба супроводжувати поверненням нового слайса, бо змінюється заголовок. Постійно тримайте в голові модель слайса як pointer + len + cap.
Помилка № 3: збирати вказівники в циклі на одну й ту саму змінну.
Якщо змінну оголошено поза циклом і ви берете її адресу на кожній ітерації, ви отримаєте набір вказівників на одну й ту саму адресу. Код компілюється, але всі значення в результаті виявляються однаковими. Рішення — брати адресу реального елемента (&nums[i]) або створювати нову змінну всередині кожної ітерації.
Помилка № 4: зберігати вказівник на елемент слайса і робити append.
Якщо append збільшить ємність і масив переїде в нове місце пам’яті, старий вказівник залишиться вказувати на «старий» масив. У маленьких прикладах це може не проявлятися (якщо cap вистачає), але в реальному коді призведе до важковловимих багів. Правило просте: не зберігайте вказівники на елементи слайса довше, ніж живе поточний масив.
Помилка № 5: розіменовувати nil-вказівник.
Розіменування nil — гарантована паніка. Тут немає «може пощастить»: якщо адреси немає, читати й писати нікуди. На відміну від вказівника, nil-слайс часто безпечний, а nil-map безпечна лише для читання. Для запису в map її потрібно попередньо ініціалізувати через make.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ