CodeGym /행동 /C++ SELF /std::variant: 상태 모델로서의 값

std::variant: 상태 모델로서의 값

C++ SELF
레벨 23, 레슨 0
사용 가능

1. 도입

프로그래밍을 막 시작하면 함수는 항상 "어떤 하나의 값"을 반환하는 것처럼 보입니다: 숫자, 문자열, 불리언 등. 하지만 실제 코드에서는 같은 "결과"가 의미상 다를 수 있다는 사실을 곧 알게 됩니다. 예를 들어, 커맨드 파서는 "명령을 성공적으로 파싱함"을 반환할 수도 있고, "오류: 알 수 없는 명령"을 반환할 수도 있습니다 — 그리고 이것들은 같은 의미가 아닙니다.

초보자가 흔히 하는 반응: "좋아, int으로 반환하고 -1을 오류로 쓰자". 문제는 -1이 정상값일 수도 있고(오늘은 안되고, 내일은 되고), 암묵적 약속을 기억해야만 하는 세계가 시작된다는 점입니다. 코드가 대체로 작동해도 누군가 이 약속을 잊으면 문제가 생깁니다. std::variant는 타입으로 이러한 의미를 명시하게 해줍니다: "결과는 여러 가능한 타입들 중 하나이다".

std::variant: 정확히 하나의 활성 대안

std::variant<T1, T2, ...>는 그 순간 목록에 있는 대안들 중에서 정확히 하나를 저장하는 값-컨테이너입니다. 이는 "구조체의 여러 필드"와 중요한 대조를 이룹니다: variant는 "지금 활성화된 타입"이라는 명확한 아이디어를 가집니다. "모든 필드가 동시에"가 아니고, "필드 A는 채워져 있고 필드 B는 무시한다"도 아니고, 바로 "지금 내부에 목록 중 특정 타입이 들어 있다"는 의미입니다.

variant를 라벨이 붙은 상자처럼 생각할 수 있습니다: 상자에는 "여기에는 int나 std::string이 들어갈 수 있음"이라고 적혀 있고, 어느 순간에는 하나의 값만 들어 있습니다. 어떤 라벨이 유효한지는 그 순간의 활성 대안입니다.

간단한 예제 — 문법을 익히기 위한 코드:

#include <string>
#include <variant>

int main() {
    std::variant<int, std::string> v = 10;          // 지금은 int가 내부에 있음
    v = std::string{"hi"};                          // 이제 내부는 string
}

여기서 중요한 생각은: 변수 v는 하나지만 "내부에 실제로 들어있는 값의 실타입"은 실행 중에 바뀔 수 있다는 것입니다.

optional과 variant: 차이점

variant의 메서드로 깊이 들어가기 전에 의미상의 차이를 확실히 해두는 것이 좋습니다. std::optional<T>는 "타입 T의 값이 있나?"에 답하며, 값이 있으면 T를 제공합니다. 반면 std::variant<A, B, C>는 "지금 어떤 상태인가?"에 답하며, A이거나 B이거나 C 중 하나를 줍니다.

기억하기 좋은 표:

도구 의미 실세계 모델 예
std::optional<int>
값이 있거나 없음 (
int
또는 없음)
요소 인덱스를 찾았거나 찾지 못함
std::variant<int, std::string>
int
이거나
std::string
이거나
결과가 숫자이거나 텍스트 메시지일 수 있음
enum class Status { Ok, Error } + 별도 필드 상태와 데이터가 분리되어 있음 자주 동작하지만 상태와 데이터가 비동기화되기 쉬움

enum class와 구조체 조합에는 전형적인 문제가 있습니다: "불가능한 상태"를 우연히 만들 수 있습니다(예: Status::Ok인데 에러 메시지가 채워져 있거나 그 반대). variant는 본질적으로 "성공"과 "오류"를 동시에 담는 것을 금지합니다: 항상 하나뿐입니다.

2. 검사와 추출: index(), holds_alternative 및 get_if

활성 대안과 index()

