GraphLMS

Основы
Начать
Глава 07 · Основы~14 мин чтения

Структуры и методы

О чём глава

Пока мы хранили данные в отдельных переменных: имя в одной, возраст в другой, баланс в третьей. Но в реальной программе эти значения связаны — они описывают одну сущность. Структура (struct) как раз и собирает несколько полей в один тип, чтобы про «пользователя» или «счёт» можно было говорить как про целое.

В этой главе мы научимся объявлять структуры и создавать их значения, привязывать к ним методы, разберёмся в главной развилке новичка — receiver по значению или по указателю, — и посмотрим на встраивание (embedding): способ Go переиспользовать поведение без классов и наследования.

Объявление структуры

Структура — это набор именованных полей, у каждого свой тип. Объявляют её через type ИмяТипа struct { ... }:

type Point struct {
    X int
    Y int
}

Теперь Point — полноценный тип, как int или string. Поля с одинаковым типом можно перечислить в одну строку: X, Y int. Имена полей с большой буквы экспортируются (видны из других пакетов), с маленькой — приватные для пакета. Это та же логика, что и у имён функций.

Литералы: создаём значения

Есть два способа записать значение структуры. По именам полей — самый читаемый и устойчивый к изменениям:

p := Point{X: 10, Y: 20}

И позиционный — поля идут строго в порядке объявления:

p := Point{10, 20}

Позиционный короче, но хрупкий: добавите новое поле в структуру — и все такие литералы перестанут компилироваться или, хуже, молча перепутают значения. Поэтому в коде почти всегда предпочитают форму по именам. К полям обращаются через точку: p.X, p.Y. Если поле не указали в литерале, оно получает нулевое значение своего типа (0, "", nil и т.д.) — отдельной инициализации не требуется.

package main
 
import "fmt"
 
type Point struct {
	X int
	Y int
}
 
func main() {
	a := Point{X: 1, Y: 2} // по именам
	b := Point{3, 4}       // позиционно
	var c Point            // всё по нулям
 
	c.X = 5 // поле можно присвоить после создания
 
	fmt.Println(a)
	fmt.Println(b)
	fmt.Println(c)
}

Вывод:

{1 2}
{3 4}
{5 0}

fmt.Println печатает структуру в фигурных скобках, значения полей через пробел. Обратите внимание на c: мы не задавали Y, и он остался нулём.

Методы

Метод — это функция, привязанная к типу. От обычной функции его отличает receiver — особый параметр в скобках перед именем метода. Внутри метода receiver работает как обычная переменная и даёт доступ к полям.

package main
 
import "fmt"
 
type Rectangle struct {
	Width  int
	Height int
}
 
// describe — метод типа Rectangle.
// (r Rectangle) — это receiver.
func (r Rectangle) describe() string {
	return fmt.Sprintf("прямоугольник %dx%d, площадь %d",
		r.Width, r.Height, r.Width*r.Height)
}
 
func main() {
	box := Rectangle{Width: 4, Height: 3}
	fmt.Println(box.describe())
}

Вывод:

прямоугольник 4x3, площадь 12

Метод вызывают через точку: box.describe(). По сути это синтаксический сахар: Go подставляет box как receiver. Методы в Go можно объявлять для любого вашего типа в этом пакете — не только для структур, но структуры самый частый случай.

Receiver по значению vs по указателю

Это место, где спотыкается каждый второй новичок. Receiver можно объявить по значению (r Rectangle) или по указателю (r *Rectangle). Разница в одном: получает метод копию значения или ссылку на оригинал.

Receiver по значению — это копия. Любые изменения полей внутри метода затронут только копию и исчезнут после возврата. Смотрите:

package main
 
import "fmt"
 
type Counter struct {
	Value int
}
 
// receiver по значению — работает с копией
func (c Counter) incBroken() {
	c.Value++ // меняем копию, оригинал не тронут
}
 
// receiver по указателю — работает с оригиналом
func (c *Counter) inc() {
	c.Value++
}
 
func main() {
	c := Counter{}
 
	c.incBroken()
	fmt.Println("после incBroken:", c.Value)
 
	c.inc()
	c.inc()
	fmt.Println("после двух inc:", c.Value)
}

