JavaRush /Курси /Go SELF /Повторення — вказівники та семантика значень

Повторення — вказівники та семантика значень

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

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.

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