Перейти к основному содержимому

Структуры и объединения

Разработчику Архитектору

Структуры и объединения

Структура (struct) — способ собрать несколько полей разных типов под одним именем. Объединение (union) — те же поля, но в памяти они делят одно и то же место: в каждый момент времени активно по смыслу одно поле.

Аналогия — структура — анкета с графами "имя", "возраст", "рост"; объединение — один конверт, куда кладут либо паспорт, либо водительские права, но не оба сразу с разным содержимым.

Структуры моделируют сущности — пользователь, пакет в сети, запись в файле. Объединения — экономия памяти и разбор форматов "либо int, либо float" (с осторожностью к типам).


Объявление и определение структуры

Объявление структуры начинается с ключевого слова struct, за которым следует имя структуры. Это имя становится новым идентификатором типа. После имени в фигурных скобках перечисляются поля — переменные различных типов, которые будут входить в состав структуры. Завершается определение точкой с запятой.

Пример:

struct Book {
char title[100];
char author[50];
int year;
float price;
};

Разбор:

  • struct Book объявляет новый пользовательский тип с четырьмя полями разных типов.
  • Поля title и author — фиксированные символьные массивы для строковых данных.
  • year и price хранят числовые свойства книги (целое и вещественное).
  • Определение структуры задаёт формат объекта, но не выделяет память под конкретный экземпляр.

В этом примере создана структура Book, содержащая четыре поля — название книги, имя автора, год издания и цена. Каждое поле имеет свой тип и имя. Память для этих полей выделяется только при создании переменной этого типа, а не при объявлении самой структуры.

Существует также возможность объявить структуру без её определения:

struct Person;

Разбор:

  • Это предварительное объявление типа (forward declaration), создающее неполный тип.
  • Компилятор узнаёт имя структуры, но пока не знает её размер и состав полей.
  • Такой приём нужен, когда достаточно работать с указателями на тип до полного определения.
  • Часто применяется для разрыва циклических зависимостей между заголовочными файлами.

Такое объявление создаёт неполный тип. Компилятор знает, что такой тип существует, но не знает его размера и состава. Неполный тип нельзя использовать для создания переменных, но можно объявлять указатели на него. Это полезно при работе с рекурсивными структурами или при разделении интерфейса и реализации.

Полное определение структуры должно быть доступно в том месте программы, где происходит обращение к её полям или создание экземпляров.


Создание переменных структурного типа

После определения структуры можно объявлять переменные этого типа:

struct Book my_book;

Разбор:

  • Создаётся переменная my_book типа struct Book.
  • Память под все поля структуры выделяется целиком в месте объявления (например, в стеке для локальной переменной).
  • Значения полей без явной инициализации остаются неопределёнными для автоматических переменных.
  • После создания к полям обращаются через оператор ..

Эта строка резервирует в памяти место, достаточное для хранения всех полей структуры Book. Размер структуры не всегда равен сумме размеров её полей — компилятор может вставлять дополнительные байты между полями для выравнивания по границам памяти, чтобы ускорить доступ к данным. Это поведение зависит от архитектуры процессора и настроек компилятора.

Переменные структурного типа можно инициализировать сразу при объявлении. Существуют два способа инициализации:

  • По позиции: значения указываются в том же порядке, в котором объявлены поля.
struct Book novel = {"1984", "George Orwell", 1949, 29.99};

Разбор:

  • Это позиционная инициализация: значения сопоставляются полям строго по порядку их объявления.

  • Строковые литералы копируются в массивы title и author структуры.

  • 1949 и 29.99 инициализируют числовые поля year и price.

  • Такой стиль короткий, но чувствителен к порядку полей, поэтому при изменении структуры может стать хрупким.

  • По имени: каждому полю явно присваивается значение, независимо от порядка.

struct Book novel = {.title = "1984", .author = "George Orwell", .year = 1949, .price = 29.99};

Разбор:

  • Именованная инициализация (.field = value) явно связывает значение с конкретным полем.
  • Порядок перечисления полей в таком стиле не критичен для корректности.
  • Повышается читаемость: сразу видно, что именно инициализируется.
  • Этот подход снижает риск ошибок при добавлении новых полей в структуру.

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


Доступ к полям структуры

Для обращения к отдельным полям используется оператор точки (.):

