Menu

Объединение (union) в C: общая память, union против struct и размеченные объединения

Как union в C хранит несколько типов в одних и тех же байтах — почему его размер равен наибольшему члену, почему читать нужно тот член, который вы записали последним, и как размеченное объединение (enum плюс union) делает это безопасным.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Структура говорит «всё это вместе». union говорит «ровно одно из этого, по очереди». Члены уложены друг поверх друга по одному адресу, поэтому объединение не больше своего наибольшего члена, а запись в один член уничтожает остальные.

Звучит как ловушка, и без присмотра это она и есть. Но это же способ выразить в C значение, которое может быть одного из нескольких видов — токен, который является числом или строкой, сообщение, которое является щелчком или нажатием клавиши, — не платя за все варианты сразу.

Объявление и использование объединения

Синтаксис в точности повторяет структуру; меняется только ключевое слово.

К членам обращаются через . (или -> через указатель) — точно как у структуры. Отличие в том, что осмысленное значение хранит только последний записанный член. После v.f = 3.5f чтение v.i даст не 3, а то целое число, которое складывается из битового представления 3.5f.

Размер: побеждает наибольший член

Сравним расположение в памяти структуры и объединения с одинаковыми членами:

На типичной машине структура занимает 24 байта (4 под int, 8 под double, 1 под char плюс выравнивающие пропуски), а объединение — 8, размер своего double, округлённый вверх с учётом выравнивания. Три напечатанных адреса одинаковы, и это вся суть объединений в одной строке вывода.

Обратите внимание на приведения к void * для %p. Именно этого printf ожидает для %p; передача указателя другого типа — неопределённое поведение, даже если обычно всё выглядит работающим. См. спецификаторы формата.

Инициализация объединения

Инициализатор в фигурных скобках без указателя на член инициализирует первый член:

Использовать стоит именованную форму. {42} незаметно зависит от порядка членов, поэтому перестановка объявления позже меняет то, какой член инициализируется, — по-настоящему мерзкий баг, потому что внешне код не меняется никак.

Настоящая проблема: какой член активен?

Объединение не запоминает, в какой член вы записывали последним. Это просто байты; знание живёт у вас в голове, а именно там знание и теряется.

Напечатанное число велико и странно — это битовое представление 1.0f, прочитанное как int. Ничего не упало, никто не предупредил, а программа тихо неверна. Объединение сделало ровно то, что обещало; ошибка наша — мы забыли, какой член активен.

Решение: размеченное объединение

Стандартное решение — сочетать объединение с перечислением, которое фиксирует активный член, и обернуть оба в структуру. Такая комбинация называется размеченным объединением (tagged union, или дискриминированное объединение), и именно так стоит писать практически каждое объединение в прикладном коде.

Теперь каждое чтение проходит через switch по kind, поэтому прочитать член, который никогда не записывался, невозможно — при условии, что каждая запись задаёт и метку. Обычный способ сделать это невозможным забыть — завернуть записи в маленькие функции-конструкторы (value_from_int, value_from_string).

Экономия памяти реальна: каждое Value здесь стоит 24 байта полезной нагрузки плюс метка, а не 4 + 4 + 24, как у структуры, хранящей все три. На сотне тысяч таких значений это уже важно.

Компиляция с -Wall добавляет вторую страховочную сетку: если вы позже добавите VAL_BOOL в перечисление и забудете для него case, GCC предупредит о необработанном значении перечисления.

Анонимные объединения

C11 разрешает член-объединение без имени внутри структуры, поднимая его члены в пространство имён внешней структуры:

Поскольку само объединение не названо, вы пишете s->circle.r, а не s->as.circle.r. Читается короче ценой того, что факт наличия объединения вообще скрыт, — и это нормально, когда метка стоит тут же рядом.

Где объединения действительно отрабатывают своё место

Четыре повторяющихся применения:

  • Вариантные значения. Интерпретаторы, парсеры JSON и конфигураций, очереди сообщений — все они несут значения, тип которых определяется во время выполнения. Размеченное объединение — каноническое представление.
  • Компактные записи. Когда у структуры несколько взаимоисключающих полей, а самих структур миллионы, наложение их друг на друга — прямая экономия.
  • Форматы протоколов и оборудования. Пакет, чья полезная нагрузка зависит от байта заголовка, естественно ложится на размеченное объединение, как и регистры устройств.
  • Изучение байтов. Наложение значения на массив unsigned char[] позволяет посмотреть на его отдельные байты — например, чтобы определить порядок байтов:

Это каламбур типов (type punning) — намеренное чтение байтов как другого типа. Чтение через объединение подобным образом в C явно разрешено с результатом, определяемым реализацией (в отличие от приведения указателей между несвязанными типами, которое нарушает правила алиасинга), а рассматривать байты как unsigned char безопасно всегда. Переинтерпретация int как float — другое дело: результат целиком зависит от представления на вашей платформе, поэтому держите такое вне переносимого кода.

Распространённые ошибки

  • Чтение члена, который вы не записывали. Главная опасность. Используйте метку.
  • Предположение, что объединение преобразует. Оно не преобразует. u.i = 3; float f = u.f; переинтерпретирует биты; int i = 3; float f = i; преобразует. См. приведение типов.
  • Указатель внутри объединения, о котором вы забыли. Если одна ветка держит выделенный вами char *, перезапись объединения другим членом приводит к утечке — на буфер больше ничего не указывает. Освобождайте память до смены ветки.
  • Надежда, что компилятор проверит. Не проверит. Объединения — одна из немногих возможностей C, где язык не помогает вообще ничем, кроме размера и выравнивания; метка — ваше единственное ограждение.

Часто задаваемые вопросы

Что такое union в C?

Объединение — это тип, все члены которого делят одну и ту же память. Запись в один член затирает остальные, поэтому объединение в каждый момент хранит ровно один из своих членов. Объявляется как структура, но с ключевым словом union: union Value { int i; float f; };.

В чём разница между union и struct в C?

Структура даёт каждому члену собственное хранилище и держит их все одновременно, поэтому её размер не меньше суммы членов. Объединение накладывает все члены по одному адресу и держит по одному за раз, поэтому его размер равен размеру наибольшего члена.

Каков размер union в C?

Достаточный для самого большого члена, округлённый вверх с учётом выравнивания. Объединение из int (4 байта) и double (8 байт) занимает 8 байт, а не 12. Проверять на своей платформе следует через sizeof.

Что будет, если прочитать член union, который вы не записывали?

Вы переинтерпретируете те же байты как другой тип. Запись u.i = 1 с последующим чтением u.f ничего не преобразует — она читает битовое представление целого как число с плавающей точкой и даёт бессмысленное значение. В стандартном C это в лучшем случае неуточнённое поведение, поэтому и существует шаблон размеченного объединения: держать рядом метку, говорящую, какой член сейчас активен.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