Пока мы хранили данные в отдельных переменных: имя в одной, возраст в другой,
баланс в третьей. Но эти значения связаны — они описывают одного пользователя.
Если передавать их в функции по отдельности, рано или поздно кто-то перепутает
порядок аргументов. Структура (struct) собирает несколько полей в один
тип, чтобы про «пользователя» или «счёт» можно было говорить как про целое. В
главе об указателях вы уже видели Account; теперь разберём структуры как
следует.
В этой главе: объявление структур и создание значений, методы, главный
выбор новичка — получатель по значению или по указателю — и встраивание,
способ переиспользовать поведение без классов и наследования.
Структура — это набор именованных полей, у каждого свой тип. Объявляют её через
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.Printf("%+v\n", c) fmt.Println(a == Point{X: 1, Y: 2})}
Вывод:
{1 2}{3 4}{X:5 Y:0}true
fmt.Println печатает структуру в фигурных скобках, значения полей через пробел.
Глагол %+v добавляет имена полей — при отладке это удобнее. Обратите внимание
на c: мы не задавали Y, и он остался нулём.
Последняя строка показывает, что структуры можно сравнивать через ==: две
структуры равны, если равны все их поля. Это работает, пока все поля
сравниваемые; если в структуре есть срез или карта, == не скомпилируется.
Когда у значения есть правила (имя не пустое, баланс не отрицательный), его
создают не литералом, а функцией с именем NewТип. Такой функции в Go нет
специального статуса, это просто соглашение. Вот конструктор для Account из
главы об указателях (errors.New создаёт ошибку, как в safeDiv из главы о
функциях):
Метод — это функция, привязанная к типу. От обычной функции его отличает
получатель (по-английски receiver) — особый параметр в скобках перед именем
метода. Внутри метода получатель работает как обычная переменная и даёт доступ к
полям.
package mainimport "fmt"type Rectangle struct { Width int Height int}// describe — метод типа Rectangle.// (r Rectangle) — это получатель.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 в
получатель r. fmt.Sprintf, напомним, работает как Printf, но не печатает,
а возвращает готовую строку.
Методы можно объявлять не только у структур, а у любого типа, объявленного в
вашем пакете. Например, type Celsius float64 создаёт новый тип на основе
числа, и ему можно дать метод func (c Celsius) ToF() float64. Добавить метод
чужому типу, скажем int или string, нельзя.
На этом месте новички ошибаются чаще всего. Получатель можно объявить
по значению (r Rectangle) или по указателю (r *Rectangle). Разница
та же, что у обычных параметров из главы об указателях:
метод получает либо копию значения, либо адрес оригинала.
Получатель по значению — это копия. Любые изменения полей внутри метода затронут
только копию и исчезнут после возврата. Смотрите:
package mainimport "fmt"type Counter struct { Value int}// получатель по значению — работает с копиейfunc (c Counter) incBroken() { c.Value++ // меняем копию, оригинал не тронут}// получатель по указателю — работает с оригиналом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.
Обратите внимание: метод с получателем-указателем мы вызвали на обычной
переменной, c.inc(), без явного &c. Go сам берёт адрес переменной. На
значении, у которого нет адреса, например на результате функции
(makeCounter().inc()), такой вызов не скомпилируется.
Структура большая, копировать дорого — тоже указатель, ради
производительности.
Иначе можно по значению — это проще для мелких типов вроде Point.
Но копия структуры неглубокая: через поля-срезы, карты и указатели метод
всё ещё может менять общие данные.
Ещё одно правило из практики: не смешивайте получатели у одного типа.
Если хоть один метод требует указатель, делайте указатель у всех методов этого
типа. Тогда не нужно помнить, какой метод что делает с копией, а в следующей
главе вы увидите, что от этого зависит и работа с интерфейсами.
В 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(). Это удобный способ «переопределить»
поведение, не ломая исходный тип.
Иногда структура нужна в одном месте, и объявлять для неё отдельный тип лишнее.
Тогда тип пишут прямо в объявлении переменной:
var input struct { Text string `json:"text"`}
Это анонимная структура: у неё есть поля, но нет имени типа. Строка в обратных
кавычках после поля — тег. Сам Go его не читает, а пакеты стандартной
библиотеки читают. Здесь тег говорит пакету encoding/json: «в JSON это поле
называется text». Именно такую запись вы встретите в курсе «Backend на Go»,
когда сервер будет принимать JSON-запрос. Поле при этом должно начинаться с
заглавной буквы, иначе пакет json его не увидит; почему — в главе
«Пакеты и модули».
Метод-мутатор с получателем по значению. Меняете поле внутри метода, а снаружи
ничего не происходит — потому что меняли копию. Нужна мутация — нужен *T.
Позиционный литерал после изменения структуры. Добавили поле — старые
T{a, b} либо не компилируются, либо тихо съезжают. Пишите по именам полей.
Смешанные получатели у одного типа. Часть методов по значению, часть по
указателю: читателю приходится помнить, кто меняет оригинал, а кто копию.
Выбирайте один стиль на тип.
Путать встраивание с наследованием. Продвижение методов — это удобный
доступ, а не родительский тип. Встроенный тип сам по себе не видит внешний —
продвижение работает только в одну сторону, наружу.
Объявите Account с полем Balance int и метод Deposit(amount int), изменяющий баланс. Создайте нулевой счёт, пополните его на 20, затем на 5. Напечатайте баланс. Замените получатель метода с указателя на значение и объясните изменившийся результат.
С указателем результат 25, с получателем по значению — 0:
type Account struct { Balance int}func (a *Account) Deposit(amount int) { a.Balance += amount}
В main используйте var a Account, a.Deposit(20), a.Deposit(5) и fmt.Println(a.Balance). Нулевой баланс уже пригоден для работы; отдельный конструктор здесь не нужен. Получатель по значению изменял бы копию поля Balance.
Следующая глава — интерфейсы. Там функция сможет работать с разными структурами через нужный ей набор методов. Перед переходом убедитесь, что можете объяснить результат двух версий Deposit из упражнения.