my_book.year = 2025;
printf("Книга: %s, автор: %s\n", my_book.title, my_book.author);

Разбор:

  • my_book.year = 2025; записывает значение в конкретное поле структуры через оператор ..
  • printf читает строковые поля структуры и подставляет их в формат %s.
  • Доступ по имени поля делает код самодокументируемым: видно, какие части объекта используются.
  • Такой стиль подходит для работы со структурами, передаваемыми по значению.

Оператор точки применяется к переменной структурного типа и позволяет читать или изменять значение любого её поля. Все операции над полями выполняются так же, как над обычными переменными соответствующего типа.

Если переменная является указателем на структуру, используется оператор стрелки (->), который совмещает разыменование указателя и доступ к полю:

struct Book *ptr = &my_book;
ptr->price = 34.50;

Разбор:

  • ptr — указатель на структуру Book, получающий адрес my_book.
  • Оператор -> объединяет разыменование и доступ к полю в одной записи.
  • ptr->price = 34.50; изменяет поле price в исходном объекте, а не в копии.
  • Это базовый паттерн для функций, которые должны менять структуру "снаружи".

Это эквивалентно записи (*ptr).price = 34.50;, но более кратко и удобно.


Область видимости структур

Структуры подчиняются тем же правилам области видимости, что и переменные и функции. Если структура объявлена вне функций — на уровне файла, — она доступна во всех функциях, расположенных ниже по тексту программы. Такие структуры обычно размещают в заголовочных файлах (.h), чтобы использовать их в нескольких исходных файлах проекта.

Если структура объявлена внутри функции или даже внутри блока кода (например, внутри фигурных скобок в цикле или условии), она существует только в пределах этой области. Вне блока структура недоступна, и попытка использовать её вызовет ошибку компиляции.

Вложенные объявления структур с одинаковыми именами не приводят к конфликту — внутреннее объявление скрывает внешнее в своей области видимости. Однако переменные, созданные до переопределения, сохраняют свою принадлежность к исходному типу.


Копирование и присваивание структур

Структуры одного типа можно присваивать друг другу напрямую:

struct Book original = {"Dune", "Frank Herbert", 1965, 25.0};
struct Book copy = original;

Разбор:

  • copy = original выполняет побитовое копирование всех полей структуры.
  • Для встроенных типов и статических массивов это обычно безопасно и ожидаемо.
  • Если бы внутри структуры были указатели на динамические данные, копировались бы только адреса (поверхностная копия).
  • Такой фрагмент полезен для понимания различия между значимыми типами и ссылочной семантикой.

При таком присваивании выполняется побайтовое копирование содержимого. Все поля получают копии значений из исходной структуры. Это работает корректно для простых типов и массивов фиксированного размера. Однако если структура содержит указатели, копируется только адрес, а не данные, на которые он указывает. Такое поведение называется поверхностным копированием и требует осторожности при работе с динамической памятью.


Упрощение синтаксиса с помощью typedef

Часто используется конструкция typedef для создания псевдонима типа структуры:

typedef struct {
char model[30];
int power;
float weight;
} Car;

Разбор:

  • typedef создаёт псевдоним Car для анонимной структуры, убирая необходимость писать struct в каждом объявлении.
  • Поля model, power, weight описывают свойства автомобиля в одном типе.
  • Конструкция особенно удобна в публичных API, где важна компактность и читаемость сигнатур.
  • Анонимная структура здесь уместна, так как отдельное имя struct не требуется.

Теперь вместо struct Car можно писать просто Car:

Car my_car = {"Tesla Model S", 670, 2100.5};
printf("%s, %d л.с.\n", my_car.model, my_car.power);

Разбор:

  • Объявляется переменная my_car типа Car, созданного через typedef.
  • Используется позиционная инициализация по порядку полей структуры.
  • my_car.model и my_car.power читаются через оператор . как обычные поля.
  • printf с %s и %d выводит строку модели и мощность в лошадиных силах.
  • Код короче, потому что не нужно писать struct Car в каждом объявлении.

Это делает код короче и чище, особенно в больших проектах. При использовании typedef имя структуры можно опустить, если оно не требуется для рекурсивных ссылок или других целей.


Альтернативные способы определения

