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

Функции позволяют организовать логику в повторяющиеся процедуры, которые могут использовать разные аргументы при каждом запуске. Но в процессе определения функций часто становится очевидно, что каждый раз несколько функций могут работать с одним и тем же фрагментом данных. 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)
}

Запустив этот код, вы получите:

Sammy says Hello!

Мы создали структуру под названием Creature со строковыми полями 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()
}

Эта программа выдаст такой же результат, как и предыдущая:

Sammy 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")

}

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

Sammy 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(), который возвращает строку. Реализация этого интерфейса позволит выводить ваш тип на экран точно так, как вы этого хотите (это называется “pretty-printed”), при передаче вашего типа в функции, определенные в пакете 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)
}

Запустив код, вы получите такой результат:

ocean contains : sea urchin, lobster, shark

Этот пример определяет новую структуру Ocean. Она реализует интерфейс fmt.Stringer, потому что Ocean определяет метод String, который не принимает параметров и возвращает строку. В main мы определили структуру Ocean и передали ее функции log; эта функция сначала принимает строку, которую нужно вывести, а за ней — что-либо, что реализует fmt.Stringer. Компилятор Go позволяет передать здесь о, потому что структура Ocean реализует все методы, запрашиваемые интерфейсом fmt.Stringer. В функции log мы используем fmt.Println, что вызывает метод String структуры Ocean, если среди параметров встречается fmt.Stringer.

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

src/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. Если это не так, он выдаст ошибку, которая выглядит следующим образом:

src/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

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

Читайте также: Как работают указатели в Go

Ресивер указателя

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

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",
}
b.AddOccupant("Sammy the Shark")
b.AddOccupant("Larry the Lobster")
b.Manifest()
}

Запустив этот код, вы получите такой результат:

The S.S has the following occupants:
Sammy the Shark
Larry the Lobster

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

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

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

Ресивер указателя и интерфейсы

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

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

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)
}

Запустив код, вы получите такой вывод:

Sammy 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 выдаст ошибку:

cannot 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 по большому счету ничем не отличается от определения функций, которые получают различные типы переменных. Правила работы с указателями остаются без изменений.

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

Tags: ,

Добавить комментарий