Если map — это ассоциативный контейнер, то struct — способ объединить разнородные данные в единую сущность. В Go struct — это основа для построения любой доменной модели, и за простым синтаксисом скрывается несколько нетривиальных механизмов: embedding, struct tags и то, как компилятор раскладывает поля в памяти.
Базовый синтаксис
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 — каждое поле инициализируется своим нулевым значением:
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 является значимым типом. При присвоении и передаче в функцию создаётся полная копия:
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:
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 — реализация интерфейсов:
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
Разрешение конфликтов имён
Если два встроенных типа имеют поле или метод с одинаковым именем, компилятор требует явного обращения:
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
При этом если поле есть у самой структуры и у встроенного типа — побеждает поле структуры (ближайший уровень):
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 в рантайме:
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:
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:
type User struct {
ID int `db:"id"`
Email string `db:"email"`
}
// sqlx сканирует строки по тегу db, а не по имени поля
rows.StructScan(&user)
validate (github.com/go-playground/validator):
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
Под капотом теги читаются так:
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 — байты-заглушки между полями:
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, int8 | 1 байт | 1 байт |
int16, uint16 | 2 байта | 2 байта |
int32, uint32, float32 | 4 байта | 4 байта |
int64, uint64, float64 | 8 байт | 8 байт |
string | 16 байт | 8 байт |
slice | 24 байта | 8 байт |
| указатель | 8 байт | 8 байт |
Практический пример оптимизации
Хорошее практическое правило: от большего к меньшему. Сначала 8-байтовые поля, потом 4-байтовые, потом 2-байтовые, потом 1-байтовые:
// Плохо — 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:
fmt.Println(unsafe.Sizeof(Request{})) // размер в байтах
fmt.Println(unsafe.Alignof(Request{})) // выравнивание структуры
fmt.Println(unsafe.Offsetof(Request{}.Score)) // смещение поля
Пустая структура — особый случай
struct{} имеет нулевой размер и широко используется как сигнальный тип:
// Канал-сигнал (не передаёт данных)
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 типы:
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 позволяет объявлять структуры без имени — прямо в месте использования. Это удобно для одноразовых данных:
// Временная группировка данных в тесте
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 — ошибка компиляции. Нужно достать копию, изменить, положить обратно.
Практика
Структура — это значимый или ссылочный тип в 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())
}
Реализуй конструктор NewUser, который принимает имя, возраст и email, валидирует их и возвращает (*User, error). Правила: имя не пустое, возраст 0–150, email содержит @.
Исправь код: функция должна обновлять возраст в оригинальной структуре, а не в копии.
Задачи: Struct
Задача 1: Конструктор и валидация
Уровень: Лёгкая
Что проверяет: паттерн конструктора, работа со структурами
Условие: Создай структуру User с полями Name string, Age int, Email string. Напиши конструктор NewUser который возвращает (*User, error) и валидирует: имя не пустое, возраст от 0 до 150, email содержит @.
Примеры:
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"
Решение:
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, переопределение методов
Условие: Что выведет код? Объясни поведение.
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())
}
Ожидаемый ответ:
Rex says: Woof!
I am Rex says: ...
I am Rex says: ...
Решение:
// 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-битной платформе. Затем для каждой предложи оптимальную раскладку полей которая минимизирует размер.
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 вставляется чтобы следующее поле было выровнено. Размер структуры кратен наибольшему выравниванию среди полей.
Решение:
// 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