JavaRush /Курси /Go SELF /Value receiver vs pointer receiver — критерії вибору

Value receiver vs pointer receiver — критерії вибору

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

1. Навіщо взагалі обирати receiver

Коли ви вперше бачите func (t Task) Done() bool і func (t *Task) MarkDone(), легко подумати: «Ну… зірочка та й зірочка, яка різниця?». Різниця, на жаль, є — і вона цілком практична. Receiver відповідає на два запитання: чи працює метод із копією, чи з оригіналом, а також чи може метод змінювати стан об’єкта. Крім того, є ще зручність виклику та продуктивність. Тож «просто поставити * всюди» — теж не найкращий варіант.

Щоб не говорити про це відірвано від практики, продовжимо наш навчальний мінідомен task-tracker: у нас будуть задачі (Task) із заголовком, прапорцем виконання та списком тегів.

Найважливіше правило: у Go все передається за значенням

Ця фраза звучить як дзен, доки не стане вашим рефлексом. У Go під час виклику функції чи методу параметр копіюється. Якщо receiver — це Task, копіюється весь Task. Якщо receiver — це *Task, копіюється не весь об’єкт, а лише вказівник — маленьке значення-адреса. Але ця адреса вказує на той самий об’єкт, тож через неї можна змінювати поля оригіналу.

Зробімо просту модель:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) MarkDoneCopy() {
	t.Done = true // змінюємо копію
}

func (t *Task) MarkDone() {
	t.Done = true // змінюємо оригінал через вказівник
}

func main() {
	task := Task{Title: "Прочитати специфікацію Go", Done: false}

	task.MarkDoneCopy()
	fmt.Println(task.Done) // false

	task.MarkDone()
	fmt.Println(task.Done) // true
}

Зверніть увагу: MarkDoneCopy ніби робить роботу, але результат зникає, бо зміни відбувалися в копії receiver-а.

2. Критерії вибору receiver

Value receiver (T): метод отримує копію

Value receiver — добрий вибір, коли метод читає дані й нічого не змінює. Це схоже на ситуацію, коли вам дали ксерокопію документа: ви можете дивитися, підкреслювати маркером, малювати котиків на полях — але оригінал у папці керівника не постраждає (на щастя для керівника і, можливо, на ваш жаль).

Приклад «читального» методу для Task:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) IsDone() bool {
	return t.Done
}

func main() {
	task := Task{Title: "Купити молоко", Done: true}
	fmt.Println(task.IsDone()) // true
}

Тут value receiver — чудовий вибір: метод простий, не змінює стан, а копіювання маленької структури не створює проблем.

Pointer receiver (*T): метод отримує адресу й може змінювати оригінал

Pointer receiver потрібен, коли метод має змінювати стан. Це головний критерій вибору, і він майже завжди знімає питання без філософії. Якщо метод за змістом змінює об’єкт, а receiver у нього value, вийде зміна без жодного ефекту.

Повернімося до задач і зробімо нормальний метод завершення:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func main() {
	task := Task{Title: "Написати тести", Done: false}
	task.MarkDone()
	fmt.Println(task.Done) // true
}

З погляду дизайну API це читається природно: task.MarkDone() справді завершує задачу, а не лише імітує це.

Критерій №1: метод змінює стан → майже завжди *T

Зараз буде корисний антиприклад із життя, який новачки дуже часто роблять. Припустімо, ви додали метод перейменування задачі, але випадково написали value receiver.

package main

import "fmt"

type Task struct {
	Title string
}

func (t Task) RenameBad(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Старий"}
	task.RenameBad("Новий")
	fmt.Println(task.Title) // Старий
}

Відчуття приблизно таке, ніби ви натиснули «Зберегти», а зміни так і не записалися. Правильна версія:

package main

import "fmt"

type Task struct {
	Title string
}

func (t *Task) Rename(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Старий"}
	task.Rename("Новий")
	fmt.Println(task.Title) // Новий
}

Якщо коротко: усі «сетери», позначення виконання, додавання, очищення й перемикачі прапорців — це майже завжди *T.

Критерій №2: «важкі» структури краще не копіювати → часто *T

