Unveiling std::variant: The Modern Type-Safe Union

std::variant has been part of C++17 and is a powerful tool that brings the flexibility of a union with the safety guarantees of a type‑safe discriminated union. It allows a single variable to hold one of several specified types, while guaranteeing that only one is active at a time and that you cannot inadvertently read the wrong type. In this article we’ll explore the practical use‑cases, common pitfalls, and advanced tricks that make std::variant a go‑to component in modern C++ codebases.

Why replace std::variant with a union?

  • Safetystd::variant maintains a discriminant internally. If you try to access the wrong type, it throws an exception (std::bad_variant_access). A raw union, on the other hand, will simply produce garbage or invoke undefined behaviour.
  • Constructors & Destructors – It correctly constructs and destructs the active member, calling the right constructors, destructors, and copy/move operations.
  • Value semanticsstd::variant behaves like a regular value type: copyable, movable, assignable, and comparable (if all alternatives are comparable).
  • Type introspection – You can query the type held by a variant at compile time (std::variant_alternative_t) or at runtime (std::holds_alternative / std::get_if).

Basic usage

#include <variant>
#include <iostream>
#include <string>

using Value = std::variant<int, double, std::string>;

int main() {
    Value v = 42;            // holds an int
    std::visit([](auto&& x) { std::cout << x << '\n'; }, v);

    v = 3.14;                // now holds a double
    std::visit([](auto&& x) { std::cout << x << '\n'; }, v);

    v = std::string{"hello"}; // holds a string
    std::visit([](auto&& x) { std::cout << x << '\n'; }, v);
}

The std::visit function dispatches a visitor to the active alternative. The visitor can be a lambda or a functor; the compiler deduces the type for each alternative.

Visiting with overloaded lambdas

A common pattern is to use a helper overloaded struct to combine multiple lambdas:

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

Value v = std::string{"example"};

std::visit(overloaded{
    [](int i) { std::cout << "int: " << i; },
    [](double d) { std::cout << "double: " << d; },
    [](const std::string& s) { std::cout << "string: " << s; }
}, v);

This eliminates the need for manual if constexpr chains and keeps the visitor succinct.

Checking the active type

You can test which type the variant currently holds:

if (std::holds_alternative <int>(v)) {
    std::cout << "int: " << std::get<int>(v) << '\n';
} else if (std::holds_alternative <double>(v)) {
    std::cout << "double: " << std::get<double>(v) << '\n';
} else {
    std::cout << "string: " << std::get<std::string>(v) << '\n';
}

Alternatively, you can retrieve the index with v.index() where the index corresponds to the order of types in the template parameter pack.

Common pitfalls

  1. Copying from an empty variant
    A default‑constructed std::variant holds the first alternative by default. Trying to access it before setting a value may give you an unexpected default. Use std::variant::valueless_by_exception() to detect if an exception during assignment left the variant in a valueless state.

  2. Returning a std::variant from a function
    Ensure that all alternative types are either copy‑constructible or move‑constructible, because the returned variant will be moved or copied by the caller.

  3. Exception safety
    If an exception is thrown while constructing the new alternative, the variant remains in the previous state. If constructing the new alternative itself throws, the variant becomes valueless_by_exception. Handling this scenario gracefully is key for robust code.

Advanced techniques

1. Type‑safe arithmetic with std::variant

Value add(const Value& a, const Value& b) {
    return std::visit([](auto&& x, auto&& y) {
        using T1 = std::decay_t<decltype(x)>;
        using T2 = std::decay_t<decltype(y)>;
        if constexpr (std::is_arithmetic_v <T1> && std::is_arithmetic_v<T2>) {
            return Value(x + y); // implicit promotion rules apply
        } else {
            throw std::logic_error("Unsupported types for addition");
        }
    }, a, b);
}

2. std::variant as a small object for visitor pattern

In event‑driven systems, std::variant can replace the classic visitor pattern:

struct MouseEvent { /* ... */ };
struct KeyboardEvent { /* ... */ };
struct ResizeEvent { /* ... */ };

using Event = std::variant<MouseEvent, KeyboardEvent, ResizeEvent>;

void dispatch(Event e) {
    std::visit(overloaded{
        [](MouseEvent const& m) { handleMouse(m); },
        [](KeyboardEvent const& k) { handleKeyboard(k); },
        [](ResizeEvent const& r) { handleResize(r); }
    }, e);
}

This removes the need for virtual inheritance and keeps all event types in a single type‑safe container.

3. Combining with std::optional

If you need a “nullable variant” you can wrap it in std::optional:

std::optional <Value> maybeVal = std::nullopt; // empty

// Later assign a value
maybeVal = 5;

if (maybeVal) {
    std::visit(/* visitor */, *maybeVal);
}

Alternatively, use std::variant<std::monostate, int, double, std::string> to encode an empty state inside the variant itself.

Performance considerations

  • Size – The size of a std::variant is the maximum size of its alternatives plus the size of the discriminant. For small types (e.g., primitives) this overhead is negligible.
  • Alignmentstd::variant guarantees proper alignment for all alternatives.
  • Copy/move costs – If you have a variant with expensive alternatives, each copy/move may copy the currently active alternative. Be mindful of copy elision and move semantics.
  • Branching – The visitor dispatch incurs a virtual‑like dispatch at runtime. For high‑performance code, you might want to keep the alternative set small or unroll the visitor manually.

Real‑world example: JSON values

A lightweight JSON representation often uses a variant:

struct Json; // forward declaration

using JsonValue = std::variant<
    std::nullptr_t,
    bool,
    int64_t,
    double,
    std::string,
    std::vector <Json>,
    std::map<std::string, Json>
>;

struct Json {
    JsonValue value;
};

You can then write parsers and serializers that operate on Json without resorting to dynamic casts or a hand‑rolled type system.

Conclusion

std::variant is a versatile and type‑safe alternative to union, std::any, or dynamic polymorphism. It gives you compile‑time guarantees, clean syntax, and robust runtime behaviour. By mastering visitors, type checks, and the nuances of exception safety, you can use std::variant to build safer, more maintainable C++ codebases.

Happy coding, and may your variants always hold the correct type!

发表评论