Об'єднання (union): один блок пам'яті, кілька інтерпретацій
Об'єднання (union): один блок пам'яті, кілька інтерпретацій
Сенсор із двома обличчями
Уявіть вбудований сенсор — наприклад, температурний датчик у промисловій системі моніторингу. Він передає 4-байтовий пакет даних по мережі. Пакет може означати одне з двох:
- вимірювання: значення
float, що містить температуру в кельвінах; - статус: значення
uint32_t, що містить бітові прапорці стану (помилка, калібрування, відключення).
Тип пакету визначається окремим полем заголовка. Якщо ви знаєте тип — ви знаєте, як інтерпретувати ці 4 байти.
Як зберегти такий пакет у C++? Перший інстинкт — структура з двома полями:
struct SensorData
{
float measurement; // 4 байти
uint32_t statusFlags; // 4 байти
};
Але це займе 8 байт, тоді як фізично дані займають лише 4 байти. Ніколи не може бути одночасно і вимірювання, і статусу — це взаємовиключні інтерпретації одного буфера. Витрата пам'яті подвоюється без жодної причини.
Саме для таких сценаріїв у C (і C++) існує конструкція union.
union — це не екзотика для вузьких спеціалістів. Це фундаментальна конструкція системного програмування, без якої неможливо ефективно реалізувати мережеві протоколи, парсери бінарних форматів, варіантні типи та деякі типи оптимізацій. Розуміння union є ознакою зрілості C++-програміста.Що таке union: фундаментальна концепція
Об'єднання (union) — це агрегатний тип даних, у якому всі члени займають одну й ту саму ділянку пам'яті. Розмір union дорівнює розміру найбільшого члена. Усі члени починаються з одної адреси.
Порівняймо з struct:
struct MyStruct
{
int i; // зміщення 0, займає байти 0-3
float f; // зміщення 4, займає байти 4-7
// sizeof(MyStruct) = 8 байт
};
union MyUnion
{
int i; // зміщення 0, займає байти 0-3
float f; // зміщення 0, займає байти 0-3 (ті самі!)
// sizeof(MyUnion) = 4 байти
};
У struct кожне поле має власний простір у пам'яті, всі вони існують одночасно і незалежно. У union усі члени накладаються на один і той самий блок — вони існують почергово, в одному буфері.
#include <iostream>
#include <cstdint>
struct SensorStruct
{
float measurement; // байти 0-3
uint32_t statusFlags; // байти 4-7
};
union SensorUnion
{
float measurement; // байти 0-3
uint32_t statusFlags; // байти 0-3 (ті самі!)
};
int main()
{
std::cout << "sizeof(SensorStruct) = " << sizeof(SensorStruct) << "\n"; // 8
std::cout << "sizeof(SensorUnion) = " << sizeof(SensorUnion) << "\n"; // 4
SensorUnion packet;
packet.measurement = 293.15f; // записуємо float
// ЗАРАЗ активний член — measurement
std::cout << "Temperature: " << packet.measurement << " K\n";
packet.statusFlags = 0b00000011; // перезаписуємо ту саму пам'ять як uint32_t
// ЗАРАЗ активний член — statusFlags (measurement "зіпсований")
std::cout << "Status: " << packet.statusFlags << "\n";
return 0;
}
Ключовий момент: рядок 27 записав значення 0b00000011 поверх тих самих 4 байт, де щойно лежав float. Після цього packet.measurement вже не містить 293.15f — він містить ті самі байти, але інтерпретовані як float. Результат буде довільним.
Синтаксис і правила оголошення
Синтаксис union ідентичний struct — відрізняється лише ключове слово:
union ІменьТипу
{
тип1 член1;
тип2 член2;
тип3 член3;
// ...
}; // крапка з комою обов'язкова
Декілька правил, що відрізняють union від struct:
- Розмір дорівнює розміру найбільшого члена (плюс можливий padding для вирівнювання).
- Усі члени мають однакову адресу —
&u.i == &u.f(дляunion { int i; float f; }). - У будь-який момент часу лише один член є «активним» — той, в який останній раз писали.
- За замовчуванням
unionне може містити члени з нетривіальними конструкторами/деструкторами (до C++11 це взагалі заборонено; починаючи з C++11 — можливо, але вимагає явного управління).
Вирівнювання та розмір
#include <iostream>
#include <cstdint>
union AlignTest
{
uint8_t byte; // 1 байт, вирівнювання 1
uint16_t word; // 2 байти, вирівнювання 2
uint32_t dword; // 4 байти, вирівнювання 4
uint64_t qword; // 8 байт, вирівнювання 8
};
union SmallOrBig
{
char c; // 1 байт
double d; // 8 байт
};
int main()
{
// Розмір = розмір найбільшого члена
std::cout << "sizeof(AlignTest) = " << sizeof(AlignTest) << "\n"; // 8
std::cout << "sizeof(SmallOrBig) = " << sizeof(SmallOrBig) << "\n"; // 8
// Всі члени починаються з одної адреси
AlignTest u;
std::cout << "Адреса &u.byte: " << static_cast<void*>(&u.byte) << "\n";
std::cout << "Адреса &u.word: " << static_cast<void*>(&u.word) << "\n";
std::cout << "Адреса &u.dword: " << static_cast<void*>(&u.dword) << "\n";
std::cout << "Адреса &u.qword: " << static_cast<void*>(&u.qword) << "\n";
// Всі чотири адреси однакові!
return 0;
}
Усі чотири різних члени — одна адреса. Це і є суть union.
Пам'ять зсередини: перекриття байтів
Щоб відчути, як union перекриває пам'ять, подивімося на конкретний приклад: запис int і читання його ж байтів через масив uint8_t.
#include <iostream>
#include <cstdint>
#include <iomanip>
union IntBytes
{
uint32_t value; // ціле число — 4 байти
uint8_t bytes[4]; // ті самі 4 байти як масив
};
int main()
{
IntBytes u;
u.value = 0x0A0B0C0D; // hex-значення для наочності
std::cout << std::hex << std::setfill('0');
std::cout << "value = 0x" << std::setw(8) << u.value << "\n";
// Читаємо ті самі байти через масив
// На little-endian машині (x86): молодший байт — bytes[0]
for (int i = 0; i < 4; ++i)
std::cout << "bytes[" << i << "] = 0x" << std::setw(2)
<< static_cast<int>(u.bytes[i]) << "\n";
return 0;
}
IntBytes у пам'яті (4 байти, little-endian)
Одні й ті самі 4 байти доступні через два різних «вікна»: як ціле uint32_t і як масив з чотирьох uint8_t. Ні копіювання, ні перетворення — лише різна інтерпретація одного буфера.
Практичні застосування
Застосування 1: IPv4-адреса — число та байти одночасно
Класичний приклад мережевого програмування: IPv4-адреса являє собою 32-бітне беззнакове ціле число, але часто потрібно отримати доступ до окремих октетів (октет = байт = одна частина адреси виду 192.168.1.1).
#include <iostream>
#include <cstdint>
union IPv4
{
uint32_t address; // вся адреса як 32-бітне число (big-endian)
uint8_t octets[4]; // ті самі 4 байти як окремі октети
};
// Допоміжна функція для виведення адреси у форматі X.X.X.X
void printIPv4(const IPv4& ip)
{
// Порядок октетів залежить від endianness.
// На little-endian (x86) найстарший октет — octets[3].
std::cout << static_cast<int>(ip.octets[3]) << "."
<< static_cast<int>(ip.octets[2]) << "."
<< static_cast<int>(ip.octets[1]) << "."
<< static_cast<int>(ip.octets[0]) << "\n";
}
int main()
{
IPv4 ip;
// Задаємо адресу 192.168.1.100 як 32-бітне число
// 192 = 0xC0, 168 = 0xA8, 1 = 0x01, 100 = 0x64
ip.address = 0xC0A80164; // big-endian представлення
std::cout << "address (hex): 0x" << std::hex << ip.address << "\n";
std::cout << "IPv4 string: ";
printIPv4(ip);
// Задаємо через октети
ip.octets[3] = 10; // 10.
ip.octets[2] = 0; // 0.
ip.octets[1] = 0; // 0.
ip.octets[0] = 1; // 1
std::cout << "New address: ";
printIPv4(ip);
return 0;
}
Застосування 2: Floating-point хак — читання бітів float
Формат IEEE 754 описує, як float зберігається у пам'яті: 1 біт знаку, 8 біт експоненти, 23 біти мантиси. Іноді потрібно отримати прямий доступ до цих бітів — наприклад, для реалізації швидкого зворотного квадратного кореня (знаменитий алгоритм Quake III).
#include <iostream>
#include <cstdint>
#include <iomanip>
#include <bitset>
union FloatBits
{
float f;
uint32_t bits;
};
void printFloatBits(float value)
{
FloatBits fb;
fb.f = value;
// Розкладаємо 32 біти за IEEE 754
uint32_t sign = (fb.bits >> 31) & 0x1;
uint32_t exponent = (fb.bits >> 23) & 0xFF;
uint32_t mantissa = fb.bits & 0x7FFFFF;
std::cout << "float " << value << ":\n";
std::cout << " bits: " << std::bitset<32>(fb.bits) << "\n";
std::cout << " sign: " << sign << "\n";
std::cout << " exponent: " << exponent << " (bias=" << (int(exponent) - 127) << ")\n";
std::cout << " mantissa: " << std::hex << mantissa << std::dec << "\n";
}
int main()
{
printFloatBits(1.0f);
std::cout << "\n";
printFloatBits(-3.14f);
return 0;
}
float, читання через uint32_t — використовував знаменитий швидкий алгоритм Q_rsqrt у Quake III (1999). Зараз для цього рекомендується std::bit_cast<uint32_t>(f) (C++20), але розуміння механіки через union будує правильну ментальну модель.Застосування 3: Анонімні union у структурах
union може бути анонімним — без імені. У такому разі його члени стають доступними безпосередньо через батьківський об'єкт. Це зручно для моделювання варіантних даних у структурах:
#include <iostream>
#include <string>
#include <cstdint>
// Повідомлення системи подій — може нести або ціле число, або дійсне
struct Event
{
enum class Type { Integer, Float };
Type type; // який член активний
union // анонімний union — члени доступні без імені
{
int32_t intValue;
float floatValue;
};
};
void printEvent(const Event& e)
{
switch (e.type)
{
case Event::Type::Integer:
std::cout << "Event(int): " << e.intValue << "\n";
break;
case Event::Type::Float:
std::cout << "Event(float): " << e.floatValue << "\n";
break;
}
}
int main()
{
Event e1;
e1.type = Event::Type::Integer;
e1.intValue = 42; // доступ без імені union — одразу через структуру
Event e2;
e2.type = Event::Type::Float;
e2.floatValue = 3.14f;
printEvent(e1);
printEvent(e2);
return 0;
}
Зверніть: e1.intValue — не e1.data.intValue. Анонімний union «розчиняє» своїх членів у просторі імен батьківської структури. Це дозволяє будувати чисті API без зайвих рівнів іменування.
Undefined Behavior: найнебезпечніша пастка union
Тут починається частина, яку необхідно засвоїти найретельніше.
Правило активного члена
У будь-який момент часу в union активним є лише той член, в який останній раз записали. Читання будь-якого іншого члена — це Undefined Behavior (UB).
#include <iostream>
union Data
{
int i;
float f;
};
int main()
{
Data d;
d.i = 42; // записали int → i є активним членом
// Законно: читаємо активний член
std::cout << d.i << "\n"; // 42
// UNDEFINED BEHAVIOR: читаємо f, але активний член — i
// Компілятор може зробити що завгодно: вивести сміття,
// оптимізувати до нуля або спричинити інший непередбачуваний ефект
std::cout << d.f << "\n"; // UB!
d.f = 1.5f; // записали float → тепер f є активним членом
// Тепер законно читати f, але i вже не активний
std::cout << d.f << "\n"; // 1.5 — OK
std::cout << d.i << "\n"; // UB!
return 0;
}
union є Undefined Behavior у стандарті C++. На практиці це часто «працює» — компілятор просто реінтерпретує байти. Але покладатися на це заборонено: компілятор може оптимізувати код із UB непередбачуваним чином, особливо при увімкнутих -O2/-O3. Те, що «спрацювало» на одному компіляторі, може дати неправильний результат на іншому або після оновлення компілятора.Виняток: «common initial sequence»
C++ дозволяє одне конкретне виключення з правила активного члена. Якщо кілька членів union є структурами із спільною початковою послідовністю (common initial sequence) полів однакових типів, читання цих спільних полів через будь-який із цих членів є законним.
struct TypeA { int kind; double value; };
struct TypeB { int kind; float data; };
union Variant
{
TypeA a;
TypeB b;
};
Variant v;
v.a.kind = 1; // записали через a
// Законно! kind — перше поле обох структур однакового типу (int)
std::cout << v.b.kind; // OK — common initial sequence
// Незаконно: v.b.data читає після common initial sequence
Цей виняток використовується у C-бібліотеках для «tagged union» патернів. У сучасному C++ перевагу надають std::variant.
union із нетривіальними типами (C++11+)
До C++11 union не міг містити члени з нетривіальними конструкторами або деструкторами (наприклад, std::string). C++11 послабив це обмеження: тепер можна, але програміст бере на себе відповідальність за ручне управління конструюванням і деструкцією.
#include <iostream>
#include <string>
#include <new> // для placement new
union StringOrInt
{
std::string s; // нетривіальний тип — має конструктор/деструктор
int i;
// Компілятор видаляє конструктор і деструктор union за замовчуванням
StringOrInt() {} // порожній конструктор — не конструює жодного члена
~StringOrInt() {} // порожній деструктор — не руйнує жодного члена
};
int main()
{
StringOrInt u;
// Ручне конструювання рядка через placement new
new (&u.s) std::string("Hello, union!");
std::cout << u.s << "\n"; // Hello, union!
// Ручне руйнування рядка перед перемиканням на int
u.s.~basic_string();
// Тепер активний член — int
u.i = 99;
std::cout << u.i << "\n"; // 99
return 0;
}
union — це складно і схильно до помилок. Якщо ви пропустите виклик деструктора перед перемиканням члена, стався витік пам'яті. Саме тому для варіантних типів у сучасному C++ існує std::variant.std::variant: безпечна сучасна альтернатива
C++17 додав до стандартної бібліотеки std::variant<T1, T2, ...> — тип, що зберігає рівно одне значення з наперед заданого набору типів. Це те, що програмісти раніше реалізовували вручну через union + тег типу. Тепер бібліотека робить це автоматично й безпечно.
#include <iostream>
#include <variant>
#include <string>
int main()
{
// Variant може зберігати int, float або std::string
std::variant<int, float, std::string> v;
v = 42;
std::cout << "int: " << std::get<int>(v) << "\n";
v = 3.14f;
std::cout << "float: " << std::get<float>(v) << "\n";
v = std::string("Hello, variant!");
std::cout << "string: " << std::get<std::string>(v) << "\n";
// Запит поточного типу
std::cout << "index: " << v.index() << "\n"; // 2 (третій тип у списку)
// Безпечна перевірка типу
if (std::holds_alternative<std::string>(v))
std::cout << "Зараз зберігається std::string\n";
return 0;
}
std::get та виключення при неправильному типі
#include <iostream>
#include <variant>
#include <stdexcept>
int main()
{
std::variant<int, float> v = 42;
// std::get<T> кидає std::bad_variant_access, якщо тип неактивний
try
{
std::cout << std::get<int>(v) << "\n"; // OK: 42
std::cout << std::get<float>(v) << "\n"; // кидає виключення!
}
catch (const std::bad_variant_access& e)
{
std::cout << "Помилка: " << e.what() << "\n";
}
// std::get_if — безпечний варіант, повертає вказівник або nullptr
if (auto* ptr = std::get_if<int>(&v))
std::cout << "int: " << *ptr << "\n";
else
std::cout << "Не int\n";
return 0;
}
std::visit: обробка всіх можливих типів
Найпотужніший інструмент для роботи з std::variant — std::visit, що приймає visitor (об'єкт або лямбда з перевантаженими operator()) і викликає потрібну версію для поточного активного типу:
#include <iostream>
#include <variant>
#include <string>
#include <vector>
// Тип для повідомлень протоколу
using Message = std::variant<int, float, std::string>;
void processMessage(const Message& msg)
{
std::visit([](const auto& value)
{
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, int>)
std::cout << "Ціле: " << value << "\n";
else if constexpr (std::is_same_v<T, float>)
std::cout << "Дійсне: " << value << "\n";
else if constexpr (std::is_same_v<T, std::string>)
std::cout << "Рядок: \"" << value << "\"\n";
}, msg);
}
int main()
{
std::vector<Message> messages = {
42,
3.14f,
std::string("Привіт!"),
100,
2.71828f
};
for (const auto& m : messages)
processMessage(m);
return 0;
}
Порівняльна таблиця: union vs std::variant
| Властивість | union | std::variant |
|---|---|---|
| Зберігання | Один буфер (unsafe) | Один буфер (safe) |
| Відстежування активного типу | ❌ вручну | ✅ автоматично |
| UB при неправильному доступі | ✅ (тихий) | ❌ (виключення) |
Нетривіальні типи (std::string) | ⚠️ ручний ctod/dtor | ✅ автоматично |
| Розмір | max(members) | max(members) + 1–8 байт |
Підтримка std::visit | ❌ | ✅ |
| Читабельність коду | ⚠️ потребує дисципліни | ✅ самодокументований |
| Доступно з | C, C++98+ | C++17+ |
| Доцільно використовувати | Системне програмування, C-сумісність, бітові хаки | Більшість сучасного коду |
Коли все ж варто використовувати union у 2024+
- C-сумісний API: коли потрібна взаємодія з C-бібліотеками або системними структурами, що використовують
union. - Бітові маніпуляції: доступ до байтів
float/double, мережеві пакети, бінарні формати. - Вбудовані системи: де немає C++17 або потрібен мінімальний розмір (відсутність накладних витрат
variant). - Анонімні
unionу структурах: для чистих API, що моделюють варіантні поля з явним тегом.
Практика
Рівень 1 — Базовий
union Data з членами int i, float f, uint8_t bytes[4]. Напишіть функцію printBytes(const Data& d), що виводить усі 4 байти у шістнадцятковому форматі. У main() присвойте i = 255, виведіть байти. Потім присвойте f = 1.0f, виведіть байти. Поясніть у коментарі різницю у значеннях байтів.Оголосіть:
union Flags
{
uint8_t all; // всі 8 прапорців разом
struct
{
uint8_t read : 1;
uint8_t write : 1;
uint8_t execute : 1;
uint8_t reserved: 5;
} bits;
};
Встановіть read = 1 та execute = 1 через поля bits. Виведіть значення all (має бути 5 = 0b00000101). Потім встановіть all = 0b00000110 і виведіть bits.read, bits.write, bits.execute.
Рівень 2 — Мережевий пакет
На основі union IPv4 { uint32_t address; uint8_t octets[4]; } реалізуйте:
IPv4 makeIPv4(uint8_t a, uint8_t b, uint8_t c, uint8_t d)— конструює IPv4 з чотирьох октетів.void printIPv4(const IPv4& ip)— виводить у форматі"A.B.C.D".bool isSameNetwork(const IPv4& a, const IPv4& b, const IPv4& mask)— перевіряє, чи перебувають дві адреси в одній підмережі (побітове AND з маскою).
У main() перевірте: 192.168.1.10 і 192.168.1.20 з маскою 255.255.255.0 — одна мережа; 192.168.1.10 і 10.0.0.1 — різні мережі.
Реалізуйте систему подій гри:
enum class EventType { Move, Attack, Score };
struct GameEvent
{
EventType type;
union
{
struct { int dx; int dy; } move;
struct { int damage; int targetId; } attack;
struct { int points; } score;
};
};
Напишіть:
GameEvent makeMove(int dx, int dy)— створює подію переміщення.GameEvent makeAttack(int damage, int targetId)— атаку.GameEvent makeScore(int points)— нарахування очок.void processEvent(const GameEvent& e)— виводить опис події залежно від типу.
Продемонструйте обробку черги з 5 різних подій.
Рівень 3 — std::variant-протокол
Реалізуйте міні-протокол для текстового чат-сервера на основі std::variant.
Визначте типи повідомлень:
struct ConnectMsg { std::string username; };
struct TextMsg { std::string from; std::string text; };
struct DisconnectMsg { std::string username; std::string reason; };
struct ErrorMsg { int code; std::string description; };
using Packet = std::variant<ConnectMsg, TextMsg, DisconnectMsg, ErrorMsg>;
Реалізуйте:
void handlePacket(const Packet& p)— обробляє пакет черезstd::visit, виводячи відповідне повідомлення для кожного типу.Packet parseInput(const std::string& raw)— простий парсер: якщо рядок починається з"/connect "—ConnectMsg, з"/disconnect "—DisconnectMsg, з"/error "—ErrorMsg, інакше —TextMsg.- У
main()продемонструйте обробку набору пакетів, включаючи помилкові.
Бонус: підрахуйте кількість пакетів кожного типу через std::visit з лічильниками.
Резюме
🔀 Один буфер, кілька поглядів
unionрозміщує всі члени в одній ділянці пам'яті- Розмір = розмір найбільшого члена
- Усі члени мають однакову початкову адресу
- Активний член — той, в який останній раз записали
⚠️ Undefined Behavior
- Читання не-активного члена — UB у стандарті C++
- Виняток: common initial sequence у struct-членах
- На практиці часто «спрацьовує», але компілятор не зобов'язаний
- Нетривіальні типи (
std::string) вимагають ручного ctod/dtor
🔧 Де union корисний
- Мережеві пакети: одночасний доступ до байтів і цілого числа
- Floating-point хаки: читання бітів через
uint32_t - Анонімні
unionу структурах: чистий API для варіантних полів - C-сумісний код, вбудовані системи, бінарні формати
✅ std::variant — сучасний вибір
- Зберігає один із наперед заданих типів безпечно
- Автоматично відстежує активний тип
- Неправильний доступ кидає
std::bad_variant_access std::visitдля обробки всіх можливих типів- Автоматично керує конструюванням/деструкцією
union — конструкцію, що дає пряме управління байтами пам'яті, і std::variant — її безпечний сучасний аналог. Наступний великий блок курсу присвячений об'єктно-орієнтованому програмуванню: класи, конструктори, деструктори та інкапсуляція — все це виростає безпосередньо з концепцій struct і методів, з якими ми вже познайомилися.std::string_view: невласницький погляд на рядок без копіювання
Клас std::string_view (C++17): внутрішня модель {ptr, length}, створення з const char* та std::string, методи read-only доступу, remove_prefix/remove_suffix, відсутність нуль-термінатора. Dangling view — найнебезпечніша пастка. Коли обирати string_view, а коли const std::string&.
Організація коду: файли, препроцесор, простори імен
Одиниця трансляції та роль лінкера, попереднє оголошення і ODR, встановлення g++ на Windows/macOS/Linux, заголовкові файли і пара .h/.cpp, директиви препроцесора (#include, #define, умовна компіляція), header guards і #pragma once, простори імен namespace та оператор ::.