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

Введение

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

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

Синтаксис для определения метода аналогичен синтаксису для определения функции. Единственная разница — это добавление дополнительного параметра после ключевого слова 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 для переменной с, чтобы мы могли обращаться к полям 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! 

Этот пример идентичен предыдущему, но в этот раз мы использовали запись через точку для вызова метода 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, этот метод добавляется в набор методов для типа. Набор методов — это коллекция функций, связанных с этим типом как методы и используемых компилятором Go для определения того, может ли определенный тип быть связан с переменной с типом интерфейса. Тип интерфейса — это спецификация методов, используемых компилятором для гарантии того, что тип обеспечивает реализацию этих методов. Любой тип, который имеет методы с тем же именем, теми же параметрами и теми же возвращаемыми значениями, что и методы, которые находятся в определении интерфейса, реализует этот интерфейс и может привязываться к переменным с данным типом интерфейса. Ниже приводится определение интерфейса fmt.Stringer из стандартной библиотеки:

type Stringer interface {   String() string } 

Чтобы тип реализовывал интерфейс fmt.Stringer, он должен иметь метод 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, как сказано, реализует интерфейс fmt.Stringer, поскольку Ocean определяет метод под названием String, который не принимает никаких параметров и возвращает строку. В main мы определили новый экземпляр Ocean и передали его функции log, которая получает строку для вывода, после чего следует что-то, реализующее 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 

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

Получатели по указателю

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

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, Whale или любого будущего обитателя моря, которого у нас еще нет. Если они определяют метод 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 не отличается от определения функций, которые получают различные типы переменных. В этом случае применяются те же правила работы с указателями. Go предоставляет некоторые удобства для данного чрезвычайно распространенного определения функции и собирает эти методы в набор, которые могут быть отобраны по типам интерфейса. Использование методов эффективно позволяет вам работать с интерфейсами в вашем коде для облегчения тестирования и позволяет лучше организовать код для будущих читателей.

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