К содержимому
ИС30

Поиск по сайту

Конспекты, лабы, квизы, ЧаВо и страницы

Войти

Лекция 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