Перейти до змісту

Методи

Метод — це функція з отримувачем (receiver) — типізованим параметром, який з'являється між func і назвою методу. Методи надають визначеному типу певну поведінку.

type Celsius float64

func (c Celsius) Fahrenheit() float64 {
    return float64(c)*9/5 + 32
}

func main() {
    c := Celsius(100)
    fmt.Println(c.Fahrenheit())     // 212
}

Отримувач (c Celsius) — це звичайний параметр; єдина відмінність — його позиція. Всередині тіла методу c поводиться як будь-яка інша змінна типу Celsius.

З досвіду Python: ≈ метод класу, з однією тонкою відмінністю — класу немає. Отримувач — це просто додатковий параметр, який компілятор прив'язує до конкретного типу. Ви також самі даєте йому ім'я — ніякого неявного self.

Отримувачі-значення

func (c Celsius) — це отримувач-значення (value receiver). Метод отримує копію значення. Зміна отримувача всередині методу не впливає на оригінал.

type Counter int

func (c Counter) Inc() {        // отримувач-значення — працює з копією
    c++
}

func main() {
    var n Counter = 0
    n.Inc()
    n.Inc()
    fmt.Println(n)              // 0 — Inc жодного разу не торкнувся n у викликача
}

Використовуйте отримувач-значення, коли:

  • Метод не потребує змінювати отримувач.
  • Отримувач невеликий (визначений тип на основі примітиву, невелика структура).

Отримувачі-вказівники

func (c *Counter) — це отримувач-вказівник (pointer receiver). Метод отримує вказівник на оригінал, і записи через нього змінюють значення у викликача.

type Counter int

func (c *Counter) Inc() {       // отримувач-вказівник — записує через вказівник
    *c++
}

func main() {
    var n Counter = 0
    n.Inc()
    n.Inc()
    fmt.Println(n)              // 2
}

Використовуйте отримувач-вказівник, коли:

  • Метод повинен змінювати отримувач.
  • Отримувач великий (структура з багатьма полями, яку не хочеться копіювати при кожному виклику).
  • Структура містить поле, яке не можна копіювати (наприклад, sync.Mutex).
  • Узгодженість: якщо хоча б один метод типу потребує отримувача-вказівника, зробіть всі методи з отримувачами-вказівниками, щоб набір методів типу був узгодженим.

Nil-отримувач — це нормальний випадок

Виклик методу з отримувачем-вказівником на nil-вказівнику — не помилка. Сам виклик цілком коректний; аварійно завершується лише розіменування nil-вказівника. Тож метод може перевірити nil і трактувати його як осмислений стан — саме так рекурсивні структури обходяться без nil-перевірок у кожному місці виклику.

type Tree struct {
    Val         int
    Left, Right *Tree
}

func (t *Tree) Sum() int {
    if t == nil {        // nil-отримувач — це нормальний випадок, а не баг
        return 0
    }
    return t.Val + t.Left.Sum() + t.Right.Sum()
}

t := &Tree{Val: 1, Left: &Tree{Val: 2}}
fmt.Println(t.Sum())     // output: 3

var empty *Tree
fmt.Println(empty.Sum()) // output: 0

t.Left.Sum() працює, навіть коли Left є nil, — саме це дає рекурсії завершитися без перевірки кожної гілки перед спуском.

З погляду Python: виклик методу на None — це завжди AttributeError. У Go отримувач — це просто аргумент, тож nil-вказівник доходить до методу цілим і неушкодженим; ви самі вирішуєте, що це означає.

Автоматичне взяття адреси та розіменування

Вам не потрібно писати (&n).Inc() чи (*p).Inc(). Go сам підставляє & або *, коли в місці виклику є значення одного виду, а метод очікує інший.

type Counter int
func (c *Counter) Inc() { *c++ }

func main() {
    var n Counter = 0
    n.Inc()                     // Go непомітно переписує як (&n).Inc()
    p := &n
    p.Inc()                     // вже вказівник; переписування не потрібне
    fmt.Println(n)              // 2
}

Умова для переписування: значення повинно бути адресованим (іменована змінна, поле адресованої структури або результат * чогось). Елемент мапи або значення, що повертає функція, не є адресованим.

type Counter int
func (c *Counter) Inc() { *c++ }

m := map[string]Counter{"x": 0}
m["x"].Inc()                    // compile error: cannot call pointer method Inc on Counter

Рішення: зчитати в локальну змінну, змінити, записати назад; або змінити мапу так, щоб вона зберігала значення *Counter.

