21. Struct. Пользовательские типы данных
В классических объектно-ориентированных языках (Java, C#, PHP) главным строительным кирпичиком прикладной архитектуры является «Класс» (Class). Класс смешивает воедино состояние (поля данных), поведение (методы), сокрытие реализации и сложную невидимую машинерию (таблицы виртуальных методов vtable для реализации полиморфизма и наследования).
В языке Go классов нет вовсе. Создатели языка вернулись к традициям Bell Labs: данные и поведение должны быть строго и ортогонально разделены. Для описания данных в Go используется структура (struct) — прозрачный, легковесный и предсказуемый составной тип данных, представляющий собой строго непрерывный блок оперативной памяти.
В этой главе мы препарируем анатомию структур, выясним, почему порядок объявления полей кардинально влияет на объем потребляемой памяти (Memory Padding), и исследуем уникальную «нулевую структуру» struct{}, ставшую стандартом в высокопроизводительных микросервисах.
Синтаксис и Zero Value
Структура объединяет несколько поименованных полей различных типов под общим типом:
package main
import "time"
type User struct {
ID int64
Email string
IsActive bool
CreatedAt time.Time
}
Как и любая переменная в Go, структура гарантированно и безопасно инициализируется рантаймом. При объявлении var u User рантайм выделяет непрерывный кусок памяти и заполняет каждое поле его нулевым значением (Zero Value):
IDполучает0Emailполучает пустую строку""IsActiveполучаетfalseCreatedAtполучает нулевое значение времениtime.Time{}(0001-01-01).
Никаких неинициализированных указателей и коварных NullReferenceException при чтении u.ID не может возникнуть в принципе.
Анонимные структуры
Если структура необходима лишь один раз (например, при парсинге локального фрагмента JSON, конфигурации или в табличных тестах Table-Driven Tests), объявлять для нее глобальный type не обязательно. Язык позволяет объявить анонимную структуру по месту:
config := struct {
Host string
Port int
}{
Host: "localhost",
Port: 8080,
}
Mechanical Sympathy: Выравнивание памяти (Padding)
Это фундаментальный раздел низкоуровневой оптимизации. Непонимание этого механизма приводит к тому, что структуры в оперативной памяти раздуваются в 1.5–2 раза по сравнению с реальным объемом полезных данных.
Процессор не считывает оперативную память по отдельным байтам: шина данных обращается к памяти «машинными словами» (Words). На 64-битной микроархитектуре слово составляет 8 байт. Чтобы процессор мог загрузить значение в регистр за один такт CPU, компилятор обязан размещать переменные по физическим адресам, кратным их размеру. Это требование называется выравниванием памяти (Memory Alignment).
Например, 64-битное целое число int64 (8 байт) обязано начинаться с адреса, кратного 8. Если разместить его со смещением, последствия зависят от архитектуры: на одних процессорах невыровненный доступ запрещён и вызывает исключение, на других (x86-64, современный ARM) внутри кэш-линии он обычно бесплатен, но становится медленным, если пересекает границу кэш-линии или страницы — тогда процессору приходится совершить два чтения и склеить половинки. Компилятор Go выравнивает поля, чтобы этого не было. Для Go выравнивание важно ещё и из-за атомарных операций: 64-битные атомики на 32-битных платформах без выравнивания по 8 байт паникуют (с Go 1.19 типы atomic.Int64/atomic.Uint64 выравниваются автоматически).
Чтобы гарантировать выравнивание, компилятор Go вставляет между полями невидимые пустые байты — Padding.
Рассмотрим хрестоматийный пример неоптимальной структуры:
type BadStruct struct {
A int8 // 1 байт
B int64 // 8 байт
C int16 // 2 байта
}
Сколько памяти должна занимать BadStruct? Теоретический расчет: $1 + 8 + 2 = 11$ байт.
Однако если мы запросим физический размер через unsafe.Sizeof(BadStruct{}), мы получим 24 байта! Куда бесследно исчезли еще 13 байт?
- Поле
Aзанимает 1 байт. - Полю
B(8 байт) требуется адрес, кратный 8. Компилятор вставляет 7 пустых байт отступа (Padding) сразу послеA. - Поле
Bзанимает следующие 8 байт. - Поле
Cзанимает 2 байта (смещение 16 кратно 2). - Итоговый размер структуры обязан быть кратен ее самому крупному полю (8 байт), чтобы при создании непрерывного массива таких структур следующий элемент начинался с правильного адреса. Сумма $1 + 7 + 8 + 2 = 18$ байт. Ближайшее кратное восьми число — 24. Компилятор дописывает еще 6 пустых байт в хвост структуры!
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Bad ["BadStruct - 24 байта"]
direction LR
A["A<br>1 байт"] --- P1["Padding<br>7 байт"] --- B["B<br>8 байт"] --- C["C<br>2 байта"] --- P2["Padding<br>6 байт"]
end
Как это починить?
Золотое правило проектирования структур в Go: сортируйте поля по убыванию их выравнивания (unsafe.Alignof): сначала 8-байтные (int64, указатели, строки), затем 4-, 2- и 1-байтные. Для простых числовых полей это совпадает с убыванием размера, но не всегда: у массива [9]byte размер 9, а выравнивание — 1, и структура {[9]byte; int64; byte}, «отсортированная по размеру», займёт 32 байта вместо 24.
type GoodStruct struct {
B int64 // 8 байт
C int16 // 2 байта
A int8 // 1 байт
}
Проведем повторный расчет:
Bзанимает 8 байт (адреса 0–7).C(2 байта) ложится сразу следом (адреса 8–9, смещение 8 кратно 2, выравнивание соблюдено без отступа).A(1 байт) ложится на байт 10.- Текущий размер — 11 байт. Дополняем структуру до кратности 8 байтам: получаем 16 байт (всего 5 байт отступа в хвосте).
Мы сократили объем структуры на 8 байт (с 24 до 16 байт — экономия 33% памяти), просто поменяв порядок строк в исходном коде! В кэше из миллиона таких объектов вы мгновенно экономите 8 Мегабайт RAM и резко увеличиваете плотность размещения данных в кэш-линиях L1/L2.
Пустая структура struct{} и zerobase
Один из самых выразительных идиоматичных приемов Go — применение пустой структуры struct{}:
package main
import (
"fmt"
"unsafe"
)
func main() {
var empty struct{}
fmt.Println(unsafe.Sizeof(empty)) // Выведет 0!
}
Структура без полей занимает строго 0 байт.
В главе 16. Slice. Главная структура данных в Go мы уже упоминали системную переменную рантайма zerobase. Все указатели на динамически созданные структуры нулевого размера ссылаются на этот единственный статический адрес zerobase. Вы можете создать миллиард пустых структур — они не потратят ни одного байта оперативной памяти.
Практическое применение
1. Реализация множества (Set) через Map
В Go нет отдельного типа Set (коллекции уникальных значений). Его эмулируют с помощью хэш-таблицы, где значениями служат пустые структуры:
// Идеальный In-Memory Set. Значения не занимают память!
visitedURLs := make(map[string]struct{})
visitedURLs["https://google.com"] = struct{}{}
if _, exists := visitedURLs["https://google.com"]; exists {
// URL уже посещен
}
Если бы вместо этого использовалась map[string]bool, каждое значение отнимало бы 1 байт на флаг true.
2. Каналы сигналов (Signal Channels)
Когда горутине требуется передать событие (сигнал пробуждения или завершения), а передаваемые данные не имеют значения, объявляют канал пустых структур:
done := make(chan struct{})
// Отправка 0 байт данных. Идеальный сигнал!
done <- struct{}{}
Отправка будит ровно одного получателя. Чтобы оповестить сразу всех ожидающих (broadcast), канал закрывают: close(done) — после этого каждое чтение <-done мгновенно возвращается.
Теги структур (Struct Tags)
Поскольку поля структуры в памяти — это сырые типизированные байты, внешние сериализаторы (JSON, Protobuf, XML) или ORM-драйверы баз данных не знают, как проецировать эти поля на внешние протоколы.
Для этой задачи в Go введены Теги структур (Struct Tags) — строковые метаданные, указываемые после типа поля в обратных апострофах:
type User struct {
ID int64 `json:"id" db:"user_id"`
Password string `json:"-"` // Тире означает: игнорировать поле при JSON-сериализации
}
Передача структуры по значению
Вспомним лекцию 14. Указатели в Go: в языке Go господствует семантика передачи по значению (Pass by value).
Если структура занимает 128 байт, передача ее в функцию по значению приведет к честному побитовому копированию всех 128 байт:
func process(u User) {
u.IsActive = true // Мутирует КОПИЮ! Оригинал не изменится.
}
Когда оправдано использование указателя *User?
- Когда логика функции обязана мутировать оригинальное состояние структуры вызывающей стороны.
- Когда структура имеет большой вес (сотни байт и выше), и ее постоянное копирование создает избыточную нагрузку на шину памяти. Однако помните про Escape Analysis: возврат указателя из функции перенесет структуру в Кучу (Heap), создавая нагрузку на Garbage Collector. Для небольших структур (до 64–128 байт) передача копией зачастую работает быстрее благодаря идеальной локальности процессорного кэша (Cache Locality).
Итог
struct— составной тип данных, представляющий собой непрерывный монолитный блок в оперативной памяти. Никакой скрытой магии vtable.- Padding (Выравнивание памяти): Процессор считывает память 8-байтными словами. Компилятор добавляет пустые байты между полями. Сортируйте поля от больших к меньшим, экономя до 30–50% памяти.
struct{}: Пустая структура весит строго 0 байт и указывает наzerobase. Идеальна для реализации множеств (map[T]struct{}) и сигналов в каналах (chan struct{}).- Теги структур: Метаданные типов, считываемые пакетом
reflectво время выполнения для сериализации данных.
Структуры формируют анатомию данных вашей системы. Но как вдохнуть в эти данные жизнь? Как связать функции с конкретными структурами, не скатываясь в громоздкие классы?
В следующей главе — 22. Методы. Value Receiver и Pointer Receiver — мы исследуем методы в Go, поймем механику работы получателей (receivers) и выясним, как компилятор трансформирует вызовы методов в обычные плоские функции.