The void* problem

C code passes values of unknown type through void* and a tag integer:

1typedef struct {
2    int type;    // 0=int, 1=float, 2=string
3    void* data;
4} Value;
5
6// Cast based on tag — undefined behaviour if wrong
7float f = *(float*)val.data;

Nothing prevents passing the wrong type. The tag can drift out of sync with the data. The void* needs explicit memory management.

C++ improves this with union + enum, but unions are still manually managed — you can forget to set the discriminant, forget to destroy the active member, or read the wrong member.

std::variant is a type-safe discriminated union. The active type is always tracked. You can’t read the wrong member without throwing an exception.


std::variant — type-safe union

std::variant<T1, T2, ...> holds exactly one of the listed types at a time.

1#include <variant>
2
3using Value = std::variant<int, float, std::string>;
4
5Value v = 42;          // holds int
6v = 3.14f;             // now holds float
7v = std::string("hi"); // now holds string

Accessing the value

std::get<T> — extracts by type, throws std::bad_variant_access if wrong:

1float f = std::get<float>(v);   // OK — v holds float
2int   i = std::get<int>(v);     // throws — v holds float

std::get_if<T> — returns a pointer, nullptr if wrong type:

1if (auto* f = std::get_if<float>(&v)) {
2    process(*f);   // safe — pointer is non-null
3}

std::holds_alternative<T> — check which type is active:

1if (std::holds_alternative<std::string>(v)) {
2    auto& s = std::get<std::string>(v);
3    uart_send(s);
4}

std::visit — pattern matching over all types

std::visit applies a callable to the active alternative, handling every case:

1std::visit([](auto&& val) {
2    using T = std::decay_t<decltype(val)>;
3    if constexpr (std::is_same_v<T, int>)
4        printf("int: %d\n", val);
5    else if constexpr (std::is_same_v<T, float>)
6        printf("float: %.2f\n", val);
7    else if constexpr (std::is_same_v<T, std::string>)
8        printf("string: %s\n", val.c_str());
9}, v);

Or an overloaded visitor using a helper:

 1// Helper to build a visitor from multiple lambdas
 2template<typename... Ts>
 3struct overloaded : Ts... { using Ts::operator()...; };
 4template<typename... Ts>
 5overloaded(Ts...) -> overloaded<Ts...>;
 6
 7std::visit(overloaded{
 8    [](int i)               { printf("int: %d\n", i); },
 9    [](float f)             { printf("float: %.2f\n", f); },
10    [](const std::string& s){ printf("string: %s\n", s.c_str()); },
11}, v);

The compiler enforces that every alternative is handled. Add a new type to the variant — the visit fails to compile until you add a handler for it. This is the C++ version of exhaustive pattern matching.


Variant for command/event dispatch

A common embedded pattern: a queue of heterogeneous events from different sources.

 1struct TemperatureEvent { float celsius; };
 2struct ButtonEvent      { uint8_t id; bool pressed; };
 3struct ErrorEvent       { uint32_t code; };
 4
 5using Event = std::variant<TemperatureEvent, ButtonEvent, ErrorEvent>;
 6
 7// Queue — no void*, no union, no manual tag
 8RingBuffer<Event, 32> eventQueue;
 9
10// Producer (ISR or task)
11eventQueue.push(TemperatureEvent{23.5f});
12eventQueue.push(ButtonEvent{1, true});
13
14// Consumer
15while (auto ev = eventQueue.pop()) {
16    std::visit(overloaded{
17        [](const TemperatureEvent& e) { updateDisplay(e.celsius); },
18        [](const ButtonEvent& e)      { handleButton(e.id, e.pressed); },
19        [](const ErrorEvent& e)       { logError(e.code); },
20    }, *ev);
21}

Compare to the void* equivalent: no casts, no manual free, no tag drift. Adding a new event type causes a compile error everywhere it’s not handled.


std::optional — nullable values without null pointers

std::optional<T> either contains a value or is empty. It’s the correct representation for “this operation might not return a value.”

 1#include <optional>
 2
 3std::optional<float> readSensor() {
 4    if (!sensorReady()) return std::nullopt;  // no value
 5    return readRaw();                          // has value
 6}
 7
 8// Usage
 9auto result = readSensor();