Пастка циклу range

Те саме правило адресовності спричиняє найпоширенішу тиху помилку з отримувачами-вказівниками. for _, x := range s прив'язує x до копії кожного елемента, тож зміна нічого не робить — і це чисто компілюється:

type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }

cs := []Counter{{}, {}}

for _, c := range cs {
    c.Inc()          // c — копія: компілюється, нічого не змінює
}
fmt.Println(cs)      // output: [{0} {0}]

for i := range cs {
    cs[i].Inc()      // cs[i] адресовний — змінює зріз
}
fmt.Println(cs)      // output: [{1} {1}]

Звертайтеся за індексом (або зробіть []*Counter), коли зміна має зберегтися.

З погляду Python: for x in lst дає сам об'єкт, тож x.inc() зберігається. Go дає копію, і ніщо вас не попереджає.

Методи на нестruct-типах

Тип отримувача може бути будь-яким визначеним типом у вашому пакеті — не лише структурами.

type Names []string

func (n Names) Contains(s string) bool {
    for _, x := range n {
        if x == s {
            return true
        }
    }
    return false
}

func main() {
    n := Names{"Ada", "Linus"}
    fmt.Println(n.Contains("Ada"))      // true
    fmt.Println(n.Contains("Grace"))    // false
}

Саме так до типів зрізу, мапи, функції або примітивних типів прикріплюється поведінка. Випадок із функцією — найнесподіваніший: функціональний тип може мати методи:

type Handler func(string) string

func (h Handler) Twice(s string) string { return h(h(s)) }

var exclaim Handler = func(s string) string { return s + "!" }
fmt.Println(exclaim.Twice("go"))   // output: go!!

exclaim — це значення-функція, та .Twice викликає її двічі: отримувач h і є цією функцією. (Саме так http.HandlerFunc перетворює звичайну функцію на інтерфейс.)

Обмеження «той самий пакет»

Тип отримувача повинен бути визначений у тому ж пакеті, що й метод:

package mine
func (t time.Time) Foo() { ... }        // compile error
func (i int) Double() int { ... }       // compile error

Не можна прикріпити методи до int, time.Time або чогось іншого з іншого пакету. Рішення таке саме, як у власні типи: визначте власний тип з чужим типом як базовим типом і прикріпіть метод до нього.

type Stamp time.Time

func (s Stamp) Unix() int64 {
    return time.Time(s).Unix()
}

Набори методів — попередній огляд

Кожен тип має набір методів (method set): методи, які можна викликати на значеннях цього типу. Правило:

  • Набір методів T містить усі методи з типом отримувача T.
  • Набір методів *T містить усі методи з типом отримувача *T та всі методи з типом отримувача T.

На практиці ви рідко явно думаєте про набори методів — аж поки не починаєте реалізовувати інтерфейси. Інтерфейс (детально розглядається в окремій статті) — це іменований набір сигнатур методів; тип задовольняє інтерфейс, коли його набір методів містить усі ці методи. Інтерфейси розглядатимуться окремо; запам'ятайте правило:

Якщо будь-який метод має отримувач-вказівник, тільки *T (не T) задовольняє інтерфейси, що включають цей метод.

type Counter int
func (c Counter) Get() int { return int(c) }   // отримувач-значення
func (c *Counter) Inc()    { *c++ }             // отримувач-вказівник

type Incrementer interface{ Inc() }

var c Counter = 5
c.Inc()                  // ок: c адресовна, тож Go бере &c за вас
fmt.Println(c.Get())     // output: 6

var i Incrementer = &c   // лише *Counter задовольняє Incrementer
i.Inc()
fmt.Println(c.Get())     // output: 7

// var bad Incrementer = c   // compile error: Counter does not implement
//                           // Incrementer (method Inc has pointer receiver)

Виклик c.Inc() напряму працює, бо c — адресовна змінна: Go нишком переписує це на (&c).Inc(). Але зберігання значення в інтерфейсі такої допомоги не отримує, тож Incrementer задовольняє лише &c.

Значення методу та вирази методу

Метод можна від'єднати від його отримувача двома способами.

Значення методу — отримувач вбудований

type Celsius float64
func (c Celsius) Fahrenheit() float64 { return float64(c)*9/5 + 32 }

c := Celsius(100)
f := c.Fahrenheit                       // *значення* методу — c захоплено
fmt.Println(f())                        // 212

f має тип func() float64. Отримувач c захоплений замиканням.

