Структура говорит «всё это вместе». 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 это в лучшем случае неуточнённое поведение, поэтому и существует шаблон размеченного объединения: держать рядом метку, говорящую, какой член сейчас активен.