Использование методов в Go

Завершено

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

Методы позволяют группировать функции и привязывать их к пользовательскому типу. Такой подход в Go аналогичен созданию класса в других языках программирования: он также позволяет реализовать определенные принципы модели объектно-ориентированного программирования (ООП), такие как встраивание, перегрузка и инкапсуляция.

Рассмотрение методов начнем с объявления одного из них.

Объявление методов

До сих пор из всех пользовательских типов Go вы использовали только структуры. В этом модуле вы узнаете, как с помощью методов можно расширить функциональность создаваемых структур.

Синтаксис объявления метода в общем виде выглядит так:

func (variable type) MethodName(parameters ...) {
    // method functionality
}

Однако прежде чем объявлять метод, нужно создать структуру. Предположим, вы хотите создать пакет для геометрических расчетов. В его составе должна быть структура, представляющая треугольник, под названием triangle. Для вычисления периметра этого треугольника вы хотите использовать метод. В Go его можно представить так:

type triangle struct {
    size int
}

func (t triangle) perimeter() int {
    return t.size * 3
}

Структура на вид обычная, но перед именем функции perimeter() стоит дополнительный параметр типа triangle. Этот получатель означает, что при использовании структуры можно вызвать функцию следующим образом:

func main() {
    t := triangle{3}
    fmt.Println("Perimeter:", t.perimeter())
}

При попытке вызвать функцию perimeter() обычным способом ничего не получится, так как согласно сигнатуре функции требуется ресивер. Единственным способом вызова этого метода является объявление структуры, которая предоставляет доступ к методу. Вы можете даже иметь то же имя для метода, если он принадлежит другой структуре. Например, можно объявить структуру square с методом perimeter():

package main

import "fmt"

type triangle struct {
    size int
}

type square struct {
    size int
}

func (t triangle) perimeter() int {
    return t.size * 3
}

func (s square) perimeter() int {
    return s.size * 4
}

func main() {
    t := triangle{3}
    s := square{4}
    fmt.Println("Perimeter (triangle):", t.perimeter())
    fmt.Println("Perimeter (square):", s.perimeter())
}

При выполнении приведенного выше кода ошибки не происходит и выводится следующий результат:

Perimeter (triangle): 9
Perimeter (square): 16

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

Указатели в методах

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

То же самое происходит, когда необходимо обновить переменную ресивера в методе. Например, предположим, что нужно создать метод для удвоения размеров треугольника. Для этого необходимо использовать указатель на переменную ресивера:

func (t *triangle) doubleSize() {
    t.size *= 2
}

Проверить работу этого метода можно так:

func main() {
    t := triangle{3}
    t.doubleSize()
    fmt.Println("Size:", t.size)
    fmt.Println("Perimeter:", t.perimeter())
}

При выполнении приведенного выше кода вы должны получить следующий результат:

Size: 6
Perimeter: 18

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

Объявление методов для других типов

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

Тем не менее есть обходной путь: вы можете создать пользовательский тип на основе базового, а затем использовать его как базовый. Например, предположим, что нужно создать метод для преобразования букв в строке из строчных в прописные. Он может выглядеть так:

package main

import (
    "fmt"
    "strings"
)

type upperstring string

func (s upperstring) Upper() string {
    return strings.ToUpper(string(s))
}

func main() {
    s := upperstring("Learning Go!")
    fmt.Println(s)
    fmt.Println(s.Upper())
}

При выполнении приведенного выше кода получается следующий результат:

Learning Go!
LEARNING GO!

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

Встраивание методов

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

Предположим, нужно создать еще одну структуру, представляющую треугольник, но на этот раз с логикой управления цветом. При этом будет использоваться ранее объявленная структура треугольника. Структура, представляющая цветной треугольник, будет выглядеть так:

type coloredTriangle struct {
    triangle
    color string
}

После этого можно инициализировать структуру coloredTriangle и вызвать метод perimeter() структуры triangle (и даже обратиться к ее полям):

