Введение
До этого момента ты работал с отдельными значениями: числами, строками, слайсами, картами. Но реальные данные почти всегда составные. Пользователь — это не просто строка, это имя, e-mail, возраст, дата регистрации и десяток других полей, которые логически принадлежат одному объекту. В Go для описания таких «сложных данных, склеенных из простых» служит struct — структура. Структура — это фундамент моделирования данных в Go: на ней держатся модели базы данных, тела HTTP-запросов, конфигурация приложения, доменные сущности. В этой главе ты научишься объявлять структуры, создавать их значения разными способами, вешать на них поведение через методы, понимать критическую разницу между value- и pointer-receiver и заменять наследование встраиванием. И в самом конце — сериализовать структуры в JSON и обратно, потому что 90% реальной работы с Go-сервисом сводится именно к этому.
Что такое структура и зачем она нужна
Структура — это именованный тип, который группирует набор полей. Каждое поле имеет своё имя и свой тип. Думай о структуре как о бланке анкеты: сам тип — это пустой бланк с графами «Имя», «Фамилия», «Возраст», а конкретное значение структуры — это заполненный экземпляр анкеты. Без структур пришлось бы таскать связанные данные по отдельным переменным (firstName, lastName, age) и следить, чтобы они не разъехались. Структура склеивает их в одно целое, которое можно передать в функцию одним аргументом, вернуть, положить в слайс или карту.
package main
import "fmt"
// Объявляем новый тип-структуру person.
// Это описание «бланка»: три поля с их типами.
type person struct {
first string
last string
age int
}
func main() {
// Заполняем бланк: создаём конкретное значение структуры.
// Значения идут в том же порядке, что и поля.
p1 := person{"James", "Bond", 20}
fmt.Println(p1.first, p1.last, p1.age) // James Bond 20
}
Обрати внимание: person — это тип, такой же полноценный, как int или string. Мы объявили его один раз, а дальше можем создавать сколько угодно значений этого типа. Доступ к полю — через точку: p1.first.
Инициализация по имени поля
Позиционная инициализация (person{"James", "Bond", 20}) компактна, но хрупка: стоит поменять местами поля в объявлении типа — и все значения молча «поедут». Поэтому в реальном коде почти всегда используют инициализацию по именам полей. Она многословнее, но самодокументируется и устойчива к перестановке полей.
p2 := person{
first: "Miss",
last: "Moneypenny",
age: 18,
}
// Поля, которые ты не указал, получают нулевое значение своего типа:
// для строки — "", для int — 0, для bool — false.
p3 := person{first: "Q"} // last == "", age == 0
fmt.Printf("%+v
", p3) // {first:Q last: age:0}
Глагол %+v в Printf печатает структуру вместе с именами полей — незаменимая вещь при отладке. Просто %v напечатает только значения, а %#v — ещё и имя типа в Go-синтаксисе.
Нулевое значение структуры
В Go у каждого типа есть «нулевое значение» — то, чем переменная инициализируется, если ты не задал ничего явно. Для структуры это структура, где каждое поле имеет своё нулевое значение. Это очень удобно: объявленную через var структуру можно сразу использовать, она не «мусорная», как в C.
var p person // готовое к работе нулевое значение
fmt.Println(p.first == "") // true
fmt.Println(p.age == 0) // true
Анонимные структуры
Иногда структура нужна ровно один раз — например, чтобы сгруппировать пару значений для одного ответа или временно распарсить кусочек JSON. Заводить под это именованный тип избыточно. Тогда пригодится анонимная структура: тип и значение объявляются на месте, «на лету».
// Тип не назван — он описан прямо перед фигурными скобками значения.
config := struct {
Host string
Port int
}{
Host: "localhost",
Port: 8080,
}
fmt.Println(config.Host, config.Port) // localhost 8080
Анонимные структуры часто встречаются в тестах (таблица тест-кейсов) и при разборе внешнего JSON, когда нужно вытащить два-три поля из большого ответа.
Методы: пришиваем поведение к данным
Структура описывает данные. Но данные обычно умеют что-то делать: пользователь может представиться полным именем, заказ — посчитать сумму, прямоугольник — вычислить площадь. В Go поведение добавляется через методы — это функции со специальным «получателем» (receiver), который указывается в скобках перед именем функции. Метод как бы «привязан» к типу.
type person struct {
first string
last string
}
// (p person) — это receiver. Метод fullName принадлежит типу person.
// Внутри метода p — это конкретное значение, на котором его вызвали.
func (p person) fullName() string {
return p.first + " " + p.last
}
func main() {
p := person{"James", "Bond"}
fmt.Println(p.fullName()) // James Bond
}
Ключевая идея: в Go нет классов. Метод — это просто функция, у которой есть получатель. Можно было бы написать fullName(p person) string как обычную функцию, но метод p.fullName() читается естественнее и лучше моделирует «объект умеет что-то делать».
Value receiver против pointer receiver
Это одна из тех тем, на которых спотыкаются почти все новички, поэтому разберём медленно. Receiver бывает двух видов: значение ((p person)) и указатель ((p *person)). Разница принципиальная.
Когда receiver — значение, метод получает копию структуры. Любые изменения полей внутри метода происходят с копией и не видны снаружи. Когда receiver — указатель, метод получает адрес оригинала и может его менять.
type counter struct {
value int
}
// Value receiver: работает с КОПИЕЙ. Изменение не сохранится.
func (c counter) incWrong() {
c.value++ // меняем копию — бесполезно
}
// Pointer receiver: работает с ОРИГИНАЛОМ через адрес.
func (c *counter) incRight() {
c.value++ // меняем оригинал — сохранится
}
func main() {
c := counter{}
c.incWrong()
fmt.Println(c.value) // 0 — изменения потерялись
c.incRight()
fmt.Println(c.value) // 1 — изменение сохранилось
}
Заметь: мы вызвали c.incRight() на обычном значении, а не на указателе — Go сам автоматически взял адрес &c, потому что c адресуемая переменная. Это синтаксический сахар, но помнить о нём стоит.
Практическое правило выбора:
- Нужно менять поля структуры внутри метода — только pointer receiver.
- Структура большая (много полей) — pointer receiver, чтобы не копировать её на каждый вызов.
- Тип должен реализовывать интерфейс с изменяющими методами — pointer receiver.
- Для консистентности: если хотя бы один метод типа использует pointer receiver, обычно все методы делают pointer receiver, чтобы не смешивать.
Указатель на структуру
Указатель на структуру создаётся через & прямо на литерале. Это очень частая идиома в Go: конструкторы почти всегда возвращают *T.
// p — это уже *person (указатель на структуру), а не person.
p := &person{first: "James", last: "Bond"}
fmt.Printf("%T
", p) // *main.person
// Обращаться к полям можно напрямую через точку —
// Go сам разыменует указатель: p.first вместо (*p).first.
fmt.Println(p.first) // James
В Go не нужно писать (*p).first, как в C — язык автоматически разыменовывает указатель при доступе к полю. Это делает код с указателями на структуры почти неотличимым от кода со значениями.
Встраивание (embedding) вместо наследования
В Go нет классов и нет наследования. Вместо «класс B наследует класс A» здесь используют встраивание: одну структуру помещают внутрь другой без имени поля. Поля и методы встроенной структуры «поднимаются» (promotion) на уровень внешней, как будто они её собственные.
type person struct {
First string
Last string
}
func (p person) fullName() string {
return p.First + " " + p.Last
}
// secretAgent встраивает person — без имени поля, просто тип.
type secretAgent struct {
person // встроенное поле
LicenseToKill bool
}
func main() {
a := secretAgent{
person: person{First: "James", Last: "Bond"},
LicenseToKill: true,
}
// Поле First «поднято»: обращаемся напрямую, минуя person.
fmt.Println(a.First) // James — promotion полей
fmt.Println(a.fullName()) // James Bond — promotion метода
fmt.Println(a.person.First) // James — можно и явно
}
Философия Go: композиция вместо наследования. Ты не строишь глубокие иерархии «животное → млекопитающее → собака», а собираешь тип из кусочков поведения. Это гибче и меньше связывает код.
Переопределение поднятых методов
Если внешняя структура объявит метод с тем же именем, что и встроенная, вызов на внешнем типе пойдёт к внешнему методу — внутренний «затенён», но доступен явно.
func (p person) greeting() string { return "Обычный человек" }
type secretAgent struct {
person
}
// Тот же метод greeting, но на secretAgent — он затеняет person.greeting.
func (a secretAgent) greeting() string { return "Агент на службе" }
func main() {
a := secretAgent{person{"James", "Bond"}}
fmt.Println(a.greeting()) // Агент на службе
fmt.Println(a.person.greeting()) // Обычный человек — явный доступ
}
Теги полей
К каждому полю структуры можно прикрепить строковый «тег» — метаданные в обратных кавычках. Сам Go теги игнорирует, но библиотеки читают их через рефлексию. Самый частый пример — теги json, которые управляют сериализацией.
type person struct {
First string `json:"first_name"`
Last string `json:"last_name"`
Age int `json:"age,omitempty"` // omitempty: пропустить, если 0
pass string `json:"-"` // "-": никогда не сериализовать
}
Тег json:"first_name" говорит: в JSON это поле называется first_name, а не First. Опция omitempty убирает поле из вывода, если оно равно нулевому значению. Дефис - полностью исключает поле (удобно для паролей и внутренних полей).
Сериализация в JSON через encoding/json
Пакет encoding/json из стандартной библиотеки превращает структуры в JSON (Marshal) и обратно (Unmarshal). Есть важное правило: сериализуются только экспортируемые поля — те, что начинаются с заглавной буквы. Поле с маленькой буквы (pass) пакет не увидит, потому что рефлексия не имеет доступа к неэкспортируемым полям чужого пакета.
package main
import (
"encoding/json"
"fmt"
)
type person struct {
First string `json:"first_name"`
Last string `json:"last_name"`
Age int `json:"age,omitempty"`
}
func main() {
p := person{First: "James", Last: "Bond", Age: 20}
// Marshal: структура -> []byte с JSON.
bs, err := json.Marshal(p)
if err != nil {
fmt.Println("ошибка:", err)
return
}
fmt.Println(string(bs))
// {"first_name":"James","last_name":"Bond","age":20}
}
// Обратное направление: JSON -> структура.
// Передаём УКАЗАТЕЛЬ &p, чтобы Unmarshal мог заполнить поля оригинала.
data := []byte(`{"first_name":"Miss","last_name":"Moneypenny","age":18}`)
var p person
if err := json.Unmarshal(data, &p); err != nil {
fmt.Println("ошибка разбора:", err)
return
}
fmt.Printf("%+v
", p) // {First:Miss Last:Moneypenny Age:18}
Именно поэтому Unmarshal берёт &p: без указателя функция получила бы копию и заполнила бы её, а твой оригинал остался бы пустым. Это прямое следствие разговора про value vs pointer из середины главы.
Сравнение структур
Структуры в Go можно сравнивать оператором ==, если все их поля сравнимы (числа, строки, булевы, указатели, массивы из сравнимых типов). Две структуры равны, когда попарно равны все их поля. Это удобно для проверок в тестах и для использования структур как ключей карты.
type point struct {
X, Y int
}
a := point{1, 2}
b := point{1, 2}
c := point{3, 4}
fmt.Println(a == b) // true — все поля совпали
fmt.Println(a == c) // false
// Раз структура сравнима, её можно использовать КАК КЛЮЧ карты.
seen := map[point]bool{}
seen[a] = true
fmt.Println(seen[b]) // true — b равна a, значит тот же ключ
Важная оговорка: если в структуре есть несравнимое поле — слайс, карта или функция, — оператор == для неё не скомпилируется. В таких случаях сравнивают вручную поле за полем или через reflect.DeepEqual (медленнее, для тестов).
Структуры в слайсах и картах
Структуры редко живут поодиночке — обычно это коллекции. И здесь снова всплывает тема копий. Когда ты берёшь структуру из слайса по индексу и меняешь её поле напрямую — изменение сохраняется, потому что s[i] адресуемо. А вот элемент карты не адресуем: поле у структуры внутри карты изменить напрямую нельзя.
type user struct {
Name string
Age int
}
// Слайс структур: элемент по индексу адресуем — меняем поле напрямую.
users := []user{{"Анна", 30}, {"Борис", 25}}
users[0].Age = 31 // ОК, изменение сохранится
fmt.Println(users[0]) // {Анна 31}
// Карта структур: элемент НЕ адресуем.
byName := map[string]user{"Анна": {"Анна", 30}}
// byName["Анна"].Age = 31 // ОШИБКА КОМПИЛЯЦИИ
// Правильно: достать копию, изменить, положить обратно.
u := byName["Анна"]
u.Age = 31
byName["Анна"] = u
fmt.Println(byName["Анна"]) // {Анна 31}
Если структуры в карте меняются часто, обычно хранят не значения, а указатели: map[string]*user. Тогда byName["Анна"].Age = 31 работает, потому что ты меняешь структуру по указателю, а не элемент карты.
Конструкторы: паттерн New
В Go нет специальных конструкторов, как в других языках. Вместо этого принято писать обычную функцию с именем New или NewТип, которая создаёт значение, задаёт разумные значения по умолчанию, проверяет входные данные и возвращает *T (и, если нужно, error). Это идиома, а не требование языка, но её придерживаются повсеместно.
type account struct {
Owner string
balance int // неэкспортируемое — снаружи не подделать
}
// NewAccount — конструктор. Проверяет вход и возвращает *account.
func NewAccount(owner string, initial int) (*account, error) {
if owner == "" {
return nil, fmt.Errorf("владелец не может быть пустым")
}
if initial < 0 {
return nil, fmt.Errorf("начальный баланс не может быть отрицательным")
}
return &account{Owner: owner, balance: initial}, nil
}
func main() {
acc, err := NewAccount("Иван", 100)
if err != nil {
fmt.Println("ошибка:", err)
return
}
fmt.Printf("%+v
", acc) // &{Owner:Иван balance:100}
}
Приём с неэкспортируемым полем balance и экспортируемым конструктором — это инкапсуляция по-гошному: снаружи пакета баланс нельзя задать напрямую, только через контролируемый NewAccount и методы. Так структура сама следит за своими инвариантами.
Вложенные структуры и JSON
Реальный JSON почти всегда вложенный: у заказа есть покупатель со своим адресом, у адреса — город и индекс. Структуры вкладываются друг в друга так же естественно, и encoding/json разбирает вложенность автоматически.
type address struct {
City string `json:"city"`
Zip string `json:"zip"`
}
type customer struct {
Name string `json:"name"`
Address address `json:"address"` // вложенная структура
}
func main() {
data := []byte(`{
"name": "Иван",
"address": {"city": "Москва", "zip": "101000"}
}`)
var c customer
if err := json.Unmarshal(data, &c); err != nil {
fmt.Println(err)
return
}
fmt.Println(c.Name, c.Address.City) // Иван Москва
}
Обрати внимание: здесь address — именованное поле, а не встраивание. Если бы мы встроили address без имени, его поля «поднялись» бы на уровень customer, и JSON стал бы плоским. Именованное вложенное поле сохраняет вложенность и в структуре, и в JSON — выбирай по форме данных.
Кейс из реального проекта
Ты пишешь HTTP-сервис для интернет-магазина. Приходит POST-запрос с телом заказа в JSON, тебе нужно его распарсить, посчитать сумму и вернуть ответ. Структуры здесь — центральный инструмент: они описывают и входные данные, и выходные, а методы вешают на них бизнес-логику.
package main
import (
"encoding/json"
"fmt"
)
// Позиция заказа. Теги задают имена полей в JSON.
type item struct {
Name string `json:"name"`
Price float64 `json:"price"`
Quantity int `json:"quantity"`
}
// Заказ встраивает данные покупателя и содержит слайс позиций.
type order struct {
Customer string `json:"customer"`
Items []item `json:"items"`
}
// Метод-логика с value receiver: он ничего не меняет, только считает.
func (o order) total() float64 {
var sum float64
for _, it := range o.Items {
sum += it.Price * float64(it.Quantity)
}
return sum
}
func main() {
// Так выглядит тело входящего запроса.
body := []byte(`{
"customer": "Иван",
"items": [
{"name": "Клавиатура", "price": 2500, "quantity": 2},
{"name": "Мышь", "price": 900, "quantity": 1}
]
}`)
var o order
if err := json.Unmarshal(body, &o); err != nil {
fmt.Println("некорректный JSON:", err)
return
}
fmt.Printf("Заказ от %s на сумму %.2f
", o.Customer, o.total())
// Заказ от Иван на сумму 5900.00
// Формируем ответ отдельной структурой — так чище, чем править входную.
resp := struct {
Customer string `json:"customer"`
Total float64 `json:"total"`
}{
Customer: o.Customer,
Total: o.total(),
}
out, _ := json.Marshal(resp)
fmt.Println(string(out)) // {"customer":"Иван","total":5900}
}
Здесь видно всё вместе: структуры моделируют данные, теги управляют JSON, метод total() инкапсулирует логику, а анонимная структура удобно описывает форму ответа, которую не хочется заводить как отдельный тип.
Типичные ошибки
Ждать изменений от value receiver
Самая частая ловушка. Ты пишешь метод с (c counter), меняешь в нём поле, а снаружи ничего не меняется — потому что метод работал с копией. Если метод должен что-то менять, receiver обязан быть указателем: (c *counter).
Забыть про заглавную букву при JSON
Ты объявил поле name string с маленькой буквы и удивляешься, почему в JSON оно не появляется. Пакет encoding/json видит только экспортируемые (заглавные) поля. Пиши Name string и, если нужно другое имя в JSON, добавляй тег json:"name".
Передавать структуру в Unmarshal без указателя
Вызов json.Unmarshal(data, p) вместо json.Unmarshal(data, &p) либо не скомпилируется, либо ничего не заполнит. Unmarshal должен получить адрес, чтобы записать в твою переменную. Всегда передавай &p.
Строить глубокие иерархии встраивания
Соблазн собрать «наследование» в пять уровней вложенности через embedding приводит к неочевидным конфликтам имён и хрупкому коду. Встраивание — для переиспользования небольших кусочков поведения, а не для имитации классов. Держи иерархии плоскими.
Практика
- Объяви структуру
bookс полямиTitle,Author,Pages. Создай значение тремя способами: позиционно, по именам полей и черезvarс последующим заполнением. Напечатай каждое через%+v. - Добавь к
bookметодisLong() bool, который возвращаетtrue, если страниц больше 500. Выбери правильный тип receiver и объясни в комментарии почему. - Сделай структуру
walletс полемbalance intи методамиdeposit(n int)иwithdraw(n int) error. Убедись, что баланс реально меняется между вызовами (подсказка: pointer receiver). - Создай структуру
authorи встрой её вbook. Проверь, что метод автора «поднимается» и вызывается прямо на книге. - Опиши структуру
productс тегамиjson(включаяomitemptyи-), сериализуй её в JSON, затем распарси обратно и убедись, что данные совпали.
Итог
Структуры — это способ Go описывать составные данные, а методы — способ добавлять к ним поведение без классов и наследования. Ты научился объявлять структуры, инициализировать их четырьмя способами (позиционно, по именам, через var, анонимно) и понял, почему нулевое значение структуры сразу готово к работе. Главный водораздел главы — value receiver против pointer receiver: копия против оригинала, и именно из этого различия растёт правило «хочешь менять — бери указатель». Встраивание заменяет наследование композицией, а теги и encoding/json связывают твои структуры с внешним миром через JSON. Всё это — базовый словарь, на котором написан любой реальный Go-сервис: в следующей главе мы добавим к структурам интерфейсы и получим полиморфизм.