C++

Об'єднання (union): один блок пам'яті, кілька інтерпретацій

Що таке union у C++, як він відрізняється від struct, чому всі члени поділяють один буфер пам'яті, де union застосовується на практиці (мережеві пакети, floating-point хаки, варіантні типи), що таке Undefined Behavior при зчитуванні не-активного члена, та чому std::variant є безпечною сучасною альтернативою.

Об'єднання (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 усі члени накладаються на один і той самий блок — вони існують почергово, в одному буфері.

UnionVsStruct.cpp
#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;
}
./UnionVsStruct
$ ./UnionVsStruct
sizeof(SensorStruct) = 8
sizeof(SensorUnion) = 4
Temperature: 293.15 K
Status: 3
Execution finished with exit code 0.

Ключовий момент: рядок 27 записав значення 0b00000011 поверх тих самих 4 байт, де щойно лежав float. Після цього packet.measurement вже не містить 293.15f — він містить ті самі байти, але інтерпретовані як float. Результат буде довільним.


Синтаксис і правила оголошення

Синтаксис union ідентичний struct — відрізняється лише ключове слово:

union ІменьТипу
{
    тип1 член1;
    тип2 член2;
    тип3 член3;
    // ...
};  // крапка з комою обов'язкова

Декілька правил, що відрізняють union від struct:

  1. Розмір дорівнює розміру найбільшого члена (плюс можливий padding для вирівнювання).
  2. Усі члени мають однакову адресу&u.i == &u.f (для union { int i; float f; }).
  3. У будь-який момент часу лише один член є «активним» — той, в який останній раз писали.
  4. За замовчуванням union не може містити члени з нетривіальними конструкторами/деструкторами (до C++11 це взагалі заборонено; починаючи з C++11 — можливо, але вимагає явного управління).

Вирівнювання та розмір

UnionSize.cpp
#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;
}
./UnionSize
$ ./UnionSize
sizeof(AlignTest) = 8
sizeof(SmallOrBig) = 8
Адреса &u.byte: 0x7ffee4b2a5e0
Адреса &u.word: 0x7ffee4b2a5e0
Адреса &u.dword: 0x7ffee4b2a5e0
Адреса &u.qword: 0x7ffee4b2a5e0
Execution finished with exit code 0.

Усі чотири різних члени — одна адреса. Це і є суть union.


Пам'ять зсередини: перекриття байтів

Щоб відчути, як union перекриває пам'ять, подивімося на конкретний приклад: запис int і читання його ж байтів через масив uint8_t.

ByteView.cpp
#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;
}
./ByteView (little-endian)
$ ./ByteView
value = 0x0a0b0c0d
bytes[0] = 0x0d ← молодший байт (little-endian)
bytes[1] = 0x0c
bytes[2] = 0x0b
bytes[3] = 0x0a ← старший байт
Execution finished with exit code 0.
IntBytes у пам'яті (4 байти, little-endian)
Memory Layout / Struct Map
+00Dbytes[0]
+10Cbytes[1]
+20Bbytes[2]
+30Abytes[3]
Offset: 0 bytes
Big Endian

Одні й ті самі 4 байти доступні через два різних «вікна»: як ціле uint32_t і як масив з чотирьох uint8_t. Ні копіювання, ні перетворення — лише різна інтерпретація одного буфера.


Практичні застосування

Застосування 1: IPv4-адреса — число та байти одночасно

Класичний приклад мережевого програмування: IPv4-адреса являє собою 32-бітне беззнакове ціле число, але часто потрібно отримати доступ до окремих октетів (октет = байт = одна частина адреси виду 192.168.1.1).

IPv4Union.cpp
#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;
}
./IPv4Union
$ ./IPv4Union
address (hex): 0xc0a80164
IPv4 string: 192.168.1.100
New address: 10.0.0.1
Execution finished with exit code 0.

Застосування 2: Floating-point хак — читання бітів float

Формат IEEE 754 описує, як float зберігається у пам'яті: 1 біт знаку, 8 біт експоненти, 23 біти мантиси. Іноді потрібно отримати прямий доступ до цих бітів — наприклад, для реалізації швидкого зворотного квадратного кореня (знаменитий алгоритм Quake III).

FloatBits.cpp
#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;
}
./FloatBits
$ ./FloatBits
float 1:
bits: 00111111100000000000000000000000
sign: 0
exponent: 127 (bias=0)
mantissa: 0
 
float -3.14:
bits: 11000000010010001111010111000011
sign: 1
exponent: 128 (bias=1)
mantissa: 48f5c3
Execution finished with exit code 0.
Саме цю техніку — запис в float, читання через uint32_t — використовував знаменитий швидкий алгоритм Q_rsqrt у Quake III (1999). Зараз для цього рекомендується std::bit_cast<uint32_t>(f) (C++20), але розуміння механіки через union будує правильну ментальну модель.

Застосування 3: Анонімні union у структурах

union може бути анонімним — без імені. У такому разі його члени стають доступними безпосередньо через батьківський об'єкт. Це зручно для моделювання варіантних даних у структурах:

AnonymousUnion.cpp
#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;
}
./AnonymousUnion
$ ./AnonymousUnion
Event(int): 42
Event(float): 3.14
Execution finished with exit code 0.

Зверніть: e1.intValue — не e1.data.intValue. Анонімний union «розчиняє» своїх членів у просторі імен батьківської структури. Це дозволяє будувати чисті API без зайвих рівнів іменування.


Undefined Behavior: найнебезпечніша пастка union

Тут починається частина, яку необхідно засвоїти найретельніше.

Правило активного члена

У будь-який момент часу в union активним є лише той член, в який останній раз записали. Читання будь-якого іншого члена — це Undefined Behavior (UB).

UBExample.cpp
#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 послабив це обмеження: тепер можна, але програміст бере на себе відповідальність за ручне управління конструюванням і деструкцією.

UnionWithString.cpp
#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 + тег типу. Тепер бібліотека робить це автоматично й безпечно.

VariantBasic.cpp
#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;
}
./VariantBasic
$ ./VariantBasic
int: 42
float: 3.14
string: Hello, variant!
index: 2
Зараз зберігається std::string
Execution finished with exit code 0.

std::get та виключення при неправильному типі

VariantGet.cpp
#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;
}
./VariantGet
$ ./VariantGet
42
Помилка: bad variant access
int: 42
Execution finished with exit code 0.

std::visit: обробка всіх можливих типів

Найпотужніший інструмент для роботи з std::variantstd::visit, що приймає visitor (об'єкт або лямбда з перевантаженими operator()) і викликає потрібну версію для поточного активного типу:

VariantVisit.cpp
#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;
}
./VariantVisit
$ ./VariantVisit
Ціле: 42
Дійсне: 3.14
Рядок: "Привіт!"
Ціле: 100
Дійсне: 2.71828
Execution finished with exit code 0.

Порівняльна таблиця: union vs std::variant

Властивістьunionstd::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 — Базовий

Рівень 2 — Мережевий пакет

Рівень 3 — std::variant-протокол


Резюме

🔀 Один буфер, кілька поглядів

  • 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 і методів, з якими ми вже познайомилися.
Copyright © 2026