Если map — это ассоциативный контейнер, то struct — способ объединить разнородные данные в единую сущность. В Go struct — это основа для построения любой доменной модели, и за простым синтаксисом скрывается несколько нетривиальных механизмов: embedding, struct tags и то, как компилятор раскладывает поля в памяти.


Базовый синтаксис

go
type User struct { ID int Name string Email string IsActive bool } // Инициализация через именованные поля — предпочтительно u := User{ ID: 1, Name: "Alice", Email: "alice@example.com", IsActive: true, } // Позиционная инициализация — хрупко, избегать в продакшне u := User{1, "Alice", "alice@example.com", true}

Позиционная инициализация ломается при добавлении нового поля в структуру — компилятор не предупредит, если типы совпали случайно. Именованные поля — всегда безопаснее.

Zero value структуры

Как и любой тип в Go, struct имеет zero value — каждое поле инициализируется своим нулевым значением:

go
var u User fmt.Println(u.ID) // 0 fmt.Println(u.Name) // "" fmt.Println(u.IsActive) // false

Это позволяет строить конструкции, где zero value структуры уже является валидным начальным состоянием — паттерн, активно используемый в стандартной библиотеке (sync.Mutex, bytes.Buffer и другие готовы к использованию без инициализации).


Struct — значимый тип

Как и массив, struct является значимым типом. При присвоении и передаче в функцию создаётся полная копия:

go
a := User{ID: 1, Name: "Alice"} b := a // полная копия b.Name = "Bob" fmt.Println(a.Name) // "Alice" — не изменился fmt.Println(b.Name) // "Bob"

Это важно для понимания того, когда использовать pointer receiver, а когда value receiver в методах. Большие структуры стоит передавать по указателю — копирование нескольких сотен байт на каждый вызов заметно в горячих путях.


Анонимные поля и Embedding

Go не имеет классического наследования, но предоставляет embedding (встраивание) — механизм, который на практике решает ту же задачу повторного использования кода, но без иерархии типов.

Анонимное поле

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

