Определение методов в Go

Вступление

Functions позволяет организовать логику в повторяющиеся процедуры, которые могут использовать разные аргументы при каждом запуске. В процессе определения функций вы часто обнаруживаете, что несколько функций могут работать с одним и тем же фрагментом данных каждый раз. Go распознает этот шаблон и позволяет вам определять специальные функции, называемыеmethods, цель которых - работать с экземплярами определенного типа, называемогоreceiver. Добавление методов к типам позволяет сообщать не только о том, что это за данные, но и о том, как эти данные следует использовать.

Определение метода

Синтаксис определения метода аналогичен синтаксису определения функции. Единственное отличие состоит в добавлении дополнительного параметра после ключевого словаfunc для указания получателя метода. Получатель - это объявление типа, для которого вы хотите определить метод. В следующем примере определяется метод для типа структуры:

package main

import "fmt"

type Creature struct {
    Name     string
    Greeting string
}

func (c Creature) Greet() {
    fmt.Printf("%s says %s", c.Name, c.Greeting)
}

func main() {
    sammy := Creature{
        Name:     "Sammy",
        Greeting: "Hello!",
    }
    Creature.Greet(sammy)
}

Если вы запустите этот код, вывод будет:

OutputSammy says Hello!

Мы создали структуру под названиемCreature с полямиstring дляName иGreeting. Для этогоCreature определен единственный метод,Greet. В объявлении получателя мы присвоили экземплярCreature переменнойc, чтобы мы могли ссылаться на поляCreature при сборке приветственного сообщения вfmt.Printf. .

В других языках получатель вызовов методов обычно упоминается по ключевому слову (например, this илиself). Go считает, что получатель является переменной, как и любая другая, поэтому вы можете называть ее как угодно. Стиль, предпочтительный сообществом для этого параметра, является строчной версией первого символа типа получателя. В этом примере мы использовалиc, потому что тип приемника былCreature.

В телеmain мы создали экземплярCreature и указали значения для его полейName иGreeting. Мы вызвали здесь методGreet, объединив имя типа и имя метода с. и указав экземплярCreature в качестве первого аргумента.

Go предоставляет другой, более удобный способ вызова методов для экземпляров структуры, как показано в этом примере:

package main

import "fmt"

type Creature struct {
    Name     string
    Greeting string
}

func (c Creature) Greet() {
    fmt.Printf("%s says %s", c.Name, c.Greeting)
}

func main() {
    sammy := Creature{
        Name:     "Sammy",
        Greeting: "Hello!",
    }
    sammy.Greet()
}

Если вы запустите это, результат будет таким же, как в предыдущем примере:

OutputSammy says Hello!

Этот пример идентичен предыдущему, но на этот раз мы использовалиdot notation для вызова методаGreet, используяCreature, хранящийся в переменнойsammy, в качестве получателя. Это сокращенное обозначение вызова функции в первом примере. Стандартная библиотека и сообщество Go предпочитают этот стиль настолько, что вы редко увидите стиль вызова функции, показанный ранее.

Следующий пример показывает одну причину, почему точечные обозначения более распространены:

package main

import "fmt"

type Creature struct {
    Name     string
    Greeting string
}

func (c Creature) Greet() Creature {
    fmt.Printf("%s says %s!\n", c.Name, c.Greeting)
    return c
}

func (c Creature) SayGoodbye(name string) {
    fmt.Println("Farewell", name, "!")
}

func main() {
    sammy := Creature{
        Name:     "Sammy",
        Greeting: "Hello!",
    }
    sammy.Greet().SayGoodbye("gophers")

    Creature.SayGoodbye(Creature.Greet(sammy), "gophers")
}

Если вы запустите этот код, вывод будет выглядеть так:

OutputSammy says Hello!!
Farewell gophers !
Sammy says Hello!!
Farewell gophers !

Мы изменили предыдущие примеры, чтобы ввести другой метод с именемSayGoodbye, а также изменилиGreet, чтобы он возвращалCreature, чтобы мы могли вызывать дополнительные методы в этом экземпляре. В телеmain мы вызываем методыGreet иSayGoodbye для переменнойsammy сначала с использованием точечной нотации, а затем с использованием функционального стиля вызова.

Оба стиля выдают одинаковые результаты, но пример с точечной нотацией гораздо удобнее для чтения. Цепочка точек также сообщает нам последовательность, в которой будут вызываться методы, где функциональный стиль инвертирует эту последовательность. Добавление параметра к вызовуSayGoodbye еще больше скрывает порядок вызовов методов. Четкость нотации с точками является причиной того, что это предпочтительный стиль для вызова методов в Go, как в стандартной библиотеке, так и среди сторонних пакетов, которые вы найдете во всей экосистеме Go.

