Пока мы хранили данные в отдельных переменных: имя в одной, возраст в другой,
баланс в третьей. Но в реальной программе эти значения связаны — они описывают
одну сущность. Структура (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 mainimport "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 mainimport "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 можно объявить
по значению (r Rectangle) или по указателю (r *Rectangle). Разница в
одном: получает метод копию значения или ссылку на оригинал.
Receiver по значению — это копия. Любые изменения полей внутри метода затронут
только копию и исчезнут после возврата. Смотрите:
package mainimport "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 mainimport "fmt"type Engine struct { Power int}// метод типа Enginefunc (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 — хороший тип должен быть полезен «из коробки», без лишней
настройки.