Skip to Content
Course content

77: std::variant for Type-Safe Unions

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was auditing a legacy codebase for a physics engine. I found a section where the original author had used a C-style union to store either a Vector3 or a Quaternion to save a few bytes of memory per object. The problem was that they relied on a separate enum to track which type was currently active. During a late-night refactor, someone changed the way the objects were initialized, and a specific edge case started leaving that enum unset. The program didn't crash immediately; it just started treating quaternions as vectors. The resulting "jitter" in the simulation was nearly impossible to debug because the memory looked "fine," but the logic was fundamentally broken.

This is exactly why std::variant was introduced in C++17. It gives us the memory efficiency of a union, but it's "type-safe." That means the variant actually knows which type it's holding, and it won't let you accidentally treat a string as an integer without throwing an exception or returning a null pointer. I call it a "discriminated union," and it's a lifesaver when you're dealing with data that can be one of several distinct types.

Moving Beyond the Danger of C-Style Unions

When you define a std::variant<int, std::string, double>, the compiler allocates enough space for the largest of those types, plus a small amount of overhead to keep track of the "index" (which type is currently active). Unlike a raw union, std::variant properly calls constructors and destructors. If your variant holds a std::string and you assign an int to it, the variant handles the cleanup of the string automatically. You don't have to manually track the active member with a side-car enum.

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

struct Click { int x, y; };
struct KeyPress { int key_code; };
struct Resize { int width, height; };

// A UIEvent can be any one of these three types
using UIEvent = std::variant<Click, KeyPress, Resize>;

void processEvent(const UIEvent& event) {
    // We can check the index or use std::holds_alternative
    if (std::holds_alternative<Click>(event)) {
        auto& click = std::get<Click>(event);
        std::cout << "Clicked at " << click.x << "," << click.y << "\n";
    }
}

Now, std::get is powerful, but it's risky. If you call std::get<Click> on a variant that currently holds a Resize, it throws a std::bad_variant_access exception. If you're not in a position to use try-catch blocks, you can use std::get_if, which returns a pointer to the value if the type matches, or nullptr if it doesn't. I personally prefer get_if for performance-critical paths where exceptions are too heavy.

Dispatching Logic with std::visit

While if (std::holds_alternative...) works, it gets incredibly clunky once you have five or six possible types. You end up with a giant if-else chain that's a pain to maintain. The "pro" way to handle variants is through std::visit. This allows you to pass a "visitor" (usually a function object or a set of lambdas) that defines how to handle every possible type in the variant.

The most elegant pattern here is the "overload" helper. It's a tiny bit of template magic that lets you pass multiple lambdas to std::visit. It looks like this:

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts... ts) <!-- deduction guide --> { return {ts...}; }

void handleEvent(const UIEvent& event) {
    std::visit(overloaded {
        [](const Click& c) { std::cout << "Click: " << c.x << "\n"; },
        [](const KeyPress& k) { std::cout << "Key: " << k.key_code << "\n"; },
        [](const Resize& r) { std::cout << "Resize: " << r.width << "\n"; }
    }, event);
}

I love this approach because the compiler enforces completeness. If you add a ScrollEvent to your UIEvent variant but forget to add a corresponding lambda in the std::visit call, the code simply won't compile. This turns a potential runtime crash into a compile-time fix, which is exactly where we want our bugs to be caught.




📋 Practical Task

Implementing a Type-Safe Command Parser

You are building a command system for a text-adventure game. The game needs to handle different types of commands: Move (which takes a direction string), Take (which takes an item name string), and Wait (which takes no arguments).

Your task is to:

  • Define three structs: Move, Take, and Wait.
  • Create a std::variant called GameCommand that can hold any of these three types.
  • Implement a function executeCommand(const GameCommand& cmd).
  • Inside executeCommand, use std::visit and the overloaded pattern to print a specific message for each command (e.g., "Moving to the North", "Picking up the Golden Key", or "Waiting patiently...").
  • In main, create a std::vector<GameCommand> containing at least one of each command type and loop through the vector, calling executeCommand for each.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.