활성 대안은 현재 variant 내부에 실제로 저장되어 있는 그 타입을 말합니다. 활성 대안이 바로 안전하게 꺼낼 수 있는 타입을 결정합니다.

또 한 가지 중요한 점: variant는 일반적인 의미의 "비어 있음"이 없습니다. 기본 생성하면 가능한 경우 첫 번째 대안을 기본 생성하려고 합니다. 이것은 때로 유용하고 때로는 놀랍습니다.

예를 보겠습니다:

#include <iostream>
#include <variant>

int main() {
    std::variant<int, double> v;     // 기본적으로 int를 값 0으로 저장함
    std::cout << v.index() << '\n';  // 0
}
출력: 0

v.index()는 활성 대안의 인덱스(0부터)를 반환합니다. 사람이 읽기에는 조금 덜 직관적이지만, 진단용으로는 유용합니다: 내부에 무엇이 들어있는지 빠르게 확인할 수 있습니다.

타입 확인하기: holds_alternative<T>(v)

variant를 다룰 때 가장 큰 질문은 "지금 무엇이 들어있나?"입니다. 표준 라이브러리에는 타입으로 확인하는 읽기 쉬운 방법이 있습니다: std::holds_alternative<T>(v). 이것은 "활성 대안이 T인가?"에 답합니다. 실제 애플리케이션 코드에서는 보통 이 방식이면 충분합니다.

예제:

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

int main() {
    std::variant<int, std::string> v = 42;

    if (std::holds_alternative<int>(v)) {
        std::cout << "int inside\n";     // int inside
    }
}

요지는 단순합니다: 추측하지 말고 확인하라는 것입니다.

안전한 추출: std::get_if<T>(&v)와 nullptr 패턴

이제 get_if를 이해할 준비가 되었습니다. 함수 std::get_if<T>(&v)는 다음을 반환합니다:

  • 만약 내부에 T 대안이 활성화되어 있으면 T* 포인터,
  • 그렇지 않으면 nullptr.

이는 컨테이너에서 요소를 찾았을 때 포인터를 반환하거나, 함수가 nullptr을 반환하는 패턴과 똑같습니다.

예:

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

int main() {
    std::variant<int, std::string> v = std::string{"abcd"};

    if (const std::string* p = std::get_if<std::string>(&v)) {
        std::cout << p->size() << '\n';  // 4
    } else {
        std::cout << "not a string\n";
    }
}

형식에 주목하세요: &v를 사용하는 이유는 get_if가 variant의 주소를 받고 내부 값의 주소를 반환하기 때문입니다. 초보자는 종종 get_if<T>(v)처럼 쓰려고 해서 컴파일 오류를 당합니다. 이는 마치 이웃이 가진 열쇠로 문을 열려는 것과 같습니다: 모양은 비슷하지만 목적에 맞지 않습니다.

왜 get_if가 초보자에게 좋은가요? 위험한 동작(특정 타입 추출)을 안전하게 해주기 때문입니다: 타입이 일치하지 않으면 예외가 발생하거나 프로그램이 중단되는 대신 nullptr을 받아 정상적으로 처리할 수 있습니다.

의미상 반례: 매직 값 대 variant

두 가지 접근을 같은 과제로 비교해 보겠습니다. 문자열을 파싱해서 0부터 9까지 숫자를 얻고 싶습니다. 실패하면 오류를 반환합니다.

"매직" 접근:

#include <string_view>

int parse_digit_magic(std::string_view s) {
    if (s.size() != 1) return -1;
    char c = s[0];
    if (c < '0' || c > '9') return -1;
    return c - '0';
}

간결하지만 문제는 -1이 "오류 암호"라는 점입니다. 모든 곳에서 이것을 기억하고 검사해야 합니다.

variant를 사용한 접근 (결과는 숫자 또는 오류 메시지):

#include <string>
#include <string_view>
#include <variant>

using ParseResult = std::variant<int, std::string>;