go
type Animal struct { Name string } func (a Animal) Speak() string { return a.Name + " makes a sound" } type Dog struct { Animal // анонимное поле — embedding Breed string } d := Dog{ Animal: Animal{Name: "Rex"}, Breed: "Labrador", } // Прямой доступ к полям и методам Animal через Dog fmt.Println(d.Name) // "Rex" — проброшено от Animal fmt.Println(d.Speak()) // "Rex makes a sound" — метод Animal доступен на Dog fmt.Println(d.Animal.Name) // "Rex" — явный доступ тоже работает

Важно: это не наследование. Dog не является подтипом Animal. Вы не можете передать Dog туда, где ожидается Animal. Embedding — это делегирование: компилятор автоматически генерирует обёртки, вызывающие методы встроенного типа.

Проброс интерфейсов через embedding

Главная практическая польза embedding — реализация интерфейсов:

go
type Logger interface { Log(msg string) } type ConsoleLogger struct{} func (l ConsoleLogger) Log(msg string) { fmt.Println("[LOG]", msg) } type Service struct { ConsoleLogger // Service автоматически реализует Logger name string } var _ Logger = Service{} // проверка на этапе компиляции s := Service{name: "payments"} s.Log("started") // [LOG] started

Разрешение конфликтов имён

Если два встроенных типа имеют поле или метод с одинаковым именем, компилятор требует явного обращения:

go
type A struct{ Value int } type B struct{ Value int } type C struct { A B } c := C{A: A{1}, B: B{2}} // fmt.Println(c.Value) // Ошибка: ambiguous selector c.Value fmt.Println(c.A.Value) // 1 — явное обращение fmt.Println(c.B.Value) // 2

При этом если поле есть у самой структуры и у встроенного типа — побеждает поле структуры (ближайший уровень):

go
type Base struct{ ID int } type Entity struct { Base ID string // перекрывает Base.ID } e := Entity{Base: Base{ID: 1}, ID: "abc"} fmt.Println(e.ID) // "abc" — поле Entity fmt.Println(e.Base.ID) // 1 — явный доступ к Base

Struct tags

Struct tags — это метаданные для полей, которые не влияют на поведение компилятора, но читаются библиотеками через reflect в рантайме:

go
type User struct { ID int `json:"id" db:"user_id" validate:"required"` Name string `json:"name" validate:"required,min=2,max=100"` Password string `json:"-"` // исключить из JSON CreatedAt time.Time `json:"created_at,omitempty"` // пропустить если zero }

Формат тега: key:"value", несколько тегов через пробел. Значение — произвольная строка, интерпретируемая конкретной библиотекой.

Распространённые теги

encoding/json:

go
type Response struct { Status string `json:"status"` Data any `json:"data,omitempty"` // пропустить если nil/zero Error string `json:"error,omitempty"` interno string `json:"-"` // всегда исключить }

database/sql и sqlx:

go
type User struct { ID int `db:"id"` Email string `db:"email"` } // sqlx сканирует строки по тегу db, а не по имени поля rows.StructScan(&user)

validate (github.com/go-playground/validator):

go
type CreateUserRequest struct { Email string `json:"email" validate:"required,email"` Age int `json:"age" validate:"required,min=18,max=120"` Password string `json:"password" validate:"required,min=8"` }

Чтение тегов через reflect

Под капотом теги читаются так:

go
t := reflect.TypeOf(User{}) field, _ := t.FieldByName("Name") tag := field.Tag.Get("json") // "name" fmt.Println(tag)

Именно поэтому теги доступны только в рантайме — компилятор их игнорирует.


Выравнивание и раскладка в памяти

Это тема, которую редко знают джуны, но часто спрашивают на Middle+ и Senior. Понимание выравнивания позволяет сэкономить память без изменения логики.

Что такое выравнивание

Процессор читает память словами фиксированного размера (обычно 8 байт на 64-битной архитектуре). Доступ к данным эффективен, когда их адрес кратен их размеру. int64 по адресу 0 — эффективно. int64 по адресу 1 — нет: процессор вынужден читать два слова и склеивать.

Чтобы избежать невыровненного доступа, компилятор вставляет padding — байты-заглушки между полями:

go
type Bad struct { A bool // 1 байт // 7 байт padding (до выравнивания int64) B int64 // 8 байт C bool // 1 байт // 7 байт padding (до выравнивания следующей структуры) } // Итого: 24 байта type Good struct { B int64 // 8 байт A bool // 1 байт C bool // 1 байт // 6 байт padding } // Итого: 16 байт

Экономия 8 байт — треть размера структуры. Для миллионов объектов это ощутимо.

Правила выравнивания

Выравнивание поля равно его размеру (но не более 8 байт на 64-битной платформе):

ТипРазмерВыравнивание
bool, byte, int81 байт1 байт
int16, uint162 байта2 байта
int32, uint32, float324 байта4 байта
int64, uint64, float648 байт8 байт
string16 байт8 байт
slice24 байта8 байт
указатель8 байт8 байт

Практический пример оптимизации

Хорошее практическое правило: от большего к меньшему. Сначала 8-байтовые поля, потом 4-байтовые, потом 2-байтовые, потом 1-байтовые:

go
// Плохо — 40 байт из-за padding type Request struct { IsAdmin bool // 1 + 7 padding UserID int64 // 8 IsActive bool // 1 + 3 padding Score float32 // 4 Name string // 16 } // Хорошо — 32 байта type Request struct { Name string // 16 UserID int64 // 8 Score float32 // 4 IsAdmin bool // 1 IsActive bool // 1 // 2 байта padding }

Проверить размер и выравнивание можно через unsafe:

go
fmt.Println(unsafe.Sizeof(Request{})) // размер в байтах fmt.Println(unsafe.Alignof(Request{})) // выравнивание структуры fmt.Println(unsafe.Offsetof(Request{}.Score)) // смещение поля

Пустая структура — особый случай

struct{} имеет нулевой размер и широко используется как сигнальный тип:

go
// Канал-сигнал (не передаёт данных) done := make(chan struct{}) go func() { // работа close(done) }() <-done // Set через map (экономим память на значениях) visited := make(map[string]struct{}) visited["node1"] = struct{}{} // Глобальная переменная zero-size не занимает адрес в heap var zeroValue struct{}

Интересный момент: два указателя на struct{} могут указывать на один и тот же адрес — unsafe.Sizeof(struct{}{}) всегда 0, и компилятор оптимизирует это, используя runtime.zerobase как общий адрес для всех zero-size значений.


Сравнение структур

Структуры можно сравнивать через ==, если все их поля — comparable типы:

go
type Point struct{ X, Y int } p1 := Point{1, 2} p2 := Point{1, 2} fmt.Println(p1 == p2) // true — сравниваются поля по значению type Polygon struct { Points []Point // слайс — не comparable } // p1 == p2 // Ошибка компиляции

Анонимные структуры

Go позволяет объявлять структуры без имени — прямо в месте использования. Это удобно для одноразовых данных:

go
// Временная группировка данных в тесте testCases := []struct { input string expected int }{ {"hello", 5}, {"world!", 6}, } for _, tc := range testCases { result := len(tc.input) if result != tc.expected { t.Errorf("got %d, want %d", result, tc.expected) } } // Декодирование JSON без объявления типа var resp struct { Status string `json:"status"` Count int `json:"count"` } json.Unmarshal(data, &resp)

Вопросы на собеседовании

Q: Чем embedding отличается от наследования?
A: Embedding — это делегирование, а не наследование. Dog со встроенным Animal не является подтипом Animal — передать Dog туда, где ожидается Animal, нельзя. Компилятор лишь генерирует обёртки, которые проксируют вызовы методов и доступ к полям встроенного типа. Никакой иерархии типов нет.

Q: Что такое zero value структуры и зачем это нужно?
A: При объявлении var s MyStruct каждое поле получает своё zero value. Это позволяет проектировать типы так, чтобы их zero value было валидным начальным состоянием — как sync.Mutex или bytes.Buffer, готовые к использованию без явной инициализации.

Q: Struct — значимый или ссылочный тип?
A: Значимый (value type). При присвоении и передаче в функцию создаётся полная копия всех полей. Чтобы избежать копирования и/или разрешить мутацию оригинала — передают указатель *MyStruct.

Q: Что такое struct tags? Как они работают?
A: Строковые метаданные для полей, игнорируемые компилятором, но читаемые библиотеками через reflect в рантайме. Формат: `key:"value"`. Используются в encoding/json для маппинга имён, в database/sql/sqlx для сканирования строк, в валидаторах и ORM.

Q: Что такое padding и почему он появляется?
A: Байты-заглушки, которые компилятор вставляет между полями структуры для выравнивания доступа к памяти. Процессор читает данные эффективно только если их адрес кратен их размеру. Padding обеспечивает это условие за счёт увеличения размера структуры.

Q: Как оптимизировать размер структуры?
A: Расположить поля от большего размера к меньшему — 8-байтовые первыми, 1-байтовые последними. Это минимизирует количество padding-байт. Можно проверить через unsafe.Sizeof.

Q: Что такое struct{}? Где применяется?
A: Структура нулевого размера — занимает 0 байт. Используется как: сигнальный тип в каналах (chan struct{}), значение в set-map (map[K]struct{}), маркер в паттернах. Все zero-size значения в Go указывают на один адрес runtime.zerobase.

Q: Можно ли сравнить две структуры через ==?
A: Да, если все поля структуры — comparable типы (базовые типы, массивы, другие comparable структуры). Если хотя бы одно поле — слайс, map или функция — компилятор запретит сравнение через ==. Тогда нужен reflect.DeepEqual или ручное сравнение.

Q: Как разрешается конфликт имён при embedding нескольких типов?
A: Если два встроенных типа имеют поле или метод с одинаковым именем — компилятор выдаёт ошибку ambiguous selector при попытке обратиться к нему без квалификатора. Нужно явно указывать: s.A.Value или s.B.Value. Если поле есть и в самой структуре, и во встроенном типе — приоритет у структуры (ближайший уровень вложенности).

Q: Почему нельзя взять адрес поля структуры из map и изменить его?
A: Элементы map non-addressable: при росте таблицы Go может переместить данные, и старый адрес стал бы невалидным. Поэтому m["key"].Field = value — ошибка компиляции. Нужно достать копию, изменить, положить обратно.


Практика

Quiz+10 XP

Структура — это значимый или ссылочный тип в Go?

  • Ссылочный — при передаче в функцию передаётся указатель
  • Значимый — при передаче в функцию создаётся полная копия
  • Зависит от размера структуры
  • Зависит от типов полей
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" type Animal struct{ Name string } func (a Animal) Speak() string { return a.Name + " says: ..." } func (a Animal) Describe() string { return "I am " + a.Speak() } type Dog struct{ Animal } func (d Dog) Speak() string { return d.Name + " says: Woof!" } func main() { d := Dog{Animal: Animal{Name: "Rex"}} fmt.Println(d.Speak()) fmt.Println(d.Describe()) fmt.Println(d.Animal.Speak()) }
Задача+20 XP

Реализуй конструктор NewUser, который принимает имя, возраст и email, валидирует их и возвращает (*User, error). Правила: имя не пустое, возраст 0–150, email содержит @.

Исправь код+25 XP

Исправь код: функция должна обновлять возраст в оригинальной структуре, а не в копии.


Задачи: Struct


Задача 1: Конструктор и валидация

Уровень: Лёгкая

Что проверяет: паттерн конструктора, работа со структурами

Условие: Создай структуру User с полями Name string, Age int, Email string. Напиши конструктор NewUser который возвращает (*User, error) и валидирует: имя не пустое, возраст от 0 до 150, email содержит @.

Примеры:

text
NewUser("Alice", 30, "alice@example.com") → &User{...}, nil NewUser("", 30, "alice@example.com") → nil, "name is required" NewUser("Bob", -1, "bob@example.com") → nil, "invalid age" NewUser("Eve", 25, "not-an-email") → nil, "invalid email"

Решение:

go
package main import ( "errors" "fmt" "strings" ) type User struct { Name string Age int Email string } func NewUser(name string, age int, email string) (*User, error) { if strings.TrimSpace(name) == "" { return nil, errors.New("name is required") } if age < 0 || age > 150 { return nil, errors.New("invalid age") } if !strings.Contains(email, "@") { return nil, errors.New("invalid email") } return &User{Name: name, Age: age, Email: email}, nil } func main() { u, err := NewUser("Alice", 30, "alice@example.com") fmt.Println(u, err) u, err = NewUser("", 30, "alice@example.com") fmt.Println(u, err) }

Задача 2: Embedding и переопределение

Уровень: Средняя

Что проверяет: понимание embedding, переопределение методов

Условие: Что выведет код? Объясни поведение.

go
package main import "fmt" type Animal struct { Name string } func (a Animal) Speak() string { return a.Name + " says: ..." } func (a Animal) Describe() string { return "I am " + a.Speak() } type Dog struct { Animal } func (d Dog) Speak() string { return d.Name + " says: Woof!" } func main() { d := Dog{Animal: Animal{Name: "Rex"}} fmt.Println(d.Speak()) fmt.Println(d.Describe()) fmt.Println(d.Animal.Speak()) }

Ожидаемый ответ:

text
Rex says: Woof! I am Rex says: ... I am Rex says: ...

Решение:

go
// d.Speak() → вызывает Dog.Speak() — Dog перекрывает метод Animal. // d.Describe() → Dog не определяет Describe(), // поэтому вызывается Animal.Describe(). // Внутри Animal.Describe() вызов a.Speak() — // это вызов на значении типа Animal, а не Dog. // Go не знает про Dog в этом контексте — нет виртуальных методов. // Результат: Animal.Speak(), а не Dog.Speak(). // Это ключевое отличие от наследования в ООП. // d.Animal.Speak() → явный вызов Animal.Speak() напрямую.

Задача 3: Выравнивание памяти

Уровень: Сложная

Что проверяет: понимание padding и оптимизации структур

Условие: Есть три структуры. Не запуская код, определи размер каждой в байтах на 64-битной платформе. Затем для каждой предложи оптимальную раскладку полей которая минимизирует размер.

go
type A struct { a bool b int64 c bool d int32 } type B struct { a int8 b int64 c int16 d int32 e bool } type C struct { a bool b bool c int64 d int32 e bool }

Подсказка: Выравнивание поля = его размер (до 8 байт). Padding вставляется чтобы следующее поле было выровнено. Размер структуры кратен наибольшему выравниванию среди полей.

Решение:

go
// A: bool(1) + 7pad + int64(8) + bool(1) + 3pad + int32(4) = 24 байта // Оптимально: int64(8) + int32(4) + bool(1) + bool(1) + 2pad = 16 байт type AOpt struct { b int64 d int32 a bool c bool // 2 байта padding } // B: int8(1) + 7pad + int64(8) + int16(2) + 2pad + int32(4) + bool(1) + 7pad = 32 байта // Оптимально: int64(8) + int32(4) + int16(2) + int8(1) + bool(1) = 16 байт type BOpt struct { b int64 d int32 c int16 a int8 e bool } // C: bool(1) + bool(1) + 6pad + int64(8) + int32(4) + bool(1) + 3pad = 24 байта // Оптимально: int64(8) + int32(4) + bool(1) + bool(1) + bool(1) + 1pad = 16 байт type COpt struct { c int64 d int32 a bool b bool e bool // 1 байт padding } // Проверить: unsafe.Sizeof(A{}) и т.д. import "unsafe" fmt.Println(unsafe.Sizeof(A{})) // 24 fmt.Println(unsafe.Sizeof(AOpt{})) // 16