Іноді метод нічого не змінює, але структура велика. Тоді value receiver формально коректний, але може бути невигідним: кожен виклик методу копіює весь об’єкт. Це не означає, що програма негайно загальмує, але це як їздити по хліб на вантажівці: можна, та дивно.

Зімітуймо «важкий» об’єкт — суто для демонстрації:

package main

import "fmt"

type Big struct {
	Data [1024]byte
	Name string
}

func (b Big) NameLen() int {
	return len(b.Name)
}

func main() {
	x := Big{Name: "abc"}
	fmt.Println(x.NameLen()) // 3
}

Big тут копіюється повністю під час кожного NameLen(). У реальності «важкість» буває не через [1024]byte, а через багато полів, вкладені структури тощо. У такому разі ви часто обираєте *T навіть для «читальних» методів — просто щоб не копіювати зайве.

Але важливо не впасти в крайність: «тепер усі методи завжди *T». Іноді value receiver робить API простішим і безпечнішим. Тому критерії треба поєднувати, а не перетворювати на релігію.

Критерій №3: узгодженість API важливіша за локальну хитрість

Коли в одного типу половина методів на T, а половина — на *T, новачок починає жити у світі сюрпризів: цей метод змінив стан, а цей — ні. Тому в Go є практичне правило: для кожного типу намагайтеся тримати єдиний стиль receiver-ів, якщо немає вагомої причини робити інакше.

Частий компроміс виглядає так: усі методи, що змінюють стан, — на *T; методи, що лише читають, — на T, але тільки якщо структура маленька й вам не потрібно уникати копіювання. Якщо структура серйозна, багато команд роблять майже всі методи на *T, навіть читальні, — заради єдності та продуктивності.

Окремо зауважу (і ми пізніше це розберемо детальніше): вибір receiver-а впливає на сумісність з інтерфейсами, бо методи з pointer receiver не завжди «видимі» значенню типу T. Це швидко спливає в реальному коді, наприклад, коли ви намагаєтеся підставити тип у контракт й отримуєте помилку компілятора.

Критерій №4: інколи *T потрібен через поля-слайси та append

Ось тонкий момент, який здається магією, доки ви не згадаєте, що слайс — це «заголовок» (len/cap/pointer на масив). Заголовок копіюється дуже легко, але саме через це value receiver може «втратити» зміни у довжині слайса.

Додаймо до задачі теги:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t Task) AddTagBad(tag string) {
	t.Tags = append(t.Tags, tag) // змінюємо копію заголовка слайса
}

func main() {
	task := Task{Title: "Вивчаємо Go", Tags: []string{}}
	task.AddTagBad("study")
	fmt.Println(task.Tags) // []
}

Тег не додався, бо t — копія, і нове значення t.Tags залишилося всередині методу. Правильно робимо метод, який змінює стан, на *Task:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t *Task) AddTag(tag string) {
	t.Tags = append(t.Tags, tag)
}

func main() {
	task := Task{Title: "Вивчаємо Go", Tags: []string{}}
	task.AddTag("study")
	fmt.Println(task.Tags) // [study]
}

Цей приклад часто стає моментом осяяння: append змінює не елементи, а потенційно весь заголовок (і інколи навіть переїжджає до нового масиву), тож без pointer receiver ви легко зробите роботу в копії.

Адресованість: чому NewTask().MarkDone() інколи не компілюється

Є ще одна річ, яка здається примхою компілятора, але насправді захищає вас від дивних ситуацій. Pointer receiver вимагає, щоб receiver був адресованим (тобто щоб значення мало «реальну адресу», яку можна взяти).

Дивіться приклад:

package main

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() Task {
	return Task{Done: false}
}

func main() {
	// NewTask().MarkDone() // не компілюється: результат функції не адресований
	task := NewTask()
	task.MarkDone()
}

Чому так? Бо NewTask() повертає тимчасове значення. Компілятор не зобов’язаний (і зазвичай не хоче) вигадувати вам «приховану змінну», адресу якої можна взяти, щоб ви змінили її й одразу викинули. Вихід простий: зберегти значення в змінну, як у прикладі.