ParseResult parse_digit(std::string_view s) {
    if (s.size() != 1) return std::string{"need exactly one char"};
    char c = s[0];
    if (c < '0' || c > '9') return std::string{"not a digit"};
    return static_cast<int>(c - '0');
}

여기서는 타입이 진실을 말합니다: 결과는 int이거나 std::string입니다. 더 이상 "-1이 오류다, 단 내가 기억하면" 같은 암묵적 규칙이 필요 없습니다.

물론 이후에는 값을 조심스럽게 꺼내야 합니다(get_if나 holds_alternative를 통해). 하지만 성공과 오류의 의미가 하나의 숫자에 섞이지 않는다는 대가는 확실히 정직한 대가입니다.

5. TodoCLI 예제: 파싱 결과로서의 variant

이제 코스에서 점차 확장할 간단한 애플리케이션 조각을 만들어 보겠습니다. 아주 간단한 콘솔 용 할 일 목록(todo) 프로그램을 예로 삼습니다. 오늘의 목표는 완전한 todo 앱을 만드는 것이 아니라, 명령 파싱 결과를 variant로 모델링하는 법을 배우는 것입니다.

사용자가 다음과 같은 문자열로 명령을 입력한다고 가정합시다:

  • add 우유 사기
  • done 2
  • list

우리는 struct로 명령 모델을 만들고, 파싱 결과는 variant로 반환하겠습니다.

명령 모델

간단한 구조체들로 각 명령의 의미에 맞는 필드를 만듭니다. 특별한 마법은 없습니다: 데이터만 모델링합니다.

#include <string>

struct AddCmd {
    std::string text;
};

struct DoneCmd {
    std::size_t index; // 작업 번호
};

struct ListCmd { };

struct ParseError {
    std::string message;
};

이제 "명령 또는 오류"의 공통 타입을 정의합니다:

#include <variant>

using Command = std::variant<AddCmd, DoneCmd, ListCmd, ParseError>;

주의할 점: Command는 "명령들의 클래스"가 아니라 정확히 말해 "다음 중 하나"입니다: AddCmd 또는 DoneCmd 또는 ListCmd 또는 ParseError.

파서: variant 반환

파서는 매우 단순하게 작성하여 핵심 아이디어에 집중합니다. 정확히 세 가지 명령과 오류를 인식하도록 합시다.

#include <string>
#include <string_view>
#include <variant>

Command parse_command(std::string_view line) {
    if (line == "list") {
        return ListCmd{};
    }
    if (line.starts_with("add ")) {
        return AddCmd{std::string{line.substr(4)}};
    }
    if (line.starts_with("done ")) {
        return ParseError{"done: parsing number not implemented yet"};
    }
    return ParseError{"unknown command"};
}

참고: starts_with는 C++20에서 도입되었고, 여기서는 최신 C++(C++23)을 가정합니다. 만약 환경에서 starts_with가 없다면 find(...) == 0으로 대체할 수 있습니다. 지금은 variant의 아이디어가 더 중요합니다.

get_if로 결과 처리하기

이 강의에서는 간단한 검사만 사용합니다. 우리는 Command를 받아 get_if로 내부에 무엇이 있는지 안전하게 확인합니다.

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

void handle_command(const Command& cmd) {
    if (const AddCmd* p = std::get_if<AddCmd>(&cmd)) {
        std::cout << "ADD: " << p->text << '\n';
        return;
    }
    if (const ListCmd* = std::get_if<ListCmd>(&cmd)) {
        std::cout << "LIST\n";
        return;
    }
    if (const ParseError* e = std::get_if<ParseError>(&cmd)) {
        std::cout << "ERROR: " << e->message << '\n';
        return;
    }
    std::cout << "Unhandled command state\n";
}

여기서 두 가지 중요한 포인트가 있습니다.

첫째: 코드를 선형적으로 읽히게 하기 위해 초기에 return을 사용합니다. 이렇게 하면 else if의 깊은 중첩을 피할 수 있습니다.