Определение методов для типов, в отличие от определения функций, работающих с определенным значением, имеет другое особое значение для языка программирования Go. Методы являются основной концепцией интерфейсов.

Интерфейсы

Когда вы определяете метод для любого типа в Go, этот метод добавляется кmethod set типа. Набор методов представляет собой набор функций, связанных с этим типом в качестве методов и используемых компилятором Go, чтобы определить, можно ли назначить некоторый тип переменной с типом интерфейса. interface type - это спецификация методов, используемых компилятором, чтобы гарантировать, что тип предоставляет реализации для этих методов. Любой тип, который имеет методы с тем же именем, теми же параметрами и теми же возвращаемыми значениями, что и в определении интерфейса, называетсяimplement этого интерфейса и может быть назначен переменным с этим типом интерфейса. Ниже приводится определение интерфейсаfmt.Stringer из стандартной библиотеки:

type Stringer interface {
  String() string
}

Чтобы тип мог реализовать интерфейсfmt.Stringer, он должен предоставить методString(), который возвращаетstring. Реализация этого интерфейса позволит печатать ваш тип точно так, как вы хотите (иногда это называется «красиво напечатано»), когда вы передаете экземпляры вашего типа функциям, определенным в пакетеfmt. В следующем примере определяется тип, который реализует этот интерфейс:

package main

import (
    "fmt"
    "strings"
)

type Ocean struct {
    Creatures []string
}

func (o Ocean) String() string {
    return strings.Join(o.Creatures, ", ")
}

func log(header string, s fmt.Stringer) {
    fmt.Println(header, ":", s)
}

func main() {
    o := Ocean{
        Creatures: []string{
            "sea urchin",
            "lobster",
            "shark",
        },
    }
    log("ocean contains", o)
}

Когда вы запустите код, вы увидите следующее:

Outputocean contains : sea urchin, lobster, shark

В этом примере определяется новый тип структуры с именемOcean. Ocean называетсяimplement интерфейсомfmt.Stringer, потому чтоOcean определяет метод с именемString, который не принимает параметров и возвращаетstring. Вmain мы определили новыйOcean и передали его функцииlog, которая сначала выводит на печатьstring, а затем все, что реализуетfmt.Stringerс. Компилятор Go позволяет нам передавать здесьo, потому чтоOcean реализует все методы, запрошенныеfmt.Stringer. Вlog мы используемfmt.Println, который вызывает методString дляOcean, когда он встречаетfmt.Stringer в качестве одного из своих параметров.

ЕслиOcean не предоставил методString(), Go выдаст ошибку компиляции, потому что методlog запрашиваетfmt.Stringer в качестве аргумента. Ошибка выглядит так:

Outputsrc/e4/main.go:24:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log:
        Ocean does not implement fmt.Stringer (missing String method)

Go также будет следить за тем, чтобы предоставленный методString() в точности совпадает с методом, запрошенным интерфейсомfmt.Stringer. Если это не так, он выдаст ошибку, которая выглядит следующим образом:

Outputsrc/e4/main.go:26:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log:
        Ocean does not implement fmt.Stringer (wrong type for String method)
                have String()
                want String() string

В приведенных выше примерах мы определили методы для получателя значения. То есть, если мы используем функциональный вызов методов, первым параметром, относящимся к типу, для которого был определен метод, будет значение этого типа, а неpointer. Следовательно, любые изменения, которые мы вносим в экземпляр, предоставленный методу, будут отброшены, когда метод завершит выполнение, потому что полученное значение является копией данных. Также возможно определить методы на приемнике указателя на тип.

Приемник указателя

Синтаксис определения методов в получателе указателя практически идентичен определению методов в получателе значения. Разница заключается в том, что в объявлении получателя перед именем типа ставится звездочка (*). В следующем примере определяется метод получения указателя на тип:

package main

import "fmt"

type Boat struct {
    Name string

    occupants []string
}

func (b *Boat) AddOccupant(name string) *Boat {
    b.occupants = append(b.occupants, name)
    return b
}

func (b Boat) Manifest() {
    fmt.Println("The", b.Name, "has the following occupants:")
    for _, n := range b.occupants {
        fmt.Println("\t", n)
    }
}

func main() {
    b := &Boat{
        Name: "S.S. DigitalOcean",
    }

    b.AddOccupant("Sammy the Shark")
    b.AddOccupant("Larry the Lobster")

    b.Manifest()
}

При запуске этого примера вы увидите следующий вывод:

OutputThe S.S. DigitalOcean has the following occupants:
     Sammy the Shark
     Larry the Lobster