Вывод:

после incBroken: 0
после двух inc: 2

incBroken увеличил счётчик у копии — снаружи ничего не поменялось. inc через указатель меняет ту самую структуру, поэтому два вызова дают 2.

Тонкость, которая экономит нервы: вызывать метод с pointer receiver на обычной переменной можно прямо так — c.inc(), без явного &c. Go сам берёт адрес, если переменная адресуема. Это работает, потому что c — переменная; на временном значении (например, на результате функции) такой автоматический & недоступен.

Как выбрать

Простое практическое правило:

  • Нужно менять поля receiver'а (мутация) — берите указатель: *T.
  • Структура большая, копировать дорого — тоже указатель, ради производительности.
  • Иначе можно по значению — это безопаснее (метод гарантированно не испортит оригинал) и проще для мелких типов вроде Point.

Ещё одно правило, важное на практике: не смешивайте receiver'ы у одного типа. Если хоть один метод требует указатель, делайте указатель у всех методов этого типа. Так весь набор методов остаётся согласованным и тип корректно удовлетворяет интерфейсам.

Встраивание: композиция вместо наследования

В Go нет классов и наследования. Вместо «класс B наследует класс A» используют композицию: одна структура содержит другую. Обычное вложенное поле выглядит так:

type Engine struct {
    Power int
}
 
type Car struct {
    engine Engine // обычное поле с именем
}

Здесь к мощности придётся обращаться длинно: car.engine.Power. Go предлагает короче — встраивание (embedding). Если написать тип без имени поля, его поля и методы продвигаются наружу, как будто они принадлежат внешней структуре:

package main
 
import "fmt"
 
type Engine struct {
	Power int
}
 
// метод типа Engine
func (e Engine) start() string {
	return fmt.Sprintf("двигатель на %d л.с. запущен", e.Power)
}
 
type Car struct {
	Engine // встроенный тип — без имени поля
	Brand  string
}
 
func main() {
	c := Car{
		Engine: Engine{Power: 120},
		Brand:  "Lada",
	}
 
	// поле Power доступно напрямую, без c.Engine.Power
	fmt.Println(c.Brand, "—", c.Power, "л.с.")
 
	// метод start() продвинулся: вызываем его на Car
	fmt.Println(c.start())
}

Вывод:

Lada — 120 л.с.
двигатель на 120 л.с. запущен

Car не наследует Engine — он его содержит, а Go лишь даёт удобный короткий доступ. Метод start() остаётся методом Engine; снаружи кажется, что он у Car. Это и есть «композиция вместо наследования»: вы собираете поведение из кусочков, а не выстраиваете иерархию классов.

Если внешняя структура объявит собственный метод с тем же именем, что и встроенный, — победит внешний. Встроенный никуда не денется, к нему всегда можно обратиться явно: c.Engine.start(). Это удобный способ «переопределить» поведение, не ломая исходный тип.

Частые ошибки

  • Метод-мутатор с receiver по значению. Меняете поле внутри метода, а снаружи ничего не происходит — потому что меняли копию. Нужна мутация — нужен *T.
  • Позиционный литерал после изменения структуры. Добавили поле — старые T{a, b} либо не компилируются, либо тихо съезжают. Пишите по именам полей.
  • Смешанные receiver'ы у одного типа. Часть методов по значению, часть по указателю — источник тонких багов с интерфейсами. Выбирайте один стиль на тип.
  • Путать встраивание с наследованием. Продвижение методов — это удобный доступ, а не родительский тип. Встроенный тип сам по себе не видит внешний — продвижение работает только в одну сторону, наружу.

Что дальше

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

  • Методы получают настоящую силу вместе с интерфейсами — там разберём, как тип «обещает» поведение, не раскрывая своё устройство.
  • Указатели мы трогали мельком; если хочется уверенности про & и *, загляните в главу про указатели.
  • Нулевые значения полей и инициализацию полезно держать в голове при проектировании API — хороший тип должен быть полезен «из коробки», без лишней настройки.