Хотя основной способ — использование struct и, при необходимости, typedef, существуют и другие подходы. Например, препроцессорная директива #define позволяет создать макрос, раскрывающийся в определение структуры:

#define POINT struct { int x; int y; }
POINT p1 = {10, 20};

Разбор:

  • Макрос POINT подставляет текст структуры на этапе препроцессора.
  • POINT p1 = {10, 20}; после раскрытия макроса превращается в объявление переменной анонимной структуры.
  • Такой приём усложняет отладку, потому что ошибки возникают уже в раскрытом тексте, а не в исходном виде.
  • Для обычного кода предпочтительнее typedef, так как он типобезопаснее и читабельнее.

Такой метод редко используется в современной практике, так как усложняет отладку и снижает читаемость. Он может быть полезен в специфических случаях метапрограммирования или генерации кода, но не рекомендуется для повседневного применения.


Практическое значение структур

Структуры лежат в основе многих концепций системного программирования. Они используются для представления записей в файлах, пакетов в сетевых протоколах, элементов графического интерфейса, узлов деревьев и списков. Без структур невозможно было бы эффективно организовать сложные данные в языке, где отсутствуют встроенные классы или объекты.

Структуры позволяют передавать связанные данные в функции как единый аргумент, а не как множество отдельных параметров. Это упрощает сигнатуры функций и делает код более модульным. Функции могут принимать структуры по значению (копируя их) или по указателю (работая с оригиналом), что даёт контроль над производительностью и семантикой изменений.


Размер структуры и выравнивание (padding)

Компилятор может вставлять пустые байты между полями, чтобы адреса совпадали с требованиями процессора (быстрый доступ к int и double).

struct Example {
char c; /* 1 байт */
/* возможно 3 байта "padding" */
int i; /* 4 байта */
};

Разбор:

  • Поле char c занимает 1 байт, но следующее int i обычно требует выравнивания по границе 4 байт.
  • Компилятор может вставить padding между полями, чтобы доступ к int был эффективным на архитектуре.
  • Из-за padding фактический размер структуры часто больше арифметической суммы размеров полей.
  • Это важно учитывать при бинарной сериализации и работе с аппаратными/сетевыми форматами.

sizeof(struct Example) часто равен 8, а не 5. Узнать размер и смещения:

printf("sizeof = %zu\n", sizeof(struct Example));
printf("offset i = %zu\n", offsetof(struct Example, i));

Разбор:

  • sizeof(struct Example) возвращает полный размер структуры в байтах с учётом выравнивания.
  • offsetof(struct Example, i) показывает смещение поля i от начала структуры.
  • %zu — корректный формат для типа size_t, который возвращают sizeof и offsetof.
  • Такой диагностический код помогает проверять ABI-предположения и искать скрытый padding.