Отримувач обчислюється в момент створення значення методу, а не під час виклику. З отримувачем-значенням це означає, що ви захоплюєте копію, заморожену в ту мить; з отримувачем-вказівником ви захоплюєте адресу, тож подальші зміни видно.

type Counter struct{ n int }
func (c Counter) Get() int { return c.n }   // отримувач-значення
func (c *Counter) Inc()    { c.n++ }         // отримувач-вказівник

c := Counter{}
get := c.Get      // копіює c просто зараз
inc := c.Inc      // захоплює &c

inc()
inc()
fmt.Println(get(), c.Get())   // output: 0 2

get() усе ще повідомляє 0 — він читає копію, зроблену до інкрементів. Це класична помилка, коли значення методу зберігають у зворотному виклику чи в defer.

З погляду Python: obj.method — це зв'язаний метод, який завжди бачить поточний стан. Значення методу Go з отримувачем-значенням — ні.

Вираз методу — отримувач є першим параметром

g := Celsius.Fahrenheit                 // *вираз* методу
fmt.Println(g(Celsius(100)))            // 212

g має тип func(Celsius) float64. Отримувач стає явним першим параметром у місці виклику.

Для методу з отримувачем-вказівником треба назвати тип-вказівник — і компілятор вам про це скаже:

inc := (*Counter).Inc     // тип: func(*Counter)
c := Counter{}
inc(&c)
inc(&c)
fmt.Println(c.n)          // output: 2

// f := Counter.Inc
// compile error: invalid method expression Counter.Inc
//   (needs pointer receiver (*Counter).Inc)

Значення методу набагато поширеніші в реальному коді; вирази зустрічаються в допоміжних бібліотеках і тестах.

Вбудовування та просування методів

Якщо структура вбудовує інший тип (поле з назвою типу і без назви поля), методи вбудованого типу стають доступними для виклику на зовнішній структурі.

type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix, msg) }

type Server struct {
    Logger              // вбудовано — без назви поля
    addr string
}

func main() {
    s := Server{Logger: Logger{prefix: "[srv]"}, addr: ":8080"}
    s.Log("starting")                   // [srv] starting
}

s.Log(...) — це скорочення для s.Logger.Log(...). Метод просунуто до Server. Компонуйте поведінку за допомогою вбудовування; в Go немає наслідування.

Що просування додає до набору методів

Просування підпорядковується тому самому поділу вказівник/значення, що й звичайні методи, лише на рівень глибше. Вбудовування значення T просуває методи T з отримувачем-значенням і до S, і до *S, але методи T з отримувачем-вказівником потрапляють лише до *S:

type Logger struct{ n int }
func (l *Logger) Log() { l.n++ }      // отримувач-вказівник

type Server struct{ Logger }          // вбудовує значення

type Loggable interface{ Log() }

var _ Loggable = &Server{}   // ок
var _ Loggable = Server{}
// compile error: Server does not implement Loggable (method Log has pointer receiver)

Уперше ця помилка збиває з пантелику, бо Log навіть не оголошено на Server — його просунуто. Виправлення — використовувати *Server.

Коли два вбудовані типи стикаються

Вбудувати два типи, що надають метод з однаковою назвою, оголосити можна. Помилка виникає лише там, де ви його справді вибираєте:

type Reader struct{}
func (Reader) Close() string { return "reader" }

type Writer struct{}
func (Writer) Close() string { return "writer" }

type File struct {
    Reader
    Writer
}

var f File
fmt.Println(f.Close())
// compile error: ambiguous selector f.Close

Менша глибина перемагає, тож оголошення Close безпосередньо на File розв'язує конфлікт — і саме так у Go працює «перевизначення». Внутрішні лишаються доступними за іменем:

func (File) Close() string { return "file" }

fmt.Println(f.Close(), f.Reader.Close())   // output: file reader

Короткий довідник

Потреба Запис
Метод, що читає отримувач func (c Celsius) F() float64 (отримувач-значення)
Метод, що змінює отримувач func (c *Counter) Inc() (отримувач-вказівник)
Виклик методу з отримувачем-вказівником на змінній-значенні Просто пишіть n.Inc() — Go підставить &
Метод на зрізі / мапі / типі на основі int Визначте type X []int, потім func (x X) Foo() {}
Прив'язати метод до фіксованого отримувача f := c.Fahrenheit (значення методу)
Використати метод як незв'язану функцію g := Celsius.Fahrenheit (вираз методу)

Джерела