В этом примере определен типBoat сName иoccupants. Мы хотим, чтобы код в других пакетах добавлял агентов только с методомAddOccupant, поэтому мы сделали полеoccupants неэкспортированным, указав первую букву имени поля в нижнем регистре. Мы также хотим убедиться, что вызовAddOccupant приведет к изменению экземпляраBoat, поэтому мы определилиAddOccupant на приемнике указателя. Указатели действуют как ссылки на конкретный экземпляр типа, а не как копия этого типа. Зная, чтоAddOccupant будет вызываться с использованием указателя наBoat, гарантирует, что любые изменения сохранятся.

Вmain мы определяем новую переменнуюb, которая будет содержать указатель наBoat (*Boat). Мы дважды вызываем методAddOccupant для этого экземпляра, чтобы добавить двух пассажиров. МетодManifest определяется для значенияBoat, потому что в его определении получатель указан как(b Boat). Вmain мы все еще можем вызватьManifest, потому что Go может автоматически разыменовать указатель для получения значенияBoat. b.Manifest() здесь эквивалентно(*b).Manifest().

Независимо от того, определен ли метод на получателе указателя или на получателе значения, это имеет важные последствия при попытке присвоить значения переменным, которые являются типами интерфейса.

Приемники и интерфейсы

Когда вы присваиваете значение переменной с типом интерфейса, компилятор Go проверяет набор методов присваиваемого типа, чтобы убедиться, что он имеет методы, ожидаемые интерфейсом. Наборы методов для получателя указателя и получателя значения различны, потому что методы, которые получают указатель, могут модифицировать свой получатель там, где те, которые получают значение, не могут.

В следующем примере показано определение двух методов: один для получателя указателя типа и для получателя его значения. Однако только приемник указателя сможет удовлетворить интерфейс, также определенный в этом примере:

package main

import "fmt"

type Submersible interface {
    Dive()
}

type Shark struct {
    Name string

    isUnderwater bool
}

func (s Shark) String() string {
    if s.isUnderwater {
        return fmt.Sprintf("%s is underwater", s.Name)
    }
    return fmt.Sprintf("%s is on the surface", s.Name)
}

func (s *Shark) Dive() {
    s.isUnderwater = true
}

func submerge(s Submersible) {
    s.Dive()
}

func main() {
    s := &Shark{
        Name: "Sammy",
    }

    fmt.Println(s)

    submerge(s)

    fmt.Println(s)
}

Когда вы запустите код, вы увидите следующее:

OutputSammy is on the surface
Sammy is underwater

В этом примере определен интерфейс с именемSubmersible, ожидающий типов, имеющих методDive(). Затем мы определили типShark с полемName и методомisUnderwater для отслеживания состоянияShark. Мы определили методDive() на приемнике указателя наShark, который изменилisUnderwater наtrue. Мы также определили методString() приемника значения, чтобы он мог чисто распечатать состояниеShark с помощьюfmt.Println, используя интерфейсfmt.Stringer, принятыйfmt.Println, которые мы рассмотрели ранее. Мы также использовали функциюsubmerge, которая принимает параметрSubmersible.

Использование интерфейсаSubmersible вместо*Shark позволяет функцииsubmerge зависеть только от поведения, обеспечиваемого типом. Это делает функциюsubmerge более пригодной для повторного использования, потому что вам не придется писать новые функцииsubmerge дляSubmarine, aWhale или любых других будущих водных обитателей, которых у нас нет. я еще не думал об этом. Пока они определяют методDive(), их можно использовать с функциейsubmerge.

Вmain мы определили переменнуюs, которая является указателем наShark, и сразу напечаталиs сfmt.Println. Это показывает первую часть вывода,Sammy is on the surface. Мы передалиs вsubmerge, а затем снова вызвалиfmt.Println сs в качестве аргумента, чтобы увидеть вторую часть вывода,Sammy is underwater.

Если мы изменимs наShark, а не на*Shark, компилятор Go выдаст ошибку:

Outputcannot use s (type Shark) as type Submersible in argument to submerge:
    Shark does not implement Submersible (Dive method has pointer receiver)

Компилятор Go любезно сообщает нам, чтоShark действительно имеет методDive, он просто определен на приемнике указателя. Когда вы видите это сообщение в своем собственном коде, исправление заключается в передаче указателя на тип интерфейса с помощью оператора& перед переменной, которой назначен тип значения.

Заключение

Объявление методов в Go в конечном счете ничем не отличается от определения функций, которые получают различные типы переменных. Применяются те же правилаworking with pointers. Go предоставляет некоторые удобства для этого чрезвычайно общего определения функции и собирает их в наборы методов, которые могут быть обоснованы типами интерфейса. Эффективное использование методов позволит вам работать с интерфейсами в вашем коде, чтобы улучшить тестируемость и оставить лучшую организацию для будущих читателей вашего кода.

Если вы хотите узнать больше о языке программирования Go в целом, ознакомьтесь с нашимиHow To Code in Go series.