Структуры в Go: разбор на живом примере
Структуры — основной способ объединить данные в Go, но между объявлением struct и кодом, который удобно поддерживать, лежит десяток практических решений. Разбираем на конкретном примере: от объявления до методов, встраивания и типичных граблей.

Зачем отдельная статья про struct
В Go нет классов, наследования и конструкторов в привычном смысле. Вся работа с составными данными строится вокруг структур и интерфейсов. Звучит просто — пока не столкнёшься с тем, что указатель на пустую структуру не равен nil-интерфейсу, встроенное поле «перетирает» метод, а JSON-маршалинг молча пропускает приватные поля. Ниже — разбор на одном сквозном примере: сервис учёта заказов. Всё написано и проверено для Go 1.22.
Объявление и инициализация
Структура — именованный набор полей. Каждое поле имеет имя, тип и необязательный тег.
// Go 1.22
package main
import (
"fmt"
"time"
)
// Order описывает один заказ.
type Order struct {
ID int
Customer string
Amount float64
CreatedAt time.Time
}
func main() {
// Литерал с именованными полями — предпочтительный способ
order := Order{
ID: 42,
Customer: "Иванов",
Amount: 1250.00,
CreatedAt: time.Now(),
}
fmt.Println(order)
}
Есть и позиционная инициализация — Order{42, "Иванов", 1250.00, time.Now()} — но она ломается при любом добавлении поля. Линтер go vet и большинство CI-конфигураций это ловят, а fieldalignment из пакета golang.org/x/tools дополнительно подскажет порядок полей, экономящий память.
Нулевое значение — не nil
Если объявить var o Order, все поля получат нулевые значения своих типов: ID == 0, Customer == "", Amount == 0.0, CreatedAt — нулевое время. Сама структура при этом существует, занимает память и не равна nil. Указатель var p *Order — вот он равен nil.
Структура в Go — это не объект. У неё нет конструктора, деструктора и скрытого состояния. Всё, что в ней есть, видно в объявлении — и это главное архитектурное преимущество.
Методы: значение или указатель
Метод привязывается к типу через получатель (receiver). Выбор между значением и указателем — одно из первых решений, которое определяет поведение всего кода вокруг.
// Total возвращает сумму с условной скидкой.
// Получатель по значению: Order копируется, оригинал не меняется.
func (o Order) Total(discount float64) float64 {
return o.Amount * (1 - discount)
}
// Cancel помечает заказ отменённым.
// Получатель по указателю: меняем оригинал.
func (o *Order) Cancel(reason string) {
o.Amount = 0
o.Customer = reason // упрощённо, для наглядности
}
| Критерий | Получатель по значению | Получатель по указателю |
|---|---|---|
| Изменяет ли оригинал | Нет | Да |
| Копирование | Каждый вызов копирует структуру | Копируется только указатель (8 байт на 64-бит) |
| Когда выбирать | Маленькая структура, чистые вычисления | Мутация, большая структура, нужна идентичность |
| Совет Go Wiki | Если хотя бы один метод — по указателю, делайте все по указателю для единообразия | |
Размер, при котором копирование становится ощутимым, зависит от сценария. Ориентир из практики: структура больше 3–4 машинных слов (24–32 байта) — повод задуматься об указателе, но решающий фактор — семантика, а не байты.
Теги и маршалинг
Теги — строковые аннотации полей, которые читает рефлексия. Самый частый случай — JSON.
type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
internalNote string // приватное поле: encoding/json его проигнорирует
}
Три момента, на которых спотыкаются чаще всего:
- Приватное поле (с маленькой буквы) не сериализуется и не десериализуется — молча, без ошибки.
- Тег
json:"-"исключает публичное поле из маршалинга. - Тег
json:"amount,omitempty"не пишет поле, если оно равно нулевому значению. Дляfloat64нулевое значение —0, а не «отсутствует», и это не всегда то, что вы хотите.
Сноска: Тег (struct tag) — строка в обратных кавычках после типа поля. Компилятор её не проверяет; валидацию тегов выполняет go vet. Опечатка в теге — частая причина «почему JSON приходит пустой».
Встраивание: композиция вместо наследования
Go продвигает композицию через встраивание (embedding). Встроенное поле делает свои методы и поля доступными напрямую.
type Timestamps struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Amount float64 `json:"amount"`
Timestamps // встроенное поле — без имени
}
func main() {
o := Order{
ID: 1,
Customer: "Петров",
Amount: 500,
Timestamps: Timestamps{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
// Обращение напрямую — как будто поле принадлежит Order
fmt.Println(o.CreatedAt)
}
Подвох: если у Order появится собственное поле CreatedAt, оно «перекроет» поле из Timestamps. Компилятор не предупредит — обращение o.CreatedAt молча начнёт возвращать внешнее поле. Доступ к внутреннему останется через o.Timestamps.CreatedAt. Такие тени трудно ловить на ревью, поэтому встраивайте только то, чьи поля и методы вы реально хотите «поднять» наружу.
Указатель на структуру и интерфейс: классическая ловушка
Одна из самых частых ошибок в Go — сравнение интерфейса с nil.
type Validator interface {
Validate() error
}
func (o *Order) Validate() error {
if o.Amount <= 0 {
return fmt.Errorf("сумма должна быть положительной")
}
return nil
}
func process(v Validator) {
if v == nil {
fmt.Println("валидатор не задан")
return
}
fmt.Println("валидируем...")
}
func main() {
var o *Order // nil-указатель
process(o) // напечатает "валидируем...", а не "валидатор не задан"
}
Интерфейс в Go — пара (тип, значение). Когда вы передаёте nil *Order в Validator, интерфейс получает тип *Order и значение nil. У такого интерфейса тип не nil — значит, и сам интерфейс не nil. Проверка v == nil пропускает его. Решение: передавайте нетипизированный nil (process(nil)) или проверяйте через reflect.ValueOf(v).IsNil(), если тип неизвестен заранее.
Сравнение и копирование
Структура сравнима оператором ==, только если все её поля сравнимы. Слайс, карта или функция в поле — и компилятор откажет в сравнении.
time.Time— сравним, но==учитывает монотонные часы. Для логического сравнения используйтеt1.Equal(t2).- Присваивание
b := aкопирует все поля по значению. Если поле — слайс, копируется заголовок, а не данные. Мутация элемента черезbизменит данные и вa.
Чего здесь не разбирали
Интерфейсы и полиморфизм на структурах — отдельная тема, как и паттерны «функциональных опций» для инициализации сложных структур. Генерики (Go 1.18+) позволяют параметризовать контейнеры поверх структур, но это тоже за рамками одного разбора.
Что делать дальше
- Пропустите свой проект через
go vet ./...— он найдёт ошибки в тегах, позиционную инициализацию чужих структур и другие проблемы. - Попробуйте
fieldalignmentизgolang.org/x/tools/go/analysis/passes/fieldalignment: он покажет, сколько байт можно сэкономить на выравнивании полей. - Напишите тест, в котором маршалите структуру в JSON и обратно — это ловит молчаливые пропуски полей раньше, чем их заметит продакшен.
- Прочитайте раздел «Method sets» в спецификации языка — он короткий, но закрывает все вопросы о том, почему метод с указателем не виден через значение интерфейса.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: The Go Programming Language Specification — Struct types, Effective Go — Methods
Частые вопросы
Можно ли менять порядок полей в структуре без последствий?
Почему encoding/json не видит моё поле?
Когда выбирать получатель по значению, а когда по указателю?
Что будет, если вызвать метод на nil-указателе структуры?
Как сделать глубокую копию структуры со слайсами внутри?
Работает ли пример из статьи на Go 1.18 или 1.20?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.