Заголовок <stddef.h> даёт макрос offsetof. Для сетевых протоколов и файлов на диске иногда задают упакованное выравнивание (#pragma pack — расширение компилятора) или выкладывают поля вручную в uint8_t буфер.


Вложенные структуры и массивы полей

struct Point { int x; int y; };

struct Line {
struct Point start;
struct Point end;
};

Разбор:

  • Line использует композицию: её поля сами являются структурами Point.
  • Такой подход моделирует иерархические данные без объектной системы.
  • Доступ к вложенным данным выполняется цепочкой полей, например line.start.x.
  • Память под вложенные структуры размещается внутри внешней структуры как единый блок.

Доступ: line.start.x. Массив внутри структуры:

struct Buffer {
size_t len;
char data[128];
};

Разбор:

  • len хранит текущую длину полезных данных, а data[128] — фиксированный внутренний буфер.
  • Размер такого объекта известен на этапе компиляции, что упрощает выделение и копирование.
  • Поле data встроено в структуру, поэтому отдельный malloc для него не нужен.
  • Этот шаблон часто используется для небольших сообщений и stack-friendly буферов.

data занимает 128 байт внутри структуры (фиксированный размер). Для переменной длины используют указатель char *data и отдельный malloc — см. Основы.


Объединения (union)

Объявление похоже на структуру, но ключевое слово union:

union Value {
int i;
float f;
char bytes[4];
};

union Value v;
v.i = 42;
printf("%d\n", v.i);

Разбор:

  • Объявляется union, где i, f и bytes делят одну и ту же память.
  • v.i = 42; записывает биты целого значения в общий блок объединения.
  • printf("%d\n", v.i); читает те же биты в том же представлении, поэтому вывод предсказуем.
  • Если после этого записать v.f, интерпретация v.i уже не будет логически валидной.

Все поля начинаются с одного адреса. sizeof(union Value) равен размеру самого большого поля (с учётом выравнивания). Запись в v.f перезаписывает биты, которые интерпретировались как v.i — читать другое поле без явного соглашения в коде опасно.

Типичное применение — разбор бинарного формата:

union {
uint32_t word;
uint8_t byte[4];
} u;
u.word = 0x01020304;
/* порядок byte[0]..byte[3] зависит от endianness машины */

Разбор:

  • В объединении word и byte смотрят на одни и те же 4 байта разными "линзами".
  • Запись u.word = 0x01020304; позволяет затем прочитать отдельные байты через u.byte[index].
  • Фактический порядок байтов зависит от endianness платформы, поэтому результат может отличаться между архитектурами.
  • Пример полезен для диагностики, но в переносимом протокольном коде безопаснее использовать маски и сдвиги.

Для переносимости между платформами предпочтительны явные сдвиги и маски с uint32_t, а не "смотреть в union".

Объединение с дискриминантом (идиома tagged union): рядом хранят enum "какой вариант активен" и union с полями; переключатель switch по enum задаёт, какое поле читать.

Код ITЗагрузка примера кода…

Разбор:

  • ValueKind kind — "тег", который говорит, какое поле union сейчас активно.
  • union экономит память: i и f не хранятся одновременно в разных ячейках.
  • switch (v->kind) выбирает корректное поле и предотвращает чтение "не того" варианта.
  • const Value *v передаёт объект только для чтения через указатель без копирования структуры.
  • Это базовая идиома для безопасной работы с вариантами данных в чистом C.

Пример использования узла связного списка:

Код ITЗагрузка примера кода…

Разбор:

  • typedef struct Node { ... } Node; описывает самоссылочный узел списка.
  • head — указатель на первый узел; NULL означает пустой список.
  • malloc(sizeof *n) выделяет память ровно под один Node (размер берётся из типа указателя).
  • n->data и n->next — доступ к полям структуры через указатель.
  • Вставка "в голову" (n->next = head; head = n;) — самый простой способ добавить элемент.
  • Память под узлы нужно освобождать (free) при удалении списка, иначе будет утечка.
Память из кучи

Память из malloc берётся из кучи процесса; каждому успешному malloc соответствует ровно один free на том же указателе (идиомы). Размер выделяйте через sizeof *ptr, а не sizeof (тип) вручную — так проще менять тип позже.


Битовые поля

Внутри структуры можно объявить поля по несколько бит:

struct Flags {
unsigned ready : 1;
unsigned mode : 3;
};

Разбор:

  • Объявляются битовые поля: ready занимает 1 бит, mode — 3 бита.
  • Такой синтаксис позволяет компактно хранить флаги в структуре.
  • Реальное расположение бит и итоговый размер структуры зависят от компилятора и ABI.
  • Для строго переносимого сетевого/дискового формата обычно предпочитают ручные маски (&, |, <<, >>).

Компилятор упаковывает биты в целые слова. Размер и порядок бит зависят от реализации — для переносимых протоколов чаще используют маски и сдвиги вручную.


Структура и указатель — разбор выражений

ВыражениеЧто происходит
s.yearполе year у переменной s
p->yearто же, если p указывает на структуру ((*p).year)
&sадрес всей структуры
sizeof(s)размер всей структуры в байтах

Передача в функцию:

void print_book(struct Book b); /* копия всей структуры */
void update_book(struct Book *b); /* адрес; изменения видны снаружи */

Разбор:

  • Первая сигнатура передаёт структуру по значению, поэтому внутри функции создаётся копия b.
  • Вторая принимает указатель, то есть работает с исходным объектом и может менять его поля "на месте".
  • Передача по указателю обычно дешевле для больших структур, потому что избегает полного копирования.
  • Выбор между этими вариантами задаёт семантику API: "читать копию" или "изменять оригинал".

Для больших структур в API почти всегда используют указатель, чтобы не копировать сотни байт на каждый вызов.

См. также: Хеш-таблица, Память процесса.