Введение
Ранее мы разобрали struct, где все поля существуют одновременно, занимая суммарный объём памяти — union представляет принципиально иной подход, где все поля совместно используют ОДНУ И ТУ ЖЕ область памяти, и в этой статье разберём union вместе с битовыми полями, дающими ещё более тонкий контроль над использованием отдельных битов внутри структуры.
Концепция
union объявляет несколько полей разных типов, физически занимающих одну и ту же область памяти (размер которой равен размеру наибольшего из объявленных полей) — в любой конкретный момент времени реально «актуальным» является только последнее записанное поле, и попытка прочитать другое поле интерпретирует те же самые биты согласно типу этого другого поля, что может быть полезно (для намеренной, контролируемой переинтерпретации представления данных) или ошибочно (при случайном чтении «неправильного» поля union). Битовые поля — синтаксис, позволяющий указать точное количество битов, занимаемое отдельным полем структуры (unsigned int flag : 1; для поля размером ровно в один бит), что даёт компактное представление множества булевых флагов или небольших по диапазону значений без затрат целого байта/слова на каждый из них по отдельности.
Пример кода
#include <iostream>
union DataInterpretation {
int asInt;
float asFloat;
unsigned char asBytes[4]; // те же 4 байта, интерпретируемые как массив отдельных байтов
};
int main()
{
DataInterpretation data;
data.asFloat = 3.14f;
// Все три поля union занимают ОДНУ И ТУ ЖЕ область памяти —
// чтение asInt после записи asFloat даёт "сырое" битовое представление float, интерпретированное как int
std::cout << "Как float: " << data.asFloat << std::endl;
std::cout << "Как int (то же битовое представление!): " << data.asInt << std::endl;
std::cout << "sizeof(union): " << sizeof(DataInterpretation) << std::endl; // 4 — размер НАИБОЛЬШЕГО поля
return 0;
}
// Битовые поля — компактное хранение нескольких маленьких значений в пределах одного машинного слова
#include <iostream>
struct StatusFlags {
unsigned int isActive : 1; // ровно 1 бит — может быть только 0 или 1
unsigned int priority : 3; // ровно 3 бита — значения от 0 до 7
unsigned int errorCode : 4; // ровно 4 бита — значения от 0 до 15
// Суммарно 1+3+4 = 8 бит, скорее всего ВСЕ помещаются в единый байт, а не в 3 отдельных int
};
int main()
{
StatusFlags status;
status.isActive = 1;
status.priority = 5;
status.errorCode = 12;
std::cout << "sizeof(StatusFlags): " << sizeof(StatusFlags) << std::endl; // значительно меньше, чем 3 * sizeof(unsigned int)
return 0;
}
Пояснения к коду
DataInterpretation явно демонстрирует суть union — запись в asFloat, а затем чтение asInt показывает, что оба поля делят одну и ту же физическую память, и asInt после записи asFloat не «конвертирует» значение математически, а просто интерпретирует те же самые биты согласно правилам представления int вместо float, что и даёт визуально неожиданное, «сырое» числовое значение, не имеющее интуитивной связи с исходным значением 3.14. StatusFlags с битовыми полями isActive : 1, priority : 3, errorCode : 4 показывает практичное применение для компактного хранения — без битовых полей три отдельные unsigned int переменные заняли бы суммарно 12 байт (по 4 байта каждая на типичной платформе), тогда как битовые поля позволяют компилятору упаковать все три значения в значительно меньший объём памяти, реально нужный для представления их совместного диапазона значений.
Подводные камни
- Запись в одно поле
unionи последующее чтение другого поля без явного намерения именно такой переинтерпретации битов («type punning») — хотя это иногда легитимная, намеренная техника (как в примере сDataInterpretationдля иллюстрации внутреннего представленияfloat), случайное обращение к «неправильному» полю union (через путаницу в коде о том, какое поле было реально записано последним) даёт осмысленно выглядящее, но содержательно неверное значение без какой-либо автоматической проверки или предупреждения об этой ошибке. - Формально неопределённое поведение при чтении поля union, отличного от последнего записанного, согласно строгой трактовке стандарта C++ (хотя на практике это весьма распространённая, «почти всегда работающая» техника на большинстве реальных компиляторов и платформ) — для полностью переносимого, строго соответствующего стандарту кода, нуждающегося в переинтерпретации битового представления, современный C++ (начиная с C++20) предоставляет
std::bit_castкак явно определённую, корректную альтернативу намеренному type punning черезunion. - Зависимость порядка и точного распределения битовых полей в памяти от конкретного компилятора/платформы — стандарт C++ не строго фиксирует точный порядок упаковки битовых полей (порядок может отличаться между компиляторами, особенно учитывающими порядок байт платформы, big-endian/little-endian), и битовые поля, чьё точное бинарное представление критично (сетевые протоколы, бинарные форматы файлов, ожидающие конкретного порядка битов), требуют либо явной, ручной упаковки через побитовые операции вместо синтаксиса битовых полей, либо тщательной проверки реального поведения конкретного целевого компилятора и платформы.
- Доступ к битовым полям из нескольких потоков одновременно без должной синхронизации, ошибочно предполагая, что отдельные битовые поля одной структуры можно изменять независимо и потокобезопасно — поскольку несколько битовых полей физически могут располагаться в пределах одного машинного слова, операция записи одного битового поля на низком уровне может фактически читать-изменять-записывать целое содержащее слово, потенциально конфликтуя с одновременной записью другого, казалось бы «независимого» битового поля той же структуры из другого потока, что является разновидностью состояния гонки (race condition), разбираемого в основном цикле статей про многопоточность.