Skip to content

C intro

MarekBykowski edited this page Aug 8, 2026 · 2 revisions

union, std::variant, std::optional — for a C engineer

Companion note to the C++ ramp-up. Covers "one value out of several types" — a place where C habits (union) and modern C++ (variant/optional) diverge sharply. Shown C way → C++ way.

TL;DR

  • A union's members are different types sharing the same memory — only one is valid at a time. So no, they don't have to be the same type.
  • Raw union is untagged: it does not remember which member is active — you track that yourself, and reading the wrong one is undefined behavior.
  • In modern C++ you almost always use std::variant (a tagged, type-safe union) or std::optional (value-or-nothing) instead of a raw union.

1. Raw union — different types, one at a time

union Value {
    int   i;
    float f;
    char  bytes[4];
};

union Value v;
v.i = 42;      // 'i' is the active member now
v.f = 3.14f;   // 'f' is active now — reading v.i here is undefined behavior
  • All members overlap in memory; sizeof(union Value) == size of the largest member.
  • The union does not store which member is live. Writing one and reading another (type punning) is undefined behavior in C++ (with a narrow exception for a common initial sequence in standard-layout structs). C is more lenient in practice but it's still fragile.

The classic C fix: a tagged union (do this by hand in C)

enum Kind { INT, FLOAT, STR };

struct Tagged {
    enum Kind kind;        // the tag: which member is valid
    union {
        int   i;
        float f;
        char *s;
    } as;
};

struct Tagged t = { .kind = INT, .as.i = 42 };
if (t.kind == INT) use(t.as.i);   // you must check the tag yourself

std::variant is exactly this pattern — the tag and the safety are built in.


2. The C++ trap raw union adds

If any member has a non-trivial constructor/destructor (e.g. std::string, std::vector), the compiler can't know which one to destroy. You must manage the lifetime by hand with placement new and an explicit destructor call:

union U {
    int i;
    std::string s;      // non-trivial: has ctor/dtor
    U() {}              // must be user-provided
    ~U() {}             // must be user-provided — but which member to destroy??
};

U u;
new (&u.s) std::string("hi");   // placement new to construct
u.s.~basic_string();            // explicit destructor before reuse

This is error-prone and rarely worth it. Which is why you reach for the standard types below instead.


3. std::variant — a tagged, type-safe union

#include <variant>

std::variant<int, float, std::string> v;   // holds exactly one of these
v = 42;                                     // now holds int
v = std::string("hello");                   // now holds string; lifetime handled

// Safe access #1: get_if returns a pointer, or nullptr if wrong type
if (auto p = std::get_if<int>(&v)) {
    use(*p);
}

// Safe access #2: index() tells you which alternative is active (0-based)
if (v.index() == 2) { /* it's the std::string */ }

// Safe access #3: visit dispatches on the active type
std::visit([](auto &&x){ handle(x); }, v);

// get<> throws std::bad_variant_access if you ask for the wrong type
std::string s = std::get<std::string>(v);
  • Remembers the active type (that's the "tagged" part) and constructs/destroys members correctly — no manual lifetime code.
  • std::visit is the idiomatic way to act on "whatever is currently inside".

4. std::optional — value or nothing (the common special case)

Very often you don't need N types — you need "a T, or nothing". In C that's the -1 / NULL / out-param sentinel pattern. In C++:

#include <optional>

std::optional<Config> loadConfig();   // returns a Config, or nothing

auto cfg = loadConfig();
if (cfg) {              // has a value?
    use(*cfg);          // dereference to get it
}
use(cfg.value_or(Config{}));   // value, or a fallback

No sentinel value to reserve, no separate "success" flag, no out-parameter.


5. Which one to use

Need Use C analogue
Same memory, different types, one at a time, full manual control union union
One of several known types, safely, lifetime handled std::variant<A,B,C> hand-written tagged union
A value or nothing std::optional<T> return -1/NULL, out-param
Any type at all (rare, has overhead) std::any void* + manual tag

depthai note: in a modern C++14/17 codebase like depthai-core you'll see std::optional and std::variant far more than raw union. Raw union still shows up in low-level/interop/serialization code — exactly the seam where your kernel/BSP background meets host-side C++.


6. Gotchas checklist

  • Raw union does not track the active member — you do (use a tag, or prefer variant).
  • Reading a different member than the one written is UB in C++ (type punning). For byte-level reinterpretation use std::memcpy or (C++20) std::bit_cast, not a union.
  • A union with a non-trivial member needs hand-written lifetime management.
  • std::get<T> / std::get<I> throw on the wrong type; std::get_if returns nullptr instead — prefer get_if in hot paths.
  • std::optional<T> is not a pointer — *opt dereferences the value, but there's no null; check with if (opt) first.

Clone this wiki locally