null

Go вместе изучать Go. Часть 4

Введение

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

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

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

Что такое метод в Go?

Метод в Go — это функция со специальным аргументом-получателем (receiver). Получатель появляется в собственном списке аргументов между ключевым словом func и названием метода.

Вот простой пример:

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
	v := Vertex{3, 4}
	fmt.Println(v.Abs())
}

// Вывод: 5

Здесь (v Vertex) — это получатель метода. Это означает, что метод Abs() принадлежит типу Vertex и может быть вызван на переменной этого типа через точку: v.Abs().

Почему это важно? Методы позволяют связать функциональность с конкретным типом данных, делая код более организованным и интуитивным. Вместо того чтобы вызывать Abs(v), мы вызываем v.Abs(), что лучше читается и отражает объектно-ориентированный стиль.

Методы — это просто функции с получателем

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

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func Abs(v Vertex) float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
	v := Vertex{3, 4}
	fmt.Println(Abs(v))
}

// Вывод: 5

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

Методы на пользовательских типах

Методы можно определять не только на структурах, но и на любых типах, определённых в том же пакете. Например, можно создать метод на базовом числовом типе:

package main

import (
	"fmt"
	"math"
)

type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

func main() {
	f := MyFloat(-math.Sqrt2)
	fmt.Println(f.Abs())
}

// Вывод: 1.4142135623730951

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

Получатели-указатели

Мы подошли к важному аспекту методов в Go — различию между получателем-значением и получателем-указателем.

Получатель-значение означает, что метод работает с копией исходного значения:

func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

Получатель-указатель означает, что метод работает с указателем на исходное значение и может его изменять:

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

Давайте рассмотрим полный пример, чтобы увидеть разницу:

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

func main() {
	v := Vertex{3, 4}
	v.Scale(10)
	fmt.Println(v.Abs())
}

// Вывод: 50

Метод Abs() с получателем-значением просто вычисляет длину вектора. Метод Scale() с получателем-указателем изменяет координаты исходной структуры. Если бы мы использовали значение-получатель для Scale(), изменения затронули бы только копию, а исходная структура осталась бы неизменной.

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

Автоматическое разыменование указателей

Go предоставляет удобство — методы с получателем-указателем можно вызывать на значениях, и Go автоматически создаст указатель:

var v Vertex
v.Scale(5)  // Go автоматически интерпретирует это как (&v).Scale(5)

Аналогично, методы с получателем-значением можно вызывать на указателях:

var p *Vertex = &Vertex{3, 4}
p.Abs()  // Go автоматически интерпретирует это как (*p).Abs()

Вот полный пример, демонстрирующий обе ситуации:

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func (v Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

func main() {
	v := Vertex{3, 4}
	v.Scale(2)        // OK: v — значение, но метод имеет получателя-указатель
	
	p := &Vertex{4, 3}
	p.Scale(3)        // OK: p — указатель на метод с получателем-указателем
	
	fmt.Println(v.Abs())     // OK: вызов Abs на значении
	fmt.Println(p.Abs())     // OK: Go автоматически разыменует указатель
}

// Вывод:
// 5
// 5

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

Когда использовать какой получатель?

1. Когда нужна модификация

Если метод должен изменить получатель, необходимо использовать получателя-указатель:

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

2. Эффективность

Если получатель — большая структура, использование получателя-указателя более эффективно, так как избегает копирования данных при каждом вызове метода:

type LargeStruct struct {
	Data [1000]float64
}

// Хорошо: получатель-указатель, избегаем копирования
func (ls *LargeStruct) Process() {
	// работаем со структурой
}

Консистентность

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

Вот пример хорошей практики:

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
	v := &Vertex{3, 4}
	fmt.Printf("До масштабирования: %+v, длина: %v\n", v, v.Abs())
	v.Scale(5)
	fmt.Printf("После масштабирования: %+v, длина: %v\n", v, v.Abs())
}

// Вывод:
// До масштабирования: &{X:3 Y:4}, длина: 5
// После масштабирования: &{X:15 Y:20}, длина: 25

Обратите внимание, что оба метода используют получателя-указатель, что обеспечивает консистентность и позволяет методу Abs() эффективно работать с потенциально большими структурами.

Заключение: Чему мы научились?

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

1. Методы — это функции с получателем. Go предоставляет синтаксический сахар для вызова функций как методов, что улучшает читаемость.

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

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

4. Консистентность важнее универсальности. Придерживайтесь одного стиля получателей для всех методов выбранного типа.


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

Вперед