func main() {
    t := coloredTriangle{triangle{3}, "blue"}
    fmt.Println("Size:", t.size)
    fmt.Println("Perimeter", t.perimeter())
}

Включите эти изменения в свою программу и посмотрите, как работает встраивание. При запуске программы с методом main(), аналогичным представленному выше, результат должен быть следующим:

Size: 3
Perimeter 9

Если вы знакомы с объектно-ориентированным языком программирования, например Java или C++, то можете решить, что структура triangle аналогична базовому классу, а coloredTriangle — подклассу (то есть вы имеете дело с наследованием), но это не так. На самом деле компилятор Go повышает уровень метода perimeter(), создавая метод-оболочку, который выглядит примерно так:

func (t coloredTriangle) perimeter() int {
    return t.triangle.perimeter()
}

Обратите внимание, что получатель здесь — coloredTriangle, а метод perimeter() вызывается для поля triangle. Радует то, что создавать такой метод не требуется. Это можно сделать, но в Go это происходит автоматически. Предыдущий пример мы привели только для иллюстрации.

Перегрузка методов

Давайте вернемся к примеру структуры triangle. Что если вам нужно изменить реализацию метода perimeter() для структуры coloredTriangle? Двух функций с одинаковыми именами быть не может. Однако, поскольку у методов есть дополнительный параметр (ресивер), их имена могут совпадать при условии, что они относятся к разным ресиверам. Использование этого различия заключается в том, как вы перегружаете методы.

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

func (t coloredTriangle) perimeter() int {
    return t.size * 3 * 2
}

Теперь, добавьте в метод main() следующий код, не изменяя ничего больше:

func main() {
    t := coloredTriangle{triangle{3}, "blue"}
    fmt.Println("Size:", t.size)
    fmt.Println("Perimeter", t.perimeter())
}

При выполнении этого кода результат будет другим:

Size: 3
Perimeter 18

Однако, если необходимо вызвать метод perimeter() для структуры triangle, это можно сделать явным образом:

func main() {
    t := coloredTriangle{triangle{3}, "blue"}
    fmt.Println("Size:", t.size)
    fmt.Println("Perimeter (colored)", t.perimeter())
    fmt.Println("Perimeter (normal)", t.triangle.perimeter())
}

После выполнения кода вы увидите следующий результат:

Size: 3
Perimeter (colored) 18
Perimeter (normal) 9

Как вы, возможно, заметили, в Go можно переопределить метод и при этом по-прежнему вызывать исходный метод.

Инкапсуляция в методах

Инкапсуляция означает, что метод недоступен вызывающей стороне (клиенту) объекта. Как правило, в других языках программирования перед именами методов ставится ключевое слово private или public. В Go достаточно начать идентификатор с прописной буквы, чтобы сделать метод открытым, или со строчной буквы, чтобы сделать его закрытым.

Инкапсуляция в Go действует только между пакетами. Другими словами, можно скрыть только сведения о реализации из другого пакета, а не самого пакета.

Чтобы испытать это на практике, создайте новый пакет geometry и переместите в него структуру triangle:

package geometry

type Triangle struct {
    size int
}

func (t *Triangle) doubleSize() {
    t.size *= 2
}

func (t *Triangle) SetSize(size int) {
    t.size = size
}

func (t *Triangle) Perimeter() int {
    t.doubleSize()
    return t.size * 3
}

Представленный выше пакет можно использовать так:

func main() {
    t := geometry.Triangle{}
    t.SetSize(3)
    fmt.Println("Perimeter", t.Perimeter())
}

Результат должен быть следующим:

Perimeter 18

Если вы попытаетесь обратиться к полю size или вызвать метод doubleSize() из функции main() следующим образом, произойдет сбой:

func main() {
    t := geometry.Triangle{}
    t.SetSize(3)
    fmt.Println("Size", t.size)
    fmt.Println("Perimeter", t.Perimeter())
}

При выполнении приведенного выше кода выводится следующая ошибка:

./main.go:12:23: t.size undefined (cannot refer to unexported field or method size)