Метод представляет собой функцию, привязанную к конкретному типу. Методы позволяют связывать поведение и данные типа в самом типе, обеспечивая инкапсуляцию. Если вы знакомы с программированием на Python или С++/C#/Java, то методы в Go будут похожи на методы классов, за некоторыми исключениями.

Note

В Python, например, объект класса передаётся в метод через явно указанную первую переменную self. В Go для этого реализован отдельный синтаксис — метод должен быть объявлен в том же пакете, что и тип, к которому он привязывается. В Python же метод определяется в теле класса. С++ требует явного указания, к какому классу принадлежит метод, однако указатель на объект класса, для которого вызван метод, передаётся неявно через переменную this.

package main
 
import "fmt"
 
 // объявление типа
type MyType int 
 
// объявление метода
func (m MyType) String() string{
    return fmt.Sprintf("MyType: %d", m)      
}
 
func main() { 
    var m MyType = 5
    
    // вызов метода
    s := m.String()
    fmt.Println(s)
}

Синтаксис метода типа похож на синтаксис обычной функции, но добавляется получатель (receiver) после ключевого слова func. Можно сказать, что получатель — это ещё один аргумент функции.

Методы могут быть не только у типа данных «структура», что удивит людей, ранее писавших на других языках ООП. Так как методы определяются в том же пакете, что и тип, то методам доступны все неэкспортируемые элементы в этом пакете.

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

Приведём пример каноничной для#Go реализации перечисления (enum) с минимальным набором методов:

// DeliveryState — статус доставки и обработки сообщения.
type DeliveryState string
 
// Возможные значения перечисления DeliveryState.
const (
    DeliveryStatePending   DeliveryState = "pending"      // сообщение отправлено
    DeliveryStateAck       DeliveryState = "acknowledged" // сообщение получено
    DeliveryStateProcessed DeliveryState = "processed"    // сообщение обработано успешно
    DeliveryStateCanceled  DeliveryState = "canceled"     // обработка сообщения прервана
)
 
// IsValid проверяет валидность текущего значения типа DeliveryState.
func (s DeliveryState) IsValid() bool {
    switch s {
    case DeliveryStatePending, DeliveryStateAck, DeliveryStateProcessed, DeliveryStateCanceled:
        return true
    default:
        return false
    }
}
 
// String возвращает строковое представление типа DeliveryState.
func (s DeliveryState) String() string {
    return string(s)
}

Тип DeliveryState эквивалентен типу string, поэтому можно получить его экземпляр простым преобразованием типов. Приведём пример такого преобразования и применения функции валидации:

func HandleMsgDeliveryStatus(status DeliveryState) error {
    // проверка корректности enum-значения через вызов метода типа DeliveryState
    if !status.IsValid() {
        return fmt.Error("status: invalid")
    }
 
    // код обработки сообщения
 
    return nil
}
 
func main() {
    // приводим строку "fake" к типу DeliveryState
    if err := HandleMsgDeliveryStatus(DeliveryState("fake")); err != nil {
        panic(err)
    }
} 

Методы структур

Приведём ещё один пример использования методов. Методы определяются для пользовательских типов данных, и чаще всего в этой роли выступают структуры. В целом методы структур явно не отличаются от методов других типов, но есть нюансы, которые стоит разобрать.

Определим тип кольцевой буфер с минимальным набором методов и приведём пример кода:

// CircularBuffer реализует структуру данных «кольцевой буфер» для значений float64.
type CircularBuffer struct {
    values  []float64 // текущие значения буфера
    headIdx int       // индекс головы (первый непустой элемент)
    tailIdx int       // индекс хвоста (первый пустой элемент)
}
 
// GetCurrentSize возвращает текущую длину буфера.
func (b CircularBuffer) GetCurrentSize() int {
    if b.tailIdx < b.headIdx {
        return b.tailIdx + cap(b.values) - b.headIdx
    }
 
    return b.tailIdx - b.headIdx
}
 
// GetValues возвращает слайс текущих значений буфера, сохраняя порядок записи.
func (b CircularBuffer) GetValues() (retValues []float64) {
    for i := b.headIdx; i != b.tailIdx; i = (i + 1) % cap(b.values) {
        retValues = append(retValues, b.values[i])
    }
 
    return
}
 
// AddValue добавляет новое значение в буфер.
func (b *CircularBuffer) AddValue(v float64) {
    b.values[b.tailIdx] = v
    b.tailIdx = (b.tailIdx + 1) % cap(b.values)
    if b.tailIdx == b.headIdx {
        b.headIdx = (b.headIdx + 1) % cap(b.values)
    }
}
 
// NewCircularBuffer — конструктор типа CircularBuffer.
func NewCircularBuffer(size int) CircularBuffer {
    return CircularBuffer{values: make([]float64, size+1)}
}
 
func main() {
    buf := NewCircularBuffer(4)
    for i := 0; i < 6; i++ {
        if i > 0 {
            buf.AddValue(float64(i))
        }
        fmt.Printf("[%d]: %v\n", buf.GetCurrentSize(), buf.GetValues())
    }
}
[0]: [] 
[1]: [1] 
[2]: [1 2] 
[3]: [1 2 3] 
[4]: [1 2 3 4] 
[4]: [2 3 4 5]

В примере выше показано, что у метода может быть два варианта получателя:

  1. Получатель по значению (b CircularBuffer).
  2. Получатель по указателю (b* CircularBuffer).

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

