Лекция 4. Структуры и объединения
Структуры и их инициализация, вложенность, копирование и передача в функции, указатели на структуры, раскладка в памяти, выравнивание и padding, уплотнение, alignas, объединения, tagged union и std::variant.
До сих пор мы хранили связанные данные в отдельных переменных или параллельных массивах. Структура объединяет поля в один пользовательский тип, а объединение позволяет разным типам жить в одной и той же памяти. На лекции разберём, как поля лежат в памяти, почему размер структуры не равен сумме размеров полей и как безопасно выбрать один из нескольких вариантов.
Структура — это способ сказать компилятору и читателю, что некоторые данные принадлежат друг другу. Если вы можете назвать объект одним существительным и перечислить его части, скорее всего, это структура.
1. Зачем структуры
Пусть нужно хранить точку на плоскости. Без структуры координаты разбросаны:
int point_x = 10;
int point_y = 20;Функции, принимающие такие данные, должны получать две величины, и ничто не мешает перепутать порядок. Структура собирает поля в один тип:
struct Point {
int x;
int y;
};
void PrintPoint(Point point) {
std::cout << '(' << point.x << ", " << point.y << ")\n";
}
int main() {
Point first{10, 20};
Point second{200, 300};
PrintPoint(first);
PrintPoint(second);
}Объявление struct Point { ... }; определяет новый тип. Точка с запятой в конце обязательна.
2. Инициализация
Простейшая форма — списковая инициализация в фигурных скобках, значения идут по порядку полей:
Point first{10, 20};
Point second{}; // обе координаты нулевыеС C++20 доступны designated initializers — имена полей в списке:
Point third{.x = 10, .y = 20};Правила для них такие:
- поля перечисляются в порядке объявления;
- поля можно пропускать, пропущенные получают значение по умолчанию;
- нельзя смешивать именованные и позиционные инициализаторы;
- нельзя переставлять поля:
Point{.y = 20, .x = 10}не скомпилируется.
Именованная инициализация в C++20 жёстко привязана к порядку объявления. Если переставить поля в заголовке, код, который их инициализирует, может перестать компилироваться, либо, что хуже, молча получить не те значения.
3. Доступ к полям
Для обычного объекта используется точка:
Point point{1, 2};
point.x = 200;
std::cout << point.x << '\n';Если у нас указатель на структуру, используем стрелку ->. Выражения pointer->field и (*pointer).field эквивалентны.
4. Вложенные структуры
Полем структуры может быть другая структура. Так собирают сложные модели из простых частей:
struct Rectangle {
Point left_top;
Point right_bottom;
};
Rectangle rectangle{
.left_top = {0, 100},
.right_bottom = {200, 0},
};
rectangle.left_top.x = 10; // сначала выбираем left_top, затем xТочки в цепочке читаются слева направо: сначала поле верхнего уровня, потом поле внутри него.
Пример с кнопкой на экране показывает ту же идею:
struct Position {
int x;
int y;
};
struct Size {
std::size_t width;
std::size_t height;
};
struct Button {
Position position;
Size size;
};
Button button{
.position = {0, 100},
.size = {400, 80},
};5. Операции со структурами
Компилятор генерирует для структуры копирование и присваивание по умолчанию: каждое поле копируется отдельно. Если все поля копируемы, копируема и структура:
Point first{1, 2};
Point second = first; // копия полей
second.x = 10; // first при этом не меняется
first = second; // присваивание копирует все поляСтруктуру можно передавать в функцию, возвращать из функции и брать её адрес. Функции, создающие и складывающие точки, выглядят естественно:
Point MakePoint(int x, int y) {
return Point{.x = x, .y = y};
}
Point Add(Point left, Point right) {
return Point{
.x = left.x + right.x,
.y = left.y + right.y,
};
}Передача по значению копирует всё. Для больших структур это дорого, и обычно их передают по константной ссылке. Этот способ разберём, когда изучим ссылки.
6. Массивы структур
Структуры хранят в массивах так же, как встроенные типы:
#include <string>
struct Record {
std::string name;
std::string surname;
long phone;
};
Record phonebook[200]{};Каждый элемент — самостоятельный объект Record со своими полями. Строки здесь std::string, которые сами управляют памятью: об этом подробнее расскажем позже.
7. Указатели на структуры
Поиск по телефонной книге возвращает указатель на найденную запись:
#include <cstddef>
Record* FindRecord(long phone, Record* records, std::size_t count) {
for (std::size_t index = 0; index < count; ++index) {
if (records[index].phone == phone) {
return &records[index];
}
}
return nullptr;
}Использование результата:
Record* record = FindRecord(22345, phonebook, 200);
if (record != nullptr) {
std::cout << record->name << ' ' << record->surname << '\n';
}Указатель на элемент массива остаётся действительным, пока существует сам массив и элемент не был перемещён или удалён. Если массив изменился, указатели на его элементы могут стать недействительными.
8. Раскладка структуры в памяти
Каждое поле занимает своё место, и компилятор решает, где его разместить. Главные понятия:
- Выравнивание (alignment) — требование к адресу объекта: например, при
alignof(T) == 8адрес кратен 8. Выравнивание помогает процессору обращаться к данным быстро и корректно. - Padding — промежутки, которые компилятор вставляет между полями и в конце структуры, чтобы выполнить выравнивание.
- Размер структуры может отличаться от суммы размеров полей, а размер и выравнивание — это разные величины.
Рассмотрим две структуры с одинаковыми полями, но в разном порядке. Предположим, int64_t требует выравнивания 8 байт:
struct PoorLayout {
char a;
std::int64_t b;
char c;
};
struct CompactLayout {
std::int64_t b;
char a;
char c;
};В PoorLayout поле a занимает один байт, затем идут семь байт padding, чтобы b начиналось с адреса, кратного 8. После b идёт поле c, и в конце добавляется ещё padding, чтобы размер стал кратен выравниванию. Полезные данные занимают 10 байт, а размер структуры становится 24.
В CompactLayout поле b стоит первым и сразу выровнено. Поля a и c умещаются рядом, и остаётся только завершающий padding. Размер становится 16 байт.
Вывод: переставляя поля от большего к меньшему, можно уменьшить padding. Это не всегда важно, но для больших массивов экономия становится заметной.
Все приведённые цифры верны для платформы, где int64_t выравнивается по 8 байтам. На другой архитектуре числа могут отличаться. Проверяйте sizeof и alignof на своей системе, а не верьте таблицам из лекции слепо.
9. Уплотнение структуры
Компилятор можно попросить убрать padding с помощью #pragma pack:
#include <cstdint>
struct Data {
char tag;
std::int32_t value;
};
#pragma pack(push, 1)
struct PackedData {
char tag;
std::int32_t value;
};
#pragma pack(pop)Типичный результат на 64-битной платформе: Data занимает 8 байт, выравнивание 4, поле value начинается со смещения 4. PackedData занимает 5 байт, выравнивание 1, value сразу после tag.
Экономия памяти получается, но за неё приходится платить. Поле value может оказаться невыровненным: процессор будет читать его медленнее или, на некоторых архитектурах, откажется работать. #pragma pack — расширение компилятора, а не часть стандарта. При работе с бинарными форматами нужно также учитывать порядок байтов.
Уплотнение уменьшает объём данных, и массив лучше помещается в кеш. Но невыровненное поле может пересекать границу кеш-линии, и процессор выполнит два чтения вместо одного. Итог зависит от процессора, компилятора и того, что вы делаете с данными. Измеряйте, а не предполагайте.
9.1. Бенчмарк на практике
Чтобы сравнить два варианта, удобно использовать Google Benchmark. Идея такая: создаём массив из 4096 элементов в обычной и в уплотнённой структуре, суммируем значение поля в цикле и измеряем время. Создание массива вне измеряемого цикла.
Для воспроизведения удобно запустить оба теста в Quick Bench, выбрав GCC, C++23 и -O3. Функции benchmark::DoNotOptimize и benchmark::ClobberMemory не дают компилятору выбросить вычисления как ненужные.
Тонкость: результат показывает совокупный эффект уплотнения, включая выравнивание, плотность данных и векторизацию. Поэтому не делайте общих выводов из одного эксперимента.
10. alignas: увеличение выравнивания
Иногда нужно наоборот: выровнять объект сильнее обычного. Это полезно, когда разные потоки часто меняют соседние объекты. Если объекты лежат в одной кеш-линии, их изменения в разных ядрах мешают друг другу. Такое явление называют false sharing.
#include <cstdint>
struct Data {
std::int32_t value;
};
struct alignas(64) AlignedData {
std::int32_t value;
};
AlignedData items[2]{};alignas(64) размещает каждый элемент на границе 64 байт. Размер AlignedData становится 64 байта вместо 4. Ценой становится расход памяти.
Размер кеш-линии зависит от платформы, а 64 байта здесь — условие примера, а не гарантия языка. В стандарте есть константа std::hardware_destructive_interference_size из заголовка <new>, которая задаёт рекомендуемое расстояние между независимо изменяемыми объектами. Её значение определяет реализация на этапе компиляции.
Есть и другая причина для alignas: некоторые SIMD-инструкции требуют выравнивания данных по определённой границе.
11. Объединения
Поля union используют одну и ту же область памяти. В каждый момент активно только одно поле:
#include <cstdint>
union Value {
std::int64_t integer;
double real;
char text[16];
};
Value value{};
value.integer = 239; // активно поле integer
value.real = 3.14; // теперь активно поле realРазмер объединения равен размеру самого большого поля с учётом выравнивания.
В стандартном C++ читать поле, которое не было последним записанным, нельзя. Объединение — средство хранить альтернативные объекты в одной памяти, а не способ переводить биты из одного типа в другой. Если нужно скопировать битовое представление, используйте std::memcpy, а для типов одинакового размера в C++20 — std::bit_cast.
Некоторые компиляторы, включая GCC, документируют чтение неактивного поля как расширение. Опираться на него переносимо нельзя.
12. Tagged union: данные с меткой
Раз в объединении нельзя понять, какое поле активно, добавим рядом тег, который это сообщает. Так получается tagged union.
Пусть фигуры хранятся в массиве, и каждая фигура — либо треугольник, либо прямоугольник, либо круг:
struct Point {
float x;
float y;
};
struct Triangle {
Point a;
Point b;
Point c;
};
struct Rectangle {
Point left_top;
Point right_bottom;
};
struct Circle {
Point center;
float radius;
};
enum class FigureType {
Triangle,
Rectangle,
Circle,
};
union FigureData {
Triangle triangle;
Rectangle rectangle;
Circle circle;
};
struct Figure {
FigureType type;
FigureData data;
};Тег type сообщает, какое поле data активно. Программа должна сама поддерживать согласованность: изменение тега без записи в поле оставит данные мусором.
Обход массива с выбором по тегу:
Figure figures[] = {
{FigureType::Circle, {.circle = {{0, 0}, 10}}},
{FigureType::Rectangle, {.rectangle = {{0, 10}, {20, 0}}}},
};
for (Figure figure : figures) {
switch (figure.type) {
case FigureType::Circle:
std::cout << "Radius: " << figure.data.circle.radius << '\n';
break;
case FigureType::Rectangle:
std::cout << "Left x: " << figure.data.rectangle.left_top.x << '\n';
break;
case FigureType::Triangle:
std::cout << "Vertex A x: " << figure.data.triangle.a.x << '\n';
break;
}
}Переменная цикла здесь берётся по значению: ссылки ещё не изучены.
13. std::variant
Современный и безопасный аналог tagged union — std::variant из заголовка <variant>:
#include <variant>
using Figure = std::variant<Triangle, Rectangle, Circle>;
Figure figure = Circle{
.center = {0.0F, 0.0F},
.radius = 10.0F,
};std::variant сам хранит тег и следит за временем жизни активного объекта. Для обработки используют std::get, std::get_if и std::visit. Попытка получить не тот тип через std::get вызывает исключение, а не даёт мусор.
Частые ошибки
- Предположение, что
sizeofструктуры равен сумме размеров полей. - Перестановка полей в заголовке, которая ломает именованную инициализацию.
- Чтение неактивного поля
union. - Тег tagged union, не совпадающий с реально записанным полем.
- Уплотнение структуры без проверки, что это действительно нужно и быстрее.
Чек-лист
- Могу объявить структуру и инициализировать её списком и именованными полями.
- Понимаю, что такое выравнивание и padding, и могу предсказать размер простой структуры.
- Знаю, почему чтение неактивного поля
unionнедопустимо. - Умею написать tagged union с согласованным тегом.
- Знаю, что
std::variantбезопаснее tagged union.
Шпаргалка
| Понятие | Суть |
|---|---|
struct | Объединяет поля в пользовательский тип |
| Designated initializers | Именованная инициализация в порядке объявления, C++20 |
. и -> | Доступ к полю объекта и через указатель |
| Выравнивание | Требование к адресу объекта, alignof |
| Padding | Промежутки, которые добавляет компилятор |
#pragma pack | Уплотнение структуры, расширение компилятора |
alignas | Увеличение выравнивания, например для кеш-линий |
union | Одна область памяти для нескольких полей, одновременно активно одно |
| Tagged union | Объединение с тегом, который сообщает активное поле |
std::variant | Безопасная альтернатива tagged union |
Актуальная версия: https://m3105.ru/notes/osnovy-programmirovaniya/lektsiya-4-struktury-i-obedineniya

Комментарии0
Пока никто ничего не написал.
Войдите, чтобы оставить комментарий