1. Основа: ресівер і method set
Іноді здається, що Go-компілятор спеціально створили, щоб псувати настрій. Ви пишете акуратний код, у типу є метод Add, в інтерфейсу теж Add, а присвоєння в інтерфейс раптом не компілюється. У цей момент хочеться сказати: «Та годі, компіляторе, я ж бачу, що метод є!» — але компілятор дивиться на світ трохи формальніше.
Цей формалізм корисний: він захищає від двозначностей і прихованих копіювань даних. У Go важливо, що значення й вказівники — це різні речі, а набір методів, доступних для T та *T, теж відрізняється. Саме цей набір і називають method set (набором методів). За ним Go вирішує, чи реалізує тип інтерфейс.
Якщо запам’ятати два-три правила про method set, ви перестанете «тикати палицею» в компілятор і почнете передбачати його поведінку. А це, погодьтеся, приємніше, ніж гадати, хто сьогодні переможе — ви чи go build.
Value receiver і pointer receiver простими словами
Перш ніж заглиблюватися в method set, корисно відновити інтуїцію. Метод може бути оголошений або на значенні, або на вказівнику. Це виглядає так:
func (t Task) TitleUpper() string { ... } // ресівер за значенням
func (t *Task) MarkDone() { ... } // ресівер за вказівником
Value receiver (Task) — це «метод працює з копією значення». Зазвичай він або нічого не змінює, або змінює лише свою копію, а назовні ці зміни не виходять.
Pointer receiver (*Task) — це «метод працює з тим самим об’єктом», і зміни видно ззовні. Це типовий вибір, коли метод має змінювати стан структури.
Але є ще один ефект, про який новачки часто не думають: ресівер впливає на те, чи реалізує T інтерфейс, чи для цього потрібен *T. І саме це безпосередньо пов’язано з method set.
Що таке method set: просте визначення й таблиця правил
Method set типу — це «список методів, які компілятор вважає належними цьому типу» в контексті перевірки інтерфейсів і викликів. Нас цікавить саме частина про інтерфейси: щоб тип реалізовував інтерфейс, у його method set мають входити всі методи інтерфейсу.
У Go ключове правило таке:
| Тип, який ми розглядаємо | Які методи входять у method set |
|---|---|
|
методи з ресівером T |
|
методи з ресівером T і методи з ресівером *T |
Це означає дуже практичну річ: якщо ви оголосили метод на *T, то значення T може не реалізовувати інтерфейс, який вимагає цей метод. Натомість *T — реалізує.
Ця ситуація настільки типова, що ви можете зустріти її навіть у прикладах і обговореннях мови: наприклад, у розборі інтерфейсів у контексті методів із pointer receiver чітко видно, що «тип не задовольняє інтерфейс, бо метод оголошено з pointer receiver».
2. Приклад: «менеджер задач» і інтерфейс TaskStore
Щоб усе було не абстрактно, продовжимо наш навчальний мініпроєкт. Нехай у нас є задача й сховище задач (поки що у пам’яті, без файлів і БД — ми просто тренуємо архітектурне мислення).
Почнемо з моделі:
package main
type Task struct {
ID int
Title string
Done bool
}
Тепер визначимо інтерфейс, який потрібен нашому коду. Наприклад, наш код хоче додавати задачу та позначати її виконаною:
package main
type TaskStore interface {
Add(t Task)
MarkDone(id int) bool
}
Зробімо просту реалізацію в пам’яті:
package main
type MemStore struct {
tasks []Task
}
func (s *MemStore) Add(t Task) {
s.tasks = append(s.tasks, t)
}
func (s *MemStore) MarkDone(id int) bool {
for i := range s.tasks {
if s.tasks[i].ID == id {
s.tasks[i].Done = true
return true
}
}
return false
}
Зверніть увагу: обидва методи — на *MemStore, бо ми змінюємо внутрішній слайс: додаємо елементи й змінюємо поля. Це цілком логічно.
А тепер — момент істини. Хочемо написати функцію, яка приймає інтерфейс:
package main
func Seed(store TaskStore) {
store.Add(Task{ID: 1, Title: "Купити чай"})
store.Add(Task{ID: 2, Title: "Не забути чай"})
}
А тепер — у main:
package main
import "fmt"
func main() {
var s TaskStore = MemStore{} // хочемо саме так
Seed(s)
fmt.Println("ok")
}
Цей код не скомпілюється. Компілятор скаже приблизно таке: MemStore does not implement TaskStore (Add has pointer receiver).
Чому? Тому що TaskStore вимагає методи Add і MarkDone. Для значення MemStore method set містить лише методи з ресівером MemStore, а в нас методи оголошені на *MemStore. Отже, значення MemStore{} не реалізує інтерфейс.
Правильне рішення — передавати вказівник:
package main
import "fmt"
func main() {
var s TaskStore = &MemStore{} // тепер це *MemStore
Seed(s)
fmt.Println("ok") // виведе ok
}
Тепер тип у змінній інтерфейсу — *MemStore, а його method set включає методи і MemStore, і *MemStore, тобто потрібний набір є.
3. «Але ж я можу викликати метод на значенні»: пастка синтаксичного цукру
Тут зазвичай починається плутанина. Студент каже: «Зачекайте. Я ж можу зробити так: ms := MemStore{}; ms.Add(...) — і воно компілюється. Чому ж тоді в інтерфейс не можна?»
Давайте чесно покажемо:
package main
import "fmt"
type MemStore struct{ tasks []int }
func (s *MemStore) Add(x int) {
s.tasks = append(s.tasks, x)
}
func main() {
ms := MemStore{}
ms.Add(10) // компілюється!
fmt.Println(ms.tasks) // [10]
}
Це компілюється, бо Go робить зручну підстановку: якщо у вас є значення, для якого можна взяти адресу (ms — змінна, у неї є адреса), то під час виклику методу з pointer receiver компілятор неявно підставляє &ms. Тобто реально викликається (&ms).Add(10).
Але важливо: це зручність виклику, а не зміна правил реалізації інтерфейсів. Для інтерфейсу компілятор дивиться суворо на method set типу. Коли ви пишете var s TaskStore = MemStore{}, компілятор не зобов’язаний і не буде «магічно брати адресу» у тимчасового значення, щоб задовольнити інтерфейс.
Корисна думка: «виклик методу» і «реалізація інтерфейсу» перевіряються схожими правилами, але не однаково. Виклик може використовувати неявне взяття адреси, а перевірка інтерфейсу — ні: вона спирається на method set.
4. Вибір ресівера з урахуванням інтерфейсів
Коли value receiver корисніший
Після попереднього розділу легко зробити неправильний висновок: «Отже, завжди треба робити методи на вказівниках». Не завжди.
Бувають типи, які логічно й безпечно передавати копією: маленькі структури, незмінні значення (наприклад, точка на площині), типи-обгортки навколо базових чисел, а також випадки, коли метод узагалі нічого не змінює.
Покажемо приклад. Зробімо тип TaskID як значущий тип, щоб випадково не переплутати ID з чимось іншим:
package main
import "fmt"
type TaskID int
func (id TaskID) String() string {
return fmt.Sprintf("task#%d", id)
}
func main() {
var x fmt.Stringer = TaskID(7)
fmt.Println(x.String()) // task#7
}
Тут value receiver чудово підходить: TaskID маленький, копіюється дешево, метод String() не змінює стан. І приємний бонус: і TaskID, і *TaskID задовольнятимуть fmt.Stringer, бо у *T method set включає методи T.
Pointer receiver: два сценарії й один небезпечний
Коли ми обираємо pointer receiver, найчастіше це один із двох сценаріїв.
Перший сценарій — нам потрібно змінювати стан. Це наш MemStore: додаємо задачу, змінюємо Done. У цьому випадку pointer receiver майже неминучий, тож просто приймаємо як факт: інтерфейс буде реалізовано *MemStore, а не MemStore.
Другий сценарій — ми не хочемо копіювати велику структуру. Навіть якщо метод нічого не змінює, копіювання великого об’єкта може бути зайвим. Тоді pointer receiver стає оптимізаційним рішенням, але його ціна — складніша сумісність з інтерфейсами: знову доведеться всюди передавати *T.
Є й третій сценарій, який виглядає невинно, але може призвести до дивних помилок: typed nil усередині інтерфейсу. Він не напряму про method set, але часто спливає саме тоді, коли методи мають pointer receiver, бо це дозволяє зберігати nil як значення типу *T.
Наприклад, error — це інтерфейс. Будь-який тип із методом Error() string реалізує його, зокрема й типи, у яких цей метод оголошено на *T.
І тут легко зробити так:
package main
import "fmt"
type MyErr struct{ Msg string }
func (e *MyErr) Error() string {
if e == nil {
return "<nil MyErr>"
}
return e.Msg
}
func bad() error {
var e *MyErr = nil
return e // typed nil запакували в інтерфейс error
}
func main() {
fmt.Println(bad() == nil) // false
}
Чому false? Бо error-інтерфейс зберігає динамічний тип *MyErr і динамічне значення nil. Сам інтерфейс загалом не nil. Ця історія — класична nil-пастка інтерфейсів, і про неї часто окремо попереджають у матеріалах про обробку помилок та дизайн API.
Який зв’язок із method set? Непрямий, але практичний: pointer receiver робить можливим існування nil-значення конкретного типу, а інтерфейс, у який ми це поклали, уже може поводитися не так, як очікується під час порівняння з nil.
Як обирати ресівер: думайте про майбутні виклики й інтерфейси
Коли ви пишете методи для типу, ви фактично проєктуєте міні-API. І ресівер — частина цього API. Якщо ваш тип планується використовувати через інтерфейси, вибір ресівера впливає на зручність.
Уявіть, що у вас є інтерфейс:
type Closer interface {
Close() error
}
Якщо Close() оголошено на *T, то в інтерфейс потрібно буде передавати *T. Якщо Close() оголошено на T, то і T, і *T підійдуть. На папері value receiver здається універсальнішим, але він може спричиняти зайві копії й те, що метод змінює лише копію (якщо раптом хтось вирішить додати мутацію всередину Close()).
У нашому застосунку про задачі правило можна сформулювати просто: MemStore — це контейнер із внутрішнім станом, тому pointer receiver виправданий, і змінна інтерфейсу має зберігати *MemStore. А TaskID — це значення, тому value receiver цілком доречний.
Корисна звичка: коли визначаєте інтерфейс і тип під нього, прямо проговорюйте: «Хто житиме в інтерфейсі: T чи *T?» Це знімає половину майбутніх запитань.
5. Діагностика: як зрозуміти, що лежить в інтерфейсі
Коли щось не компілюється або поводиться дивно, хочеться побачити реальність, а не здогадки. З інтерфейсами вам дуже допомагає fmt.Printf("%T", x) — він показує динамічний тип значення всередині інтерфейсу (або "<nil>", якщо інтерфейс повністю nil).
Наприклад, у нашому TaskStore:
package main
import "fmt"
type TaskStore interface {
Add(id int)
}
type MemStore struct{ items []int }
func (s *MemStore) Add(id int) { s.items = append(s.items, id) }
func main() {
var a TaskStore = &MemStore{}
fmt.Printf("%T\n", a) // *main.MemStore
}
Якщо ви бачите *main.MemStore, отже інтерфейс справді зберігає вказівник.
І навпаки, якщо ви колись запідозрите typed nil, такий вивід теж допомагає: ви можете побачити, що тип усередині інтерфейсу — вказівниковий, навіть якщо «значення» там nil.
6. Типові помилки
Помилка №1: «Метод на *T, але я намагаюся покласти T в інтерфейс».
Зазвичай це виглядає як var x I = T{...} і помилка компілятора про pointer receiver. Причина майже завжди в method set: у значення T немає методів, оголошених на *T, тому T не реалізує інтерфейс. Лікується або заміною на &T{...}, або переглядом ресіверів, якщо мутацій немає й копіювання безпечне.
Помилка №2: «Я викликав t.M() на значенні, тож воно реалізує інтерфейс».
Це логічна пастка. Виклик методу допускає зручності на кшталт неявного взяття адреси у змінної, для якої доступна адреса. Реалізація інтерфейсу перевіряється за method set суворіше. Якщо тримати це в голові, зникає відчуття «компілятор проти мене».
Помилка №3: «Зробив value receiver у методі, який має змінювати стан, і здивувався, що нічого не змінюється».
Це зворотний бік медалі: ви обрали ресівер T, метод чесно змінює копію, а початкове значення лишилося тим самим. Такий баг особливо неприємний, коли всередині методу багато логіки і здається, що «ну він же точно щось зробив». На практиці, якщо метод змінює поля структури або має змінювати внутрішній слайс чи мапу, майже завжди потрібен *T.
Помилка №4: «Зловив typed nil в інтерфейсі й перевіряю iface == nil як ознаку відсутності значення».
Найчастіше це трапляється з error: функція повертає (*MyErr)(nil) як error, а викликач отримує err != nil і думає, що операція провалилася. Це не напряму про method set, але типово виникає там, де методи на *T, а отже nil стає допустимим «значенням» конкретного типу. Про цю пастку окремо попереджають у матеріалах про помилки й контракт error.
Помилка №5: «Роблю тип для інтерфейсу, а потім додаю метод із pointer receiver — і все “зламалося”».
Таке трапляється, коли тип раніше реалізовував інтерфейс значенням, а ви додали або змінили метод так, що він став pointer receiver. У результаті T перестав реалізовувати інтерфейс, і в коді починає вимагатися *T. Це не «поганий Go», а зміна API-контракту. Тому ресівер — частина дизайну: змінювати його потрібно усвідомлено, особливо якщо тип уже використовується через інтерфейси.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