Note

Вызов в обоих случаях одинаков. Однако при b.Method() для получателей по указателю компилятор фактически создаёт такой вызов (&b).Method(), то есть в метод передаётся указатель на объект, для которого вызывается метод.

У типа CircularBuffer есть два метода с получателем по значению (value receiver): GetCurrentSize, GetValues. Для этих методов получатель принимает вид func (b CircularBuffer).

Оба метода не изменяют состояние объекта с точки зрения логики типа. С точки зрения языка методы с таким получателем не могут изменить состояние объекта, который вызвал данный метод.

Переменная b (получатель метода) содержит копию экземпляра CircularBuffer, поэтому любое изменение полей b приведёт к изменению локальной (для метода) копии объекта. Если поле структуры имеет ссылочный тип, то изменение этого поля в локальной копии отразится на оригинальной переменной. Для примера добавим метод, который явно устанавливает значение элемента буфера (слайса values) по индексу:

// ForceSetValueByIdx выставляет значение буфера по индексу.
func (b CircularBuffer) ForceSetValueByIdx(idx int, v float64) {
    // лучше не использовать такой приём на практике, когда параметр метода
    // не указатель, а значение
    b.values[idx] = v
}
 
func main() {
    buf := NewCircularBuffer(4)
    buf.ForceSetValueByIdx(0, -1.0)
    buf.ForceSetValueByIdx(1, -2.0)
    fmt.Println(buf.values)
} 
[-1 -2 0 0 0] 

Почему так происходит? Если поле — это указатель или имеет ссылочный тип (map, chan, slice), то оно будет ссылаться на те же самые объекты и в копии переменной.

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

У типа CircularBuffer есть один метод с получателем по указателю (pointer receiver): AddValue. Получатель такого типа принимает вид func (b *CircularBuffer). Функция метода получает указатель на экземпляр типа и, как следствие, может изменять его поля.

Важно отметить, что методы с получателем по указателю могут быть не только у структур, но и у любых других пользовательских типов:

type IntSlice []int
 
func (s *IntSlice) Add(v int) {
    *s = append(*s, v)
}
 
func main() {
    s := make(IntSlice, 0)
    s.Add(1)
    s.Add(2)
    fmt.Println(s)
} 
[1 2] 

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

  • Вызов метода с получателем по значению для указателя на объект будет эквивалентен вызову (*b).Method().
  • Вызов метода с получателем по указателю для значения объекта будет эквивалентен вызову (&b).Method().

ООП и методы

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

Note

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

Функция как поле структуры

Для примера создадим структуру следующего вида:

package main
 
import "fmt"
 
type MyStruct struct {
    A   int
    Log func(s string)
}
 
func main() {
    var s = MyStruct{
        A:   1,
        Log: func(s string) { fmt.Println(s) },
    }
 
    s.Log("some string")
} 

Вызов функции-поля внешне не отличается от вызова метода, однако есть существенные особенности:

  1. Функция-поле не имеет доступа к вызвавшему её объекту, если он не передан в неё явно.
  2. Функция-поле может быть динамически переопределена во время работы программы. Это позволяет использовать, например, функции из других пакетов.
  3. Функция-поле может быть пустой. Тогда её вызов создаст панику.

Функция как поле структуры может использоваться для изменения поведения объекта на лету.

Передача метода как аргумента функции

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

Рассмотрим пример. У нас есть некоторая функция-обработчик, которая принимает число и функцию:

func Handle(num float64,  add func(float64)) {
  add(num)
} 

Создадим новый кольцевой буфер из примера выше:

buf := NewCircularBuffer(4)
 
// Теперь вызовем
Handle(1.0, buf.AddValue)
Handle(2.0, buf.AddValue)
Handle(3.0, buf.AddValue)
Handle(4.0, buf.AddValue)
fmt.Printf("[%d]: %v\n", buf.GetCurrentSize(), buf.GetValues())  

Здесь показан очень важный момент — метод был передан как функция в функцию-обработчик. При этом он сохранил привязку к конкретному экземпляру структуры, методом которой он является.

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

Обратите внимание, у типа CircularBuffer есть методы с получателями и по значению, и по указателю. Такое смешивание разных типов методов допускается стандартом языка, но не принято в Go-сообществе. Придерживайтесь соглашений, которым следует ваша команда разработчиков.

Если у объекта все методы только с получателем по значению и все поля неэкспортируемые, можно сказать, что этот объект неизменяем (immutable). И наоборот, объект изменяем (mutable), если все методы с получателем по указателю или одно из полей экспортируемое.

Наличие методов по указателю не обязывает вас создавать его экземпляры через указатель:

type MyType struct {
    value int
}
 
func (t *MyType) SetValue(v int) {
    t.value = v
}
 
func (t MyType) String() string {
    return fmt.Sprintf("Value: %d", t.value)
}
 
func main() {
    t := MyType{}
    // или
    t := &MyType{}
 
    t.SetValue(100)
    fmt.Println(t)
} 
Value: 100 

Таким образом, методы с получателем по указателю и по значению работают практически одинаково, за исключением того, что передаётся в функцию-метод — значение или указатель.

Note

Методы — это один из основных и, можно сказать, наиболее используемый инструмент в Go для построения сложных программ. При этом методы не являются членами класса, как в Python, и могут быть созданы для любого типа данных. Доступ к переменным из метода осуществляется через получателя.


📂 Go | Последнее изменение: 26.08.2024 19:39