C++ std::variant vs union Complete Comparison
이 글의 핵심
Master C++ sum types: std::variant (type-safe, std::visit, exceptions) vs union (unsafe, manual tracking). Complete comparison with use cases, performance, and when to choose each.
Overview
Both std::variant and union store one of several types (sum types), but with different safety guarantees.
// std::variant (C++17, type-safe)
// 실행 예제
std::variant<int, double, std::string> value = 42;
int x = std::get<int>(value); // Runtime type check
// union (C, unsafe)
union Data {
int i;
double d;
char c;
};
Data data;
data.i = 42;
double d = data.d; // Wrong type! UB
Key difference: std::variant tracks the active type and checks it at runtime; union doesn’t track type, requiring manual management.
std::variant
Basic Usage
#include <variant>
#include <iostream>
int main() {
std::variant<int, double, std::string> value;
// Store int
value = 42;
cout << std::get<int>(value) << endl; // 42
// Store string
value = std::string("hello");
cout << std::get<std::string>(value) << endl; // hello
// Store double
value = 3.14;
cout << std::get<double>(value) << endl; // 3.14
}
Output:
42
hello
3.14
Type Checking
std::variant<int, double, std::string> value = 42;
// Check active index
cout << value.index() << endl; // 0 (int is first type)
// holds_alternative
if (std::holds_alternative<int>(value)) {
cout << "It's an int" << endl;
}
// Wrong type access → exception
try {
auto s = std::get<std::string>(value); // Throws!
} catch (const std::bad_variant_access& e) {
cerr << "Bad access: " << e.what() << endl;
}
Output:
It's an int
Bad access: std::get: wrong index for variant
Safe Access with get_if
std::variant<int, double, std::string> value = 42;
// Pointer access (returns nullptr on wrong type)
if (int* ptr = std::get_if<int>(&value)) {
cout << "int: " << *ptr << endl;
}
if (std::string* ptr = std::get_if<std::string>(&value)) {
cout << "string: " << *ptr << endl;
} else {
cout << "Not a string" << endl;
}
Output:
int: 42
Not a string
std::visit (Exhaustive Handling)
std::variant<int, double, std::string> value = 3.14;
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
cout << "int: " << arg << endl;
} else if constexpr (std::is_same_v<T, double>) {
cout << "double: " << arg << endl;
} else if constexpr (std::is_same_v<T, std::string>) {
cout << "string: " << arg << endl;
}
}, value);
Output:
double: 3.14
Key: std::visit ensures all types are handled at compile time.
union
Basic Usage
union Data {
int i;
double d;
char c;
};
int main() {
Data data;
data.i = 42;
cout << data.i << endl; // 42
data.d = 3.14;
cout << data.d << endl; // 3.14
// ❌ data.i is now garbage (d overwrote it)
cout << data.i << endl; // Garbage
}
Output:
42
3.14
-1717986918 // Garbage (UB)
Tagged Union (Manual Type Tracking)
enum class DataType { INT, DOUBLE, STRING };
struct TaggedData {
DataType type;
union {
int i;
double d;
char str[32];
} value;
};
int main() {
TaggedData data;
// Store int
data.type = DataType::INT;
data.value.i = 42;
// Access with type check
if (data.type == DataType::INT) {
cout << data.value.i << endl; // 42
}
// Store string
data.type = DataType::STRING;
strcpy(data.value.str, "hello");
if (data.type == DataType::STRING) {
cout << data.value.str << endl; // hello
}
}
Output:
42
hello
Key: Manual type tracking is error-prone—easy to forget to update type field.
union Limitations
// ❌ union cannot hold non-trivial types
union Bad {
int i;
std::string s; // Error! std::string has constructor/destructor
};
// ✅ std::variant can hold any type
std::variant<int, std::string> good = std::string("hello");
Comparison
Feature Comparison
| Feature | std::variant | union |
|---|---|---|
| Type safety | ✅ Automatic tracking | ❌ Manual tracking |
| Exception on wrong access | ✅ Yes | ❌ No (UB) |
| Non-trivial types | ✅ Yes (string, vector) | ❌ No |
| std::visit | ✅ Yes | ❌ No |
| Memory overhead | 1 byte (type index) | 0 bytes |
| C compatibility | ❌ No | ✅ Yes |
| Constructors/Destructors | ✅ Called | ❌ Not called |
Safety Comparison
// std::variant: Safe
std::variant<int, double> v = 42;
try {
auto d = std::get<double>(v); // Throws bad_variant_access
} catch (const std::bad_variant_access&) {
cout << "Wrong type" << endl;
}
// union: Unsafe
union U { int i; double d; };
U u;
u.i = 42;
double d = u.d; // UB! Reading inactive member
Memory Layout
// std::variant
std::variant<int, double> v; // sizeof: 16 bytes (8 for double + 8 for alignment/index)
// union
union U { int i; double d; }; // sizeof: 8 bytes (max of members)
Key: std::variant has small overhead (1 byte + alignment) for type index.
When to Use Each
Use std::variant When:
- ✅ Type safety is important
- ✅ Storing non-trivial types (string, vector)
- ✅ Building modern C++ APIs
- ✅ Need std::visit for exhaustive handling
// Result type
std::variant<int, std::string> parseValue(const std::string& input) {
if (isNumber(input)) {
return std::stoi(input);
}
return input;
}
Use union When:
- ✅ C API interop
- ✅ Legacy code maintenance
- ✅ Extreme memory constraints
- ✅ Only trivial types (int, float, char)
// C API
struct Packet {
enum { INT, FLOAT } type;
union {
int i;
float f;
} data;
};
Common Mistakes
Mistake 1: Reading Inactive union Member
// ❌ Undefined behavior
union U { int i; double d; };
U u;
u.i = 42;
cout << u.d << endl; // UB! Reading inactive member
Fix: Use std::variant or track active type manually.
Mistake 2: Forgetting to Update Type Tag
// ❌ Type tag out of sync
struct TaggedData {
enum { INT, DOUBLE } type;
union { int i; double d; } value;
};
TaggedData data;
data.type = INT;
data.value.d = 3.14; // Forgot to update type!
if (data.type == INT) {
cout << data.value.i << endl; // Garbage!
}
Fix: Use std::variant to avoid manual tracking.
Mistake 3: Wrong std::get Index
// ❌ Wrong index
std::variant<int, double, std::string> v = 3.14;
auto x = std::get<0>(v); // Throws! (0 is int, but v holds double)
// ✅ Correct index or type
auto x = std::get<1>(v); // OK (1 is double)
auto y = std::get<double>(v); // OK (type-based)
Practical Examples
Example 1: Result Type
template<typename T, typename E>
using Result = std::variant<T, E>;
Result<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::string("Division by zero");
}
return a / b;
}
int main() {
auto result = divide(10, 2);
std::visit([](auto&& value) {
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, int>) {
cout << "Success: " << value << endl;
} else {
cout << "Error: " << value << endl;
}
}, result);
}
Output:
Success: 5
Example 2: State Machine
struct Idle {};
struct Running { int progress; };
struct Completed { std::string result; };
using State = std::variant<Idle, Running, Completed>;
void processState(const State& state) {
std::visit([](auto&& s) {
using T = std::decay_t<decltype(s)>;
if constexpr (std::is_same_v<T, Idle>) {
cout << "Idle" << endl;
} else if constexpr (std::is_same_v<T, Running>) {
cout << "Running: " << s.progress << "%" << endl;
} else if constexpr (std::is_same_v<T, Completed>) {
cout << "Completed: " << s.result << endl;
}
}, state);
}
int main() {
State s1 = Idle{};
State s2 = Running{50};
State s3 = Completed{"Done"};
processState(s1);
processState(s2);
processState(s3);
}
Output:
Idle
Running: 50%
Completed: Done
Example 3: JSON Value
struct JsonNull {};
using JsonValue = std::variant<
JsonNull,
bool,
int,
double,
std::string,
std::vector<JsonValue>,
std::map<std::string, JsonValue>
>;
void printJson(const JsonValue& value) {
std::visit([](auto&& v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, JsonNull>) {
cout << "null";
} else if constexpr (std::is_same_v<T, bool>) {
cout << (v ? "true" : "false");
} else if constexpr (std::is_same_v<T, int>) {
cout << v;
} else if constexpr (std::is_same_v<T, double>) {
cout << v;
} else if constexpr (std::is_same_v<T, std::string>) {
cout << "\"" << v << "\"";
}
// ... handle array/object ...
}, value);
}
int main() {
JsonValue v1 = 42;
JsonValue v2 = std::string("hello");
JsonValue v3 = true;
printJson(v1); // 42
cout << ", ";
printJson(v2); // "hello"
cout << ", ";
printJson(v3); // true
}
Output:
42, "hello", true
Production Patterns
Pattern 1: Error Handling
template<typename T>
using Result = std::variant<T, std::string>;
Result<int> parseInt(const std::string& str) {
try {
return std::stoi(str);
} catch (...) {
return std::string("Invalid integer");
}
}
int main() {
auto result = parseInt("123");
if (int* value = std::get_if<int>(&result)) {
cout << "Parsed: " << *value << endl;
} else {
cout << "Error: " << std::get<std::string>(result) << endl;
}
}
Output:
Parsed: 123
Pattern 2: Command Pattern
struct CreateCommand { std::string name; };
struct UpdateCommand { int id; std::string data; };
struct DeleteCommand { int id; };
using Command = std::variant<CreateCommand, UpdateCommand, DeleteCommand>;
void executeCommand(const Command& cmd) {
std::visit([](auto&& c) {
using T = std::decay_t<decltype(c)>;
if constexpr (std::is_same_v<T, CreateCommand>) {
cout << "Creating: " << c.name << endl;
} else if constexpr (std::is_same_v<T, UpdateCommand>) {
cout << "Updating: " << c.id << endl;
} else if constexpr (std::is_same_v<T, DeleteCommand>) {
cout << "Deleting: " << c.id << endl;
}
}, cmd);
}
int main() {
executeCommand(CreateCommand{"user"});
executeCommand(UpdateCommand{1, "new_data"});
executeCommand(DeleteCommand{2});
}
Output:
Creating: user
Updating: 1
Deleting: 2
Pattern 3: Network Protocol
struct ConnectPacket { std::string host; int port; };
struct DataPacket { std::vector<uint8_t> payload; };
struct DisconnectPacket { int reason; };
using Packet = std::variant<ConnectPacket, DataPacket, DisconnectPacket>;
void handlePacket(const Packet& packet) {
std::visit([](auto&& p) {
using T = std::decay_t<decltype(p)>;
if constexpr (std::is_same_v<T, ConnectPacket>) {
cout << "Connect to " << p.host << ":" << p.port << endl;
} else if constexpr (std::is_same_v<T, DataPacket>) {
cout << "Data: " << p.payload.size() << " bytes" << endl;
} else if constexpr (std::is_same_v<T, DisconnectPacket>) {
cout << "Disconnect: reason " << p.reason << endl;
}
}, packet);
}
int main() {
handlePacket(ConnectPacket{"localhost", 8080});
handlePacket(DataPacket{{0x01, 0x02, 0x03}});
handlePacket(DisconnectPacket{0});
}
Output:
Connect to localhost:8080
Data: 3 bytes
Disconnect: reason 0
union (Legacy)
Basic Tagged Union
enum class ValueType { INT, DOUBLE, STRING };
struct Value {
ValueType type;
union {
int i;
double d;
char str[32];
} data;
// Helper methods
static Value makeInt(int value) {
Value v;
v.type = ValueType::INT;
v.data.i = value;
return v;
}
static Value makeDouble(double value) {
Value v;
v.type = ValueType::DOUBLE;
v.data.d = value;
return v;
}
void print() const {
switch (type) {
case ValueType::INT:
cout << "int: " << data.i << endl;
break;
case ValueType::DOUBLE:
cout << "double: " << data.d << endl;
break;
case ValueType::STRING:
cout << "string: " << data.str << endl;
break;
}
}
};
int main() {
Value v1 = Value::makeInt(42);
Value v2 = Value::makeDouble(3.14);
v1.print();
v2.print();
}
Output:
int: 42
double: 3.14
Key: Manual type tracking is verbose and error-prone.
Common Issues
Issue 1: union with Non-Trivial Types
// ❌ Error: union cannot hold std::string
union Bad {
int i;
std::string s; // Compile error!
};
// ✅ std::variant can hold any type
std::variant<int, std::string> good = std::string("hello");
Issue 2: Forgetting Type Tag
// ❌ Type tag out of sync
struct TaggedData {
enum { INT, DOUBLE } type;
union { int i; double d; } value;
};
TaggedData data;
data.type = INT;
data.value.d = 3.14; // Forgot to update type!
if (data.type == INT) {
cout << data.value.i << endl; // Garbage!
}
Fix: Use std::variant to eliminate manual tracking.
Issue 3: Missing std::visit Case
// ❌ Forgot to handle string case
std::variant<int, double, std::string> v = std::string("hello");
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
cout << "int: " << arg << endl;
} else if constexpr (std::is_same_v<T, double>) {
cout << "double: " << arg << endl;
}
// Missing string case!
}, v);
Fix: Use exhaustive if constexpr or overloaded visitor.
Performance Comparison
Memory Size
// std::variant
std::variant<int, double> v; // 16 bytes (8 for double + 8 for alignment/index)
// union
union U { int i; double d; }; // 8 bytes (max of members)
Key: std::variant adds 1 byte for type index (plus alignment padding).
Access Speed
// Benchmark: 10,000,000 accesses
// std::variant: 25ms (includes type check)
// union: 20ms (no check)
Key: std::variant is slightly slower due to type checking, but difference is negligible.
Migration from union to std::variant
Before (union)
enum class Type { INT, STRING };
struct Data {
Type type;
union {
int i;
char str[32];
} value;
};
Data data;
data.type = Type::INT;
data.value.i = 42;
if (data.type == Type::INT) {
cout << data.value.i << endl;
}
After (std::variant)
using Data = std::variant<int, std::string>;
Data data = 42;
if (int* ptr = std::get_if<int>(&data)) {
cout << *ptr << endl;
}
// Or use std::visit
std::visit([](auto&& value) {
cout << value << endl;
}, data);
Benefits:
- Type-safe
- No manual type tracking
- Supports non-trivial types
- Exhaustive handling with std::visit
Summary
Key Points
- std::variant: Type-safe sum type with automatic type tracking
- union: Unsafe sum type requiring manual type tracking
- Use std::variant: For modern C++ code
- Use union: For C API interop, legacy code
- std::visit: Exhaustive handling for std::variant
- Performance: union slightly smaller/faster, but std::variant overhead is minimal
Decision Matrix
| Scenario | Recommendation |
|---|---|
| Modern C++ API | std::variant |
| C API interop | union |
| Non-trivial types | std::variant (union can’t hold them) |
| Type safety critical | std::variant |
| Legacy codebase | union (migrate to std::variant) |
| Extreme memory constraints | union (measure first) |
Migration Checklist
- Replace union with std::variant
- Remove manual type tag
- Use std::visit for exhaustive handling
- Use std::get_if for safe access
- Test all code paths
Related Articles
- C++ std::variant Complete Guide
- C++ union and Tagged Union
- C++ Sum Types and Pattern Matching
- C++ std::any vs void* Comparison
Keywords
C++ std::variant, union, sum types, type safety, std::visit, tagged union, type erasure One-line summary: std::variant provides type-safe sum types with automatic tracking and std::visit, while union offers unsafe manual tracking—prefer std::variant for modern C++ code.
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Master C++ sum types: std::variant (type-safe, std::visit, exceptions) vs union (unsafe, manual tracking, legacy).
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
Keywords Covered in This Article (Related Search Terms)
This article covers C++, std::variant, union, sum types, type safety, C++17, std::visit.