C++ variant | Type-safe union guide

C++ variant | Type-safe union guide

이 글의 핵심

std::variant is a type-safe union introduced in C++17. It can store a value of one of several types, and keeps track of which type it is currently storing. Unlike C's union, it provides type safety and automatic life cycle management.

Entering

std::variant is a type safe union introduced in C++17. It can store a value of one of several types, and keeps track of which type it is currently storing.

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

int main() {
    // std::variant<int, double, std::string>:
    // A type that can store one of int, double, and string values.
    // When creating the default, initialized to the first type (int) → 0
    std::variant<int, double, std::string> v;
    
    // Value assignment: automatic conversion according to type
    v = 42;           // Store int (automatically destroy old value)
    // std::get<type>: Extract stored value as type
    // std::bad_variant_access exception if types do not match
    std::cout << std::get<int>(v) << std::endl;
    
    v = 3.14;         // Save double (destroy int, create double)
    std::cout << std::get<double>(v) << std::endl;
    
    v = "hello";      // Save string (double destruction, string creation)
    // String literal → std::string automatic conversion
    std::cout << std::get<std::string>(v) << std::endl;
    
    return 0;
}

Why do you need it?:

  • Type safety: Unlike union, it keeps track of the current type.
  • Automatic Lifecycle: Automatic call of destructor
  • Exception Safety: Safe exception handling when value changes.
  • Pattern matching: Handle all types with std::visit

1. variant vs union

Comparison table

Featuresunionstd::variant
Type Safe❌ None✅ Available
Type Tracking❌ Manual✅ Automatic
Non-trivial type❌ Not possible✅ Available
Destructor❌ Manual✅ Automatic
Exception Safe❌ None✅ Available
Copy/Move❌ Manual✅ Automatic

Code comparison

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

// ❌ C union: type unsafe, manual management
union OldUnion {
    int i;
    double d;
    // std::string s;  // Error: Non-trivial type not possible
};

void testUnion() {
    OldUnion u;
    u.i = 42;
    std::cout << u.i << std::endl;  // 42
    
    u.d = 3.14;
    // std::cout << u.i << std::endl;  // UB (don't know what type it is)
    std::cout << u.d << std::endl;  // 3.14
}

// ✅ std::variant: type safe, automatically managed
void testVariant() {
    std::variant<int, double, std::string> v;
    
    v = 42;
    std::cout << std::get<int>(v) << std::endl;  // 42
    
    v = 3.14;  // Automatic destruction of old int, creation of double
    std::cout << std::get<double>(v) << std::endl;  // 3.14
    
    v = "hello";  // Double destruction, string creation
    std::cout << std::get<std::string>(v) << std::endl;  // hello
    
    // Check type
    if (std::holds_alternative<std::string>(v)) {
std::cout << "Current type: string" << std::endl;
    }
}

int main() {
    std::cout << "=== union ===" << std::endl;
    testUnion();
    
    std::cout << "\n=== variant ===" << std::endl;
    testVariant();
    
    return 0;
}

output of power:

=== union ===
42
3.14

=== variant ===
42
3.14
hello
Current type: string

2. Basic use

Creation and allocation

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

int main() {
    // Basic creation (first type)
    std::variant<int, double, std::string> v1;  // int{} = 0
std::cout << "Index: " << v1.index() << std::endl;  // 0
std::cout << "Value: " << std::get<0>(v1) << std::endl;  // 0
    
    // Created by value
    std::variant<int, double, std::string> v2 = 42;
std::cout << "Index: " << v2.index() << std::endl;  // 0
    
    // change value
    v2 = 3.14;
std::cout << "Index: " << v2.index() << std::endl;  // 1
    
    v2 = "hello";
std::cout << "Index: " << v2.index() << std::endl;  // 2
    
    return 0;
}

output of power:

Index: 0
Value: 0
Index: 0
Index: 1
Index: 2

Value access

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> v = 42;
    
    // get: by type
    int x = std::get<int>(v);
    std::cout << "get<int>: " << x << std::endl;
    
    // get: by index
    int y = std::get<0>(v);
    std::cout << "get<0>: " << y << std::endl;
    
    // get_if: returns pointer (safe)
    if (auto* ptr = std::get_if<int>(&v)) {
        std::cout << "get_if<int>: " << *ptr << std::endl;
    }
    
    if (auto* ptr = std::get_if<double>(&v)) {
        std::cout << "get_if<double>: " << *ptr << std::endl;
    } else {
std::cout << "not a double" << std::endl;
    }
    
    // holds_alternative
    if (std::holds_alternative<int>(v)) {
std::cout << "int type" << std::endl;
    }
    
    return 0;
}