10
11if (result) {
12    process(*result);          // dereference when non-empty
13}
14
15// Or with value_or — provide a default
16float temp = readSensor().value_or(NAN);
17
18// value() throws std::bad_optional_access if empty
19float temp2 = readSensor().value();  // throws if sensor not ready

Replacing sentinel values

 1// Before: sentinel value — -1 means "not found"
 2int findDevice(uint8_t addr) {
 3    for (int i = 0; i < deviceCount; ++i)
 4        if (devices[i].addr == addr) return i;
 5    return -1;   // caller must remember to check
 6}
 7
 8// After: optional — emptiness is explicit in the type
 9std::optional<int> findDevice(uint8_t addr) {
10    for (int i = 0; i < deviceCount; ++i)
11        if (devices[i].addr == addr) return i;
12    return std::nullopt;
13}
14
15auto idx = findDevice(0x3C);
16if (idx) initDevice(devices[*idx]);

No magic numbers, no “is -1 an error or a valid index?” ambiguity.

Replacing output parameters

 1// Before: output parameter + bool return
 2bool parseFrame(const uint8_t* buf, size_t len, Frame* out);
 3
 4// After: optional return
 5std::optional<Frame> parseFrame(std::span<const uint8_t> buf) {
 6    if (buf.size() < FRAME_MIN_LEN) return std::nullopt;
 7    if (checksum(buf) != buf.back()) return std::nullopt;
 8    return Frame::from(buf);
 9}
10
11auto frame = parseFrame(rxBuf);
12if (frame) handle(*frame);

Cleaner call site, no uninitialized output parameter, error case obvious.


std::optional and embedded

std::optional<T> stores T plus a bool flag. For trivially copyable T, it’s exactly sizeof(T) + 1 (with padding to alignment). No heap allocation.

1// Stack-allocated optional — no heap, ISR-safe
2std::optional<SensorSample> latest = readAdc();

This makes optional a natural replacement for sentinel values in embedded code where heap is forbidden.

For variant, size is max(sizeof(T1), sizeof(T2), ...) + the discriminant. Also stack-allocated. On a Cortex-M4, std::variant<float, uint32_t, ErrorCode> is 8 bytes — same as a float + a tag integer you’d use manually.


Error handling with variant

A common pattern: Result<T, E> — either a value or an error. C++23 adds std::expected for exactly this, but you can implement it with variant in C++17:

 1template <typename T, typename E>
 2using Result = std::variant<T, E>;
 3
 4struct ParseError { std::string message; };
 5
 6Result<Frame, ParseError> parse(std::span<const uint8_t> data) {
 7    if (data.size() < 4) return ParseError{"too short"};
 8    if (!validateChecksum(data)) return ParseError{"bad checksum"};
 9    return buildFrame(data);
10}
11
12auto result = parse(rxBuf);
13std::visit(overloaded{
14    [](const Frame& f)       { processFrame(f); },
15    [](const ParseError& e)  { logError(e.message); },
16}, result);

This is safer than exceptions (no stack unwinding overhead, works on MCUs with -fno-exceptions) and more explicit than error codes.


C++23: std::expected

C++23 standardises the result type as std::expected<T, E>:

 1#include <expected>
 2
 3std::expected<Frame, ParseError> parse(std::span<const uint8_t> data) {
 4    if (data.size() < 4) return std::unexpected(ParseError{"too short"});
 5    return buildFrame(data);
 6}
 7
 8auto result = parse(rxBuf);
 9if (result) processFrame(*result);
10else        logError(result.error().message);

Same semantics as the variant version but with a dedicated API — .value(), .error(), .value_or(), and monadic operations .and_then(), .or_else().


Summary

  • std::variant<T1, T2, ...> — type-safe discriminated union; replaces void* + tag and union + enum
  • std::visit — pattern matching over variants; compiler enforces exhaustiveness
  • std::optional<T> — nullable value; replaces sentinel values and output parameters
  • Both are stack-allocated — no heap, safe in embedded and ISR-adjacent code
  • Use variant for heterogeneous event queues, command dispatch, type-safe return types
  • Use optional for “might not have a value” return, error-free “not found” results

What’s next