Якщо ви хочете, щоб «конструктор» зручно працював із pointer-методами, часто роблять так: NewTask повертає *Task.

package main

import "fmt"

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() *Task {
	return &Task{Done: false}
}

func main() {
	task := NewTask()
	task.MarkDone()
	fmt.Println(task.Done) // true
}

Так, це трохи змінює стиль використання типу: тепер «задача» частіше живе як вказівник. Але це логічно, якщо в типу багато методів, що змінюють стан.

4. Шпаргалка: таблиця й блок-схема вибору receiver-а

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

Запитання про метод Якщо «так» Якщо «ні»
Метод має змінювати поля?
*T
йдемо далі
Метод виконує append у поле-слайс або повністю змінює map чи слайс у полі?
*T
йдемо далі
Тип великий і копіювати його шкода? частіше
*T
можна
T
Хочемо єдиний стиль і передбачуваність API? частіше
*T
для всіх методів
можна змішувати, але обережно

Якщо хочеться ще наочніше, ось невелика блок-схема:

flowchart TD
    A[Пишемо метод] --> B{Метод змінює стан?}
    B -- так --> P[*T]
    B -- ні --> C{Усередині є append / повністю змінюємо поле-слайс?}
    C -- так --> P
    C -- ні --> D{Тип великий і копіювати його шкода?}
    D -- так --> P
    D -- ні --> V[T]

Ця схема не скасовує здорового глузду, але рятує від ситуації «я поставив *, бо так сказали в інтернеті».

Чому навіть у стандартній бібліотеці часто обирають *T

Іноді здається, що pointer receiver — це хак для новачків, а справжні гофери все роблять красиво. На практиці pointer receiver — звичайна річ, зокрема в стандартній бібліотеці, особливо коли метод логічно прив’язаний до об’єкта і не хочеться копіювати його або коли потрібно змінювати стан.

Наприклад, типізовані помилки дуже часто реалізують метод Error() саме на вказівнику на структуру помилки, тобто func (e *NotFoundError) Error() string { ... }. Ми зараз не розбираємо дизайн помилок детально, але сам факт корисний: *T — це нормальний інструмент, а не «брудний трюк».

5. Типові помилки

Помилка №1: метод, що змінює стан, написаний із value receiver, і зміни «не зберігаються».
Це найчастіший баг: метод за змістом має змінювати об’єкт (Rename, MarkDone, AddTag), але receiver у нього T. Код компілюється, метод викликається, а стан лишається попереднім. Лікується просто: щойно метод «обіцяє» змінити об’єкт, робіть *T і не намагайтеся обійтися return без потреби.

Помилка №2: «ставлю *T всюди, бо так безпечніше».
Здається логічним: «вказівник же потужніший». Але це створює інший тип проблем: ви починаєте всюди тягати вказівники, частіше натрапляєте на nil і інколи ускладнюєте прості типи, які могли б бути значеннями. Краще тримати правило: *T — там, де потрібні мутація або економія на копіюванні, а не «за замовчуванням».

Помилка №3: неочікувана поведінка через append у полі-слайсі при value receiver.
Цей баг особливо підступний, бо він виглядає як «ну я ж додав тег!». Але ви додали тег у копію заголовка слайса. У результаті ззовні змін немає. Якщо метод змінює довжину або місткість слайса в полі структури, майже завжди потрібен *T.

Помилка №4: спроба викликати pointer-метод на тимчасовому значенні (неадресованому).
Класика: NewTask().MarkDone() і компілятор свариться. Це не шкідливість Go, а захист від неявної магії та дивних побічних ефектів. Правильна звичка: спочатку збережіть результат у змінну або проєктуйте «конструктор» так, щоб він повертав *T, якщо в типу багато методів, що змінюють стан.

Помилка №5: хаотична суміш receiver-ів без зрозумілого правила.
Коли частина методів на T, а частина — на *T, ви самі за кілька тижнів почнете забувати, де що. А якщо код читають інші, вони почнуть вгадувати, де метод змінює стан, а де ні. Краще обрати просте правило для типу й дотримуватися його, залишаючи винятки лише там, де вони справді потрібні.

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