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 중 하나를 줍니다.
기억하기 좋은 표:
| 도구 | 의미 | 실세계 모델 예 |
|---|---|---|
|
값이 있거나 없음 ( 또는 없음) |
요소 인덱스를 찾았거나 찾지 못함 |
|
이거나 이거나 |
결과가 숫자이거나 텍스트 메시지일 수 있음 |
| 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를 추가하고, 그 상태를 정상적인 프로그램 상태로 취급하세요.
GO TO FULL VERSION