output of power:

get<int>: 42
get<0>: 42
get_if<int>: 42
not a double
int type

3. std::visit

Basic visit

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

int main() {
    std::variant<int, double, std::string> v = 42;
    
    // perform appropriate processing depending on the current type of std::visit: variant
    // First argument: visitor function (handles all possible types)
    // Second argument: variant object
    std::visit( {
        // auto&&: universal reference (accepts all types)
        // using T = std::decay_t<decltype(arg)>:
        // Extract the actual type of arg (remove the reference, const)
        using T = std::decay_t<decltype(arg)>;
        
        // if constexpr: compile-time branching
        // Only branches matching the current type are compiled.
        if constexpr (std::is_same_v<T, int>) {
            std::cout << "int: " << arg << std::endl;
        } else if constexpr (std::is_same_v<T, double>) {
            std::cout << "double: " << arg << std::endl;
        } else if constexpr (std::is_same_v<T, std::string>) {
            std::cout << "string: " << arg << std::endl;
        }
    }, v);
    
    // Visit again after changing the value
    v = 3.14;
    std::visit( {
        using T = std::decay_t<decltype(arg)>;
        
        if constexpr (std::is_same_v<T, int>) {
            std::cout << "int: " << arg << std::endl;
        } else if constexpr (std::is_same_v<T, double>) {
            std::cout << "double: " << arg << std::endl;
        } else if constexpr (std::is_same_v<T, std::string>) {
            std::cout << "string: " << arg << std::endl;
        }
    }, v);
    
    return 0;
}

output of power:

int: 42
double: 3.14

Overload pattern

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

// Overload helper: combine multiple lambdas into one function object
// If you provide a different lambda for each type, the compiler will choose the appropriate one.
template<class... Ts>
struct overloaded : Ts... {
    // using Ts::operator()...: Inheriting operator() from all base classes
    // Enables all call operators of each lambda
    using Ts::operator()...;
};

// Deduction guide: Inferring template type from constructor arguments
// overloaded{Lambda1, Lambda2, ...} → overloaded<Lambda1 type, Lambda2 type, ...>
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    std::variant<int, double, std::string> v = "hello";
    
    // overloaded pattern: Provides different lambdas for each type
    // The compiler selects a lambda that matches the type of the current variant.
    // Readable and type safe (enforces all type handling)
    std::visit(overloaded{
         { std::cout << "int: " << x << std::endl; },
         { std::cout << "double: " << x << std::endl; },
         { std::cout << "string: " << x << std::endl; }
    }, v);
    
    v = 42;
    std::visit(overloaded{
         { std::cout << "int: " << x << std::endl; },
         { std::cout << "double: " << x << std::endl; },
         { std::cout << "string: " << x << std::endl; }
    }, v);
    
    return 0;
}

output of power:

string: hello
int: 42

4. Practical example

Example 1: State Machine

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

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

template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

struct Idle {};
struct Running { int progress; };
struct Completed { std::string result; };

using State = std::variant<Idle, Running, Completed>;

class Task {
    State state = Idle{};
    
public:
    void start() {
        state = Running{0};
std::cout << "start operation" << std::endl;
    }
    
    void update(int progress) {
        if (auto* running = std::get_if<Running>(&state)) {
            running->progress = progress;
std::cout << "Progress: " << progress << "%" << std::endl;
            
            if (progress >= 100) {
state = Completed{"Success"};
std::cout << "Operation completed" << std::endl;
            }
        }
    }
    
    void printState() const {
        std::visit(overloaded{
{ std::cout << "Status: Waiting" << std::endl; },
{ std::cout << "Status: In progress (" << r.progress << "%)" << std::endl; },
{ std::cout << "status: completed (" << c.result << ")" << std::endl; }
        }, state);
    }
};

int main() {
    Task task;
    
    task.printState();
    task.start();
    task.printState();
    task.update(50);
    task.printState();
    task.update(100);
    task.printState();
    
    return 0;
}