둘째: 우리는 어떤 "위험한 강제 추출"도 하지 않습니다. variant에 "지금 너는 AddCmd냐?"고 묻고, 맞으면 포인터를 받아 사용합니다. 틀리면 nullptr이 되어 다음 분기로 넘어갑니다.

6. std::monostate: 명시적인 "아무것도 없음" 상태

때로는 variant에 "아직 선택된 것이 없다"는 상태를 표현하고 싶을 때가 있습니다. 앞서 말했듯이 variant는 비어 있을 수 없고 기본 생성 시 첫 번째 대안을 생성합니다. 그래서 관용적으로 목록의 첫 번째 대안으로 std::monostate를 추가합니다. 이것은 특수한 빈 자리 표시자 타입입니다.

예를 들어 UI 시나리오(콘솔 포함): "아직 명령이 입력되지 않았지만 결과를 담을 변수가 존재한다"거나 "작업이 아직 수행되지 않았다"는 경우에 monostate가 적절합니다.

#include <variant>

using MaybeCommand = std::variant<std::monostate, AddCmd, DoneCmd, ListCmd, ParseError>;

이제 "빈 상태"는 명시적이고 타입-안전하게 표현됩니다: std::monostate입니다. 빈 문자열을 message에 넣거나 index를 0으로 설정해 놓고 "아무것도 아니다"라고 하는 대신에 말이죠.

7. std::variant 사용 시 흔한 실수

실수 #1: 활성 대안 확인 없이 무작정 값을 꺼내기.
때로는 "여기에는 분명히 AddCmd가 들어 있다"라고 생각하고 확인 없이 추출하려는 유혹이 있습니다. 최선의 경우 예외가 발생하거나 비정상 종료되고, 최악의 경우 문제의 증상만 임시로 막게 됩니다. 해결책은 규율입니다: 항상 holds_alternative나 get_if로 확인한 뒤 값을 사용하세요.

실수 #2: get_if와 일반 get을 혼동하거나 &를 빼먹기.
std::get_if<T>(&v)는 variant의 주소를 요구합니다. 주소 대신 값을 넘기면 컴파일 오류가 나고 "variant가 고장났다"라고 생각할 수 있습니다. 좋은 습관은 "get_if는 variant의 주소를 받고 내부 값의 주소를 준다"라고 소리 내어 말해보는 것입니다.

실수 #3: 의미상 구분이 잘 안 되는 대안 설계.
예: variant<int, long long> 또는 variant<int, unsigned>는 거의 언제나 골칫거리가 됩니다. 어떤 상황에서 왜 unsigned인지 계속 고민하게 되고 암묵적인 변환에 걸립니다. 초기에는 서로 다른 상태를 반영하도록 대안을 고르세요: Command 대 ParseError, int 대 std::string 등.

실수 #4: 상태가 두 개뿐이라면 굳이 variant를 쓰려 한다.
진짜로 "값 있음/없음" 두 상태뿐이라면 optional이 더 단순하고 읽기 쉽습니다. variant는 상태가 두 개를 초과하거나 타입으로 차이를 표현해야 할 때 적합합니다(예: 서로 다른 형태의 명령).

실수 #5: variant가 기본적으로 첫 번째 대안을 생성한다는 사실을 잊는 것.
초보자는 종종 "variant를 생성했더니 이미 뭔가 들어 있다"며 놀랍니다. 네, 들어 있습니다: 첫 번째 타입입니다. 만약 "아무것도 없음" 상태가 필요하면 첫 번째 대안으로 std::monostate를 추가하고, 그 상태를 정상적인 프로그램 상태로 취급하세요.

1
과제
C++ SELF, 레벨 23, 레슨 0
잠금
캐셔 토큰
캐셔 토큰
1
과제
C++ SELF, 레벨 23, 레슨 0
잠금
엔지니어의 나눗셈
엔지니어의 나눗셈
1
과제
C++ SELF, 레벨 23, 레슨 0
잠금
봇 명령
봇 명령
1
과제
C++ SELF, 레벨 23, 레슨 0
잠금
터미널 메모리
터미널 메모리
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION