C++ variant | "타입 안전 union" 가이드

C++ variant | "타입 안전 union" 가이드

이 글의 핵심

std::variant 는 C++17에서 도입된 타입 안전한 union입니다. 여러 타입 중 하나의 값을 저장할 수 있으며, 현재 어떤 타입을 저장하고 있는지 추적합니다. C의 union과 달리 타입 안전성과 자동 생명주기 관리를 제공합니다.

들어가며

**std::variant**는 C++17에서 도입된 타입 안전한 union입니다. 여러 타입 중 하나의 값을 저장할 수 있으며, 현재 어떤 타입을 저장하고 있는지 추적합니다.

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

int main() {
    // std::variant<int, double, std::string>:
    // int, double, string 중 하나의 값을 저장할 수 있는 타입
    // 기본 생성 시 첫 번째 타입(int)으로 초기화 → 0
    std::variant<int, double, std::string> v;
    
    // 값 할당: 타입에 맞게 자동 변환
    v = 42;           // int 저장 (이전 값 자동 소멸)
    // std::get<타입>: 저장된 값을 타입으로 추출
    // 타입이 맞지 않으면 std::bad_variant_access 예외
    std::cout << std::get<int>(v) << std::endl;
    
    v = 3.14;         // double 저장 (int 소멸, double 생성)
    std::cout << std::get<double>(v) << std::endl;
    
    v = "hello";      // string 저장 (double 소멸, string 생성)
    // 문자열 리터럴 → std::string 자동 변환
    std::cout << std::get<std::string>(v) << std::endl;
    
    return 0;
}

왜 필요한가?:

  • 타입 안전: union과 달리 현재 타입을 추적
  • 자동 생명주기: 소멸자 자동 호출
  • 예외 안전: 값 변경 시 안전한 예외 처리
  • 패턴 매칭: std::visit로 모든 타입 처리

1. variant vs union

비교표

특징unionstd::variant
타입 안전❌ 없음✅ 있음
타입 추적❌ 수동✅ 자동
비trivial 타입❌ 불가✅ 가능
소멸자❌ 수동✅ 자동
예외 안전❌ 없음✅ 있음
복사/이동❌ 수동✅ 자동

코드 비교

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

// ❌ C union: 타입 불안전, 수동 관리
union OldUnion {
    int i;
    double d;
    // std::string s;  // 에러: 비trivial 타입 불가
};

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 (어떤 타입인지 모름)
    std::cout << u.d << std::endl;  // 3.14
}

// ✅ std::variant: 타입 안전, 자동 관리
void testVariant() {
    std::variant<int, double, std::string> v;
    
    v = 42;
    std::cout << std::get<int>(v) << std::endl;  // 42
    
    v = 3.14;  // 이전 int 자동 소멸, double 생성
    std::cout << std::get<double>(v) << std::endl;  // 3.14
    
    v = "hello";  // double 소멸, string 생성
    std::cout << std::get<std::string>(v) << std::endl;  // hello
    
    // 타입 확인
    if (std::holds_alternative<std::string>(v)) {
        std::cout << "현재 타입: string" << std::endl;
    }
}

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

출력:

=== union ===
42
3.14

=== variant ===
42
3.14
hello
현재 타입: string

2. 기본 사용

생성과 할당

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

int main() {
    // 기본 생성 (첫 번째 타입)
    std::variant<int, double, std::string> v1;  // int{} = 0
    std::cout << "인덱스: " << v1.index() << std::endl;  // 0
    std::cout << "값: " << std::get<0>(v1) << std::endl;  // 0
    
    // 값으로 생성
    std::variant<int, double, std::string> v2 = 42;
    std::cout << "인덱스: " << v2.index() << std::endl;  // 0
    
    // 값 변경
    v2 = 3.14;
    std::cout << "인덱스: " << v2.index() << std::endl;  // 1
    
    v2 = "hello";
    std::cout << "인덱스: " << v2.index() << std::endl;  // 2
    
    return 0;
}

출력:

인덱스: 0
값: 0
인덱스: 0
인덱스: 1
인덱스: 2

값 접근

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> v = 42;
    
    // get: 타입으로
    int x = std::get<int>(v);
    std::cout << "get<int>: " << x << std::endl;
    
    // get: 인덱스로
    int y = std::get<0>(v);
    std::cout << "get<0>: " << y << std::endl;
    
    // get_if: 포인터 반환 (안전)
    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 << "double이 아님" << std::endl;
    }
    
    // holds_alternative
    if (std::holds_alternative<int>(v)) {
        std::cout << "int 타입" << std::endl;
    }
    
    return 0;
}

출력:

get<int>: 42
get<0>: 42
get_if<int>: 42
double이 아님
int 타입

3. std::visit

기본 visit

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

int main() {
    std::variant<int, double, std::string> v = 42;
    
    // std::visit: variant의 현재 타입에 따라 적절한 처리 수행
    // 첫 번째 인자: 방문자 함수 (모든 가능한 타입을 처리)
    // 두 번째 인자: variant 객체
    std::visit( {
        // auto&&: 유니버설 참조 (모든 타입 받음)
        // using T = std::decay_t<decltype(arg)>:
        //   arg의 실제 타입 추출 (참조, const 제거)
        using T = std::decay_t<decltype(arg)>;
        
        // if constexpr: 컴파일 타임 분기
        // 현재 타입에 맞는 분기만 컴파일됨
        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
    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;
}

출력:

int: 42
double: 3.14

오버로드 패턴

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

// 오버로드 헬퍼: 여러 람다를 하나의 함수 객체로 결합
// 각 타입별로 다른 람다를 제공하면 컴파일러가 적절한 것 선택
template<class... Ts>
struct overloaded : Ts... {
    // using Ts::operator()...: 모든 베이스 클래스의 operator() 상속
    // 각 람다의 호출 연산자를 모두 사용 가능하게 함
    using Ts::operator()...;
};

// Deduction guide: 생성자 인자로부터 템플릿 타입 추론
// overloaded{람다1, 람다2, ...} → overloaded<람다1타입, 람다2타입, ...>
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    std::variant<int, double, std::string> v = "hello";
    
    // overloaded 패턴: 타입별로 다른 람다 제공
    // 컴파일러가 현재 variant의 타입에 맞는 람다 선택
    // 가독성이 좋고 타입 안전함 (모든 타입 처리 강제)
    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;
}

출력:

string: hello
int: 42

4. 실전 예제

예제 1: 상태 머신

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

// 오버로드 헬퍼
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 << "작업 시작" << std::endl;
    }
    
    void update(int progress) {
        if (auto* running = std::get_if<Running>(&state)) {
            running->progress = progress;
            std::cout << "진행: " << progress << "%" << std::endl;
            
            if (progress >= 100) {
                state = Completed{"성공"};
                std::cout << "작업 완료" << std::endl;
            }
        }
    }
    
    void printState() const {
        std::visit(overloaded{
             { std::cout << "상태: 대기 중" << std::endl; },
             { std::cout << "상태: 진행 중 (" << r.progress << "%)" << std::endl; },
             { std::cout << "상태: 완료 (" << 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;
}

출력:

상태: 대기 중
작업 시작
상태: 진행 중 (0%)
진행: 50%
상태: 진행 중 (50%)
진행: 100%
작업 완료
상태: 완료 (성공)

예제 2: 에러 처리

#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{"0으로 나눌 수 없음"};
    }
    return a / b;
}

Result<int, Error> squareRoot(int x) {
    if (x < 0) {
        return Error{"음수는 제곱근을 구할 수 없음"};
    }
    return static_cast<int>(std::sqrt(x));
}

int main() {
    auto result1 = divide(10, 2);
    std::visit(overloaded{
         { std::cout << "결과: " << value << std::endl; },
         { std::cout << "에러: " << err.message << std::endl; }
    }, result1);
    
    auto result2 = divide(10, 0);
    std::visit(overloaded{
         { std::cout << "결과: " << value << std::endl; },
         { std::cout << "에러: " << err.message << std::endl; }
    }, result2);
    
    auto result3 = squareRoot(16);
    std::visit(overloaded{
         { std::cout << "제곱근: " << value << std::endl; },
         { std::cout << "에러: " << err.message << std::endl; }
    }, result3);
    
    return 0;
}

출력:

결과: 5
에러: 0으로 나눌 수 없음
제곱근: 4

예제 3: 명령 패턴

#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 << "생성: " << c.name << std::endl;
            },
             {
                std::cout << "업데이트: ID=" << c.id << ", 값=" << c.newValue << std::endl;
            },
             {
                std::cout << "삭제: 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;
}

출력:

생성: user1
업데이트: ID=1, 값=new_value
삭제: ID=1

5. 자주 발생하는 문제

문제 1: 잘못된 타입

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> v = 42;
    
    // ❌ 잘못된 타입
    try {
        double d = std::get<double>(v);  // std::bad_variant_access
    } catch (const std::bad_variant_access& e) {
        std::cout << "타입 불일치: " << e.what() << std::endl;
    }
    
    // ✅ 확인 후 접근
    if (std::holds_alternative<int>(v)) {
        int x = std::get<int>(v);
        std::cout << "int: " << x << std::endl;
    }
    
    // ✅ get_if 사용 (안전)
    if (auto* ptr = std::get_if<double>(&v)) {
        std::cout << "double: " << *ptr << std::endl;
    } else {
        std::cout << "double이 아님" << std::endl;
    }
    
    return 0;
}

출력:

타입 불일치: std::bad_variant_access
int: 42
double이 아님

문제 2: 기본 생성

#include <variant>
#include <iostream>

int main() {
    // 첫 번째 타입으로 기본 생성
    std::variant<int, double> v1;  // int{} = 0
    std::cout << "v1: " << std::get<int>(v1) << std::endl;  // 0
    
    // 명시적 초기화
    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;
}

출력:

v1: 0
v2: 3.14
v3: 2.71

문제 3: 참조

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

int main() {
    int x = 42;
    
    // ❌ 참조 저장 불가
    // std::variant<int&> v{x};
    
    // ✅ reference_wrapper 사용
    std::variant<std::reference_wrapper<int>> v1{std::ref(x)};
    v1.get().get() = 100;
    std::cout << "x: " << x << std::endl;  // 100
    
    // ✅ 포인터 사용
    std::variant<int*> v2{&x};
    *std::get<int*>(v2) = 200;
    std::cout << "x: " << x << std::endl;  // 200
    
    return 0;
}

출력:

x: 100
x: 200

6. 실전 예제: JSON 값 표현

#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 값들: [";
    for (size_t i = 0; i < values.size(); ++i) {
        if (i > 0) std::cout << ", ";
        values[i].print();
    }
    std::cout << "]" << std::endl;
    
    // 타입 확인
    if (values[2].is<int64_t>()) {
        std::cout << "values[2]는 int64_t: " << values[2].as<int64_t>() << std::endl;
    }
    
    return 0;
}

출력:

JSON 값들: [null, true, 42, 3.14, "hello"]
values[2]는 int64_t: 42

정리

핵심 요약

  1. variant: 타입 안전한 union
  2. 타입 추적: 현재 타입 자동 추적
  3. std::visit: 모든 타입 처리
  4. 오버로드 패턴: 타입별 람다
  5. 실무: 상태 머신, 에러 처리, 명령 패턴

variant vs union

특징unionstd::variant
타입 안전
타입 추적
비trivial 타입
소멸자수동자동
복사/이동수동자동

실전 팁

사용 원칙:

  • 여러 타입 중 하나
  • 타입 안전 필요
  • 상태 머신
  • 에러 처리

성능:

  • 스택 할당
  • 크기: 가장 큰 타입 + 인덱스
  • 런타임 타입 확인
  • visit 오버헤드 (작음)

주의사항:

  • 타입 확인 필수
  • 참조 저장 불가
  • 중복 타입 불가
  • 첫 번째 타입 기본값

다음 단계

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

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ Union과 Variant | “타입 안전 공용체” 가이드
  • C++ optional | “선택적 값” 가이드
  • C++ any | “타입 소거” 가이드

관련 글

  • C++ std::variant vs union |
  • C++ Union과 Variant |
  • C++ Algorithm Set |
  • C++ any |
  • 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기