output of power:

Status: Pending
start work
Status: In Progress (0%)
Progress: 50%
Status: In Progress (50%)
Progress: 100%
job done
Status: Completed (success)

Example 2: Error handling

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

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

template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

template<typename T, typename E>
using Result = std::variant<T, E>;

struct Error {
    std::string message;
};

Result<int, Error> divide(int a, int b) {
    if (b == 0) {
return Error{"Cannot divide by 0"};
    }
    return a / b;
}

Result<int, Error> squareRoot(int x) {
    if (x < 0) {
return Error{"Cannot find square root of negative number"};
    }
    return static_cast<int>(std::sqrt(x));
}

int main() {
    auto result1 = divide(10, 2);
    std::visit(overloaded{
{ std::cout << "Result: " << value << std::endl; },
{ std::cout << "Error: " << err.message << std::endl; }
    }, result1);
    
    auto result2 = divide(10, 0);
    std::visit(overloaded{
{ std::cout << "Result: " << value << std::endl; },
{ std::cout << "Error: " << err.message << std::endl; }
    }, result2);
    
    auto result3 = squareRoot(16);
    std::visit(overloaded{
{ std::cout << "square root: " << value << std::endl; },
{ std::cout << "Error: " << err.message << std::endl; }
    }, result3);
    
    return 0;
}

output of power:

Result: 5
Error: cannot divide by 0
square root: 4

Example 3: Command pattern

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

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

template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

struct CreateCommand {
    std::string name;
};

struct UpdateCommand {
    int id;
    std::string newValue;
};

struct DeleteCommand {
    int id;
};

using Command = std::variant<CreateCommand, UpdateCommand, DeleteCommand>;

class CommandProcessor {
public:
    void execute(const Command& cmd) {
        std::visit(overloaded{
             {
std::cout << "Create: " << c.name << std::endl;
            },
             {
std::cout << "Update: ID=" << c.id << ", value=" << c.newValue << std::endl;
            },
             {
std::cout << "Delete: ID=" << c.id << std::endl;
            }
        }, cmd);
    }
};

int main() {
    CommandProcessor processor;
    
    std::vector<Command> commands = {
        CreateCommand{"user1"},
        UpdateCommand{1, "new_value"},
        DeleteCommand{1}
    };
    
    for (const auto& cmd : commands) {
        processor.execute(cmd);
    }
    
    return 0;
}

output of power:

Created by: user1
Update: ID=1, value=new_value
Delete: ID=1

5. Frequently occurring problems

Problem 1: Invalid type

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> v = 42;
    
    // ❌ Wrong type
    try {
        double d = std::get<double>(v);  // std::bad_variant_access
    } catch (const std::bad_variant_access& e) {
std::cout << "Type mismatch: " << e.what() << std::endl;
    }
    
    // ✅ Access after confirmation
    if (std::holds_alternative<int>(v)) {
        int x = std::get<int>(v);
        std::cout << "int: " << x << std::endl;
    }
    
    // ✅ Use get_if (safe)
    if (auto* ptr = std::get_if<double>(&v)) {
        std::cout << "double: " << *ptr << std::endl;
    } else {
std::cout << "not a double" << std::endl;
    }
    
    return 0;
}

output of power:

Type mismatch: std::bad_variant_access
int: 42
not a double

Issue 2: Base Creation

#include <variant>
#include <iostream>

int main() {
    // Create default with first type
    std::variant<int, double> v1;  // int{} = 0
    std::cout << "v1: " << std::get<int>(v1) << std::endl;  // 0
    
    // explicit initialization
    std::variant<int, double> v2 = 3.14;  // double
    std::cout << "v2: " << std::get<double>(v2) << std::endl;  // 3.14
    
    // in_place_type
    std::variant<int, double> v3(std::in_place_type<double>, 2.71);
    std::cout << "v3: " << std::get<double>(v3) << std::endl;  // 2.71
    
    return 0;
}

output of power:

v1: 0
v2: 3.14
v3: 2.71

Issue 3: References

#include <variant>
#include <iostream>
#include <functional>

