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; replacesvoid*+ tag andunion+ enumstd::visit— pattern matching over variants; compiler enforces exhaustivenessstd::optional<T>— nullable value; replaces sentinel values and output parameters- Both are stack-allocated — no heap, safe in embedded and ISR-adjacent code
- Use
variantfor heterogeneous event queues, command dispatch, type-safe return types - Use
optionalfor “might not have a value” return, error-free “not found” results
What’s next
- Container selection guide — choosing the right container for your data
- Smart pointers and ownership — ownership-safe heap allocation
- Lock-free queues — passing variants between threads without allocation