int main() {
    int x = 42;
    
    // ❌ Cannot save reference
    // std::variant<int&> v{x};
    
    // ✅ Use reference_wrapper
    std::variant<std::reference_wrapper<int>> v1{std::ref(x)};
    v1.get().get() = 100;
    std::cout << "x: " << x << std::endl;  // 100
    
    // ✅ Use pointers
    std::variant<int*> v2{&x};
    *std::get<int*>(v2) = 200;
    std::cout << "x: " << x << std::endl;  // 200
    
    return 0;
}

output of power:

x: 100
x: 200

6. Practical example: JSON value expression

#include <variant>
#include <vector>
#include <map>
#include <iostream>
#include <string>

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

template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

class JsonValue {
public:
    using Value = std::variant<
        std::nullptr_t,
        bool,
        int64_t,
        double,
        std::string
    >;
    
    Value value_;
    
    JsonValue() : value_(nullptr) {}
    JsonValue(Value v) : value_(std::move(v)) {}
    
    template<typename T>
    bool is() const {
        return std::holds_alternative<T>(value_);
    }
    
    template<typename T>
    const T& as() const {
        return std::get<T>(value_);
    }
    
    void print() const {
        std::visit(overloaded{
             { std::cout << "null"; },
             { std::cout << (b ? "true" : "false"); },
             { std::cout << i; },
             { std::cout << d; },
             { std::cout << '"' << s << '"'; }
        }, value_);
    }
};

int main() {
    std::vector<JsonValue> values = {
        JsonValue(nullptr),
        JsonValue(true),
        JsonValue(int64_t(42)),
        JsonValue(3.14),
        JsonValue(std::string("hello"))
    };
    
std::cout << "JSON values: [";
    for (size_t i = 0; i < values.size(); ++i) {
        if (i > 0) std::cout << ", ";
        values[i].print();
    }
    std::cout << "]" << std::endl;
    
    // Check type
    if (values[2].is<int64_t>()) {
std::cout << "values[2] is int64_t: " << values[2].as<int64_t>() << std::endl;
    }
    
    return 0;
}

output of power:

JSON values: [null, true, 42, 3.14, "hello"]
values[2] is int64_t: 42

organize

Key takeaways

  1. variant: type safe union
  2. Type tracking: Automatically tracking the current type
  3. std::visit: Handles all types
  4. Overload pattern: Lambda by type
  5. Practical: State machines, error handling, instruction patterns

variant vs union

Featuresunionstd::variant
Type Safe
Type Tracking
Non-trivial type
DestructorManualautomatic
Copy/MoveManualautomatic

Practical tips

Principle of use:

  • One of several types
  • Requires type safety
  • state machine
  • Error handling

Performance:

  • Stack Allocation
  • Size: largest type + index
  • Check runtime type
  • visit overhead (small)

caution:

  • Type confirmation required
  • Cannot save reference
  • Duplicate types are not allowed
  • First type default

Next steps

  • C++ optional
  • C++ any
  • C++ Union

Good article to read together (internal link)

Here’s another article related to this topic.

  • C++ Union and Variant | “Type Safe Unions” Guide
  • C++ optional | “Optional Values” Guide
  • C++ any | “Type erasure” guide

Practical tips

These are tips that can be applied right away in practice.

Debugging tips

  • If you run into a problem, check the compiler warnings first.
  • Reproduce the problem with a simple test case

Performance Tips

  • Don’t optimize without profiling
  • Set measurable indicators first

Code review tips

  • Check in advance for areas that are frequently pointed out in code reviews.
  • Follow your team’s coding conventions

Practical checklist

This is what you need to check when applying this concept in practice.

Before writing code

  • Is this technique the best way to solve the current problem?
  • Can team members understand and maintain this code?
  • Does it meet the performance requirements?

Writing code

  • Have you resolved all compiler warnings?
  • Have you considered edge cases?
  • Is error handling appropriate?

When reviewing code

  • Is the intent of the code clear?
  • Are there enough test cases?
  • Is it documented?

Use this checklist to reduce mistakes and improve code quality.


Keywords covered in this article (related search terms)

This article will be helpful if you search for C++, variant, union, visit, C++17, etc.


  • C++ std::variant vs union |
  • C++ Union and Variant |
  • C++ Algorithm Set |
  • C++ any |
  • Modern C++ (C++11~C++20) Core Grammar Cheat Sheet | A glance at frequently used items in the workplace