C++17 optional·variant·any: nullptr 체크 대신 타입으로 표현하기
💡 초보자를 위한 한 줄: “있을 수도 없을 수도”는
std::optional, “정해진 타입들 중 하나”는std::variant, 정말 타입을 런타임에만 알겠다면 최후에std::any. 포인터 + nullptr 대신optional<T>부터 습관화하면 안전합니다. 12-2 범위 for 다음이 읽기 순서에 맞습니다.
들어가며: nullptr 체크에 지쳤다
사용자 정보를 조회하는 함수를 만들었습니다. 하지만 사용자가 없을 때를 표현하기 어려웠습니다.
std::optional은 “값이 있거나 없거나”를 타입으로 표현해서 nullptr(널 포인터—아무 객체도 가리키지 않음을 나타내는 값)·예외·bool+참조 패턴보다 명확하며, 호출하는 쪽에서 반드시 유무를 확인하게 만들 수 있습니다. std::variant(정해진 타입 목록 중 하나를 담는 타입)·std::any(어떤 타입이든 담을 수 있는 타입)는 “여러 타입 중 하나”나 “타입을 나중에 정하는 값”을 타입 안전하게 다룰 때 쓰면, void*나 공용체보다 안전합니다.
문제의 코드:
// ❌ 방법 1: 포인터 (메모리 관리 복잡)
User* findUser(int id) {
if (존재하지않음) return nullptr;
return new User{...}; // 누가 delete?
}
// ❌ 방법 2: 예외 (성능 문제)
User findUser(int id) {
if (존재하지않음) throw std::runtime_error("Not found");
return User{...};
}
// ❌ 방법 3: bool + 참조 (복잡)
bool findUser(int id, User& out) {
if (존재하지않음) return false;
out = User{...};
return true;
}
optional로 해결:
std::optional<User> findUser(int id) {
if (존재하지않음) {
return std::nullopt; // 값 없음
}
return User{...}; // 값 있음
}
int main() {
auto user = findUser(123);
if (user) {
std::cout << "Found: " << user->name << "\n";
} else {
std::cout << "Not found\n";
}
}
선택 기준: “값이 없을 수 있다”만 표현하면 optional, “여러 타입 중 하나”면 variant, “타입을 아예 나중에 정한다”면 any입니다. any는 타입 안전성이 떨어지므로 꼭 필요할 때만 쓰며, 대부분은 optional과 variant로 커버할 수 있습니다.
이 글을 읽으면:
- optional로 값의 존재 여부를 안전하게 표현할 수 있습니다.
- variant로 여러 타입 중 하나를 저장할 수 있습니다.
- any로 임의의 타입을 저장할 수 있습니다.
- 실전에서 타입 안전하게 코드를 작성할 수 있습니다.
nullptr 체크 누락, 타입 혼동, 동적 타입 전달 문제
시나리오 1: nullptr 체크 누락으로 크래시
// ❌ 문제: nullptr 체크를 깜빡하면 크래시
User* user = findUser(123);
std::cout << user->name << "\n"; // user가 nullptr면 Segmentation fault!
// ✅ 해결: optional은 타입으로 "없을 수 있음"을 강제
std::optional<User> user = findUser(123);
if (user) {
std::cout << user->name << "\n"; // 확인 후 접근
}
주의할 점은 optional도 ->와 *를 제공하기 때문에 user->name을 확인 없이 쓰면 컴파일은 됩니다(값이 없으면 UB). optional이 주는 이점은 컴파일러가 막아 준다는 것이 아니라, 반환 타입만 보고도 “없을 수 있다”는 사실이 드러나 호출부가 확인을 빠뜨리기 어려워진다는 데 있습니다. 확인 없이 꺼내야 하는 곳이라면 value()(없으면 예외)나 value_or()를 쓰는 편이 안전합니다.
시나리오 2: JSON/설정 파싱 시 타입 혼동
// ❌ 문제: "port"가 정수인지 문자열인지 런타임에만 알 수 있음
void* getConfig(const std::string& key); // 반환 타입이 뭔지 모름
// ✅ 해결: variant로 허용 타입을 명시
using ConfigValue = std::variant<int, double, std::string, bool>;
std::optional<ConfigValue> getConfig(const std::string& key);
시나리오 3: 플러그인/스크립트에서 동적 타입 전달
// ❌ 문제: C++ API에 Python/JS에서 온 값을 넘겨야 함
void setProperty(const std::string& name, ??? value); // 타입을 미리 알 수 없음
// ✅ 해결: any (최후의 수단, 타입 체크는 런타임에)
void setProperty(const std::string& name, std::any value);
시나리오 4: 이벤트·메시지가 여러 타입 중 하나
// ❌ 상속 + dynamic_cast 사슬: 새 이벤트를 추가해도 빠진 분기를 컴파일러가 알려 주지 않음
if (auto* e = dynamic_cast<MouseEvent*>(event)) { /* ... */ }
else if (auto* e = dynamic_cast<KeyEvent*>(event)) { /* ... */ }
// ✅ variant + visit: 처리하지 않은 타입이 있으면 컴파일 단계에서 드러남
using Event = std::variant<MouseClick, KeyPress, TimerTick>;
GUI 이벤트, 네트워크 메시지, 상태 머신의 상태처럼 종류가 정해져 있고 자주 늘지 않는 값은 variant가 잘 맞습니다. 전체 예제는 variant 이벤트 시스템 예제에 있습니다.
시나리오 5: 파싱 실패를 예외로 처리하는 부담
// ❌ 사용자 입력은 틀린 게 정상 경로인데 예외로 흐름 제어
int port = std::stoi(str); // "abc"면 std::invalid_argument
// ✅ 실패도 반환값으로
std::optional<int> port = parseInt(str);
설정 파일이나 사용자 입력에서 “형식이 틀림”은 예외적인 상황이 아니라 자주 일어나는 분기입니다. 이런 곳에서 예외를 쓰면 호출부마다 try-catch가 붙고, 어떤 함수가 무엇을 던지는지 시그니처로는 알 수 없습니다. optional(실패 이유가 필요 없을 때)이나 variant 기반 Result(이유가 필요할 때)로 바꾸면 실패 가능성이 반환 타입에 드러납니다.
타입 선택 흐름도
flowchart TD
A[값이 없을 수 있나?] -->|예| B[optional]
A -->|아니오| C[여러 타입 중 하나?]
C -->|예, 타입 고정| D[variant]
C -->|예, 타입 미정| E[any]
C -->|아니오| F[일반 타입]
std::optional: 값이 없을 수 있음을 타입으로 표현
기본 사용법
std::optional<T>는 “T 타입 값이 있거나, 없거나”를 하나의 타입으로 표현합니다. divide처럼 0으로 나누는 경우처럼 유효한 값을 반환할 수 없는 상황에서 std::nullopt를 반환하며, 정상일 때만 return a / b로 값을 담아 반환합니다. 호출부에서는 if (result)로 값 존재 여부를 확인한 뒤 *result 또는 result.value()로 접근하면, 포인터나 예외 없이 “값 없음”을 다룰 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o optional_divide optional_divide.cpp && ./optional_divide
#include <optional>
#include <iostream>
std::optional<int> divide(int a, int b) {
if (b == 0) {
return std::nullopt; // 값 없음
}
return a / b; // 값 있음
}
int main() {
auto result = divide(10, 2);
if (result) {
std::cout << "Result: " << *result << "\n"; // 5
}
auto fail = divide(10, 0);
if (!fail) {
std::cout << "Division by zero\n";
}
return 0;
}
실행 결과: Result: 5 한 줄, 이어서 Division by zero 한 줄이 출력됩니다.
값 확인과 접근
값이 있는지 확인하는 방법은 has_value(), if (opt)(bool 변환), value_or(기본값) 이 있습니다. value()는 값이 없으면 std::bad_optional_access 예외를 던지므로, 먼저 has_value()나 if (opt)로 확인한 뒤 사용하는 것이 안전합니다. 기본값으로 대체하고 싶을 때는 value_or("Unknown")처럼 쓰면, 값이 없으면 “Unknown”이 반환됩니다.
#include <optional>
#include <iostream>
#include <string>
std::optional<std::string> getName() {
return "Alice";
}
int main() {
auto name = getName();
// 방법 1: has_value()
if (name.has_value()) {
std::cout << name.value() << "\n";
}
// 방법 2: operator bool
if (name) {
std::cout << *name << "\n";
}
// 방법 3: value_or (기본값)
std::string result = name.value_or("Unknown");
}
언제 무엇을 쓸까: 값이 있을 때만 쓰려면 if (opt) 또는 has_value()로 확인한 뒤 value() 또는 *opt로 접근합니다. 값이 없을 수 있는데 기본값으로 대체하고 싶으면 value_or(기본값)이 가장 안전합니다. value()는 값이 없으면 std::bad_optional_access 예외를 던지고, *opt는 값이 없을 때 정의되지 않은 동작(UB)이므로 확실히 값이 있을 때만 사용하세요.
예외 처리
#include <optional>
#include <iostream>
int main() {
std::optional<int> opt;
// ❌ 값이 없으면 예외
try {
int value = opt.value(); // std::bad_optional_access
} catch (const std::bad_optional_access& e) {
std::cerr << "No value\n";
}
// ✅ 권장: value_or
int value = opt.value_or(0);
return 0;
}
optional 생성
#include <optional>
#include <string>
// 빈 optional
std::optional<int> opt1;
std::optional<int> opt2 = std::nullopt;
// 값 있는 optional
std::optional<int> opt3 = 42;
std::optional<int> opt4{42};
std::optional<int> opt5 = std::make_optional(42);
// in-place 생성
std::optional<std::string> opt6(std::in_place, 10, 'x'); // "xxxxxxxxxx"
optional 수정
#include <optional>
int main() {
std::optional<int> opt;
// 값 할당
opt = 42;
// 값 제거
opt.reset();
opt = std::nullopt;
// emplace
opt.emplace(100);
return 0;
}
완전한 optional 예제: 사용자 조회 시스템
// g++ -std=c++17 -o optional_user optional_user.cpp && ./optional_user
#include <optional>
#include <iostream>
#include <string>
#include <map>
struct User {
int id;
std::string name;
int age;
};
std::map<int, User> db = {
{1, {1, "Alice", 25}},
{2, {2, "Bob", 30}},
};
std::optional<User> findUser(int id) {
auto it = db.find(id);
if (it == db.end()) {
return std::nullopt;
}
return it->second;
}
int main() {
// 케이스 1: 사용자 있음
if (auto user = findUser(1)) {
std::cout << "Found: " << user->name << ", " << user->age << "\n";
}
// 케이스 2: 사용자 없음
auto missing = findUser(999);
std::cout << "User 999: " << (missing ? "Found" : "Not found") << "\n";
// 케이스 3: 기본값 사용
auto user = findUser(2).value_or(User{0, "Guest", 0});
std::cout << "User 2 or Guest: " << user.name << "\n";
return 0;
}
std::variant: 정해진 타입 중 하나와 std::visit
기본 사용법
#include <variant>
#include <string>
std::variant<int, double, std::string> value;
value = 42; // int
value = 3.14; // double
value = std::string("hello"); // string
값 접근
#include <variant>
#include <iostream>
#include <string>
std::variant<int, double, std::string> v = 42;
// 방법 1: std::get<T>
try {
int i = std::get<int>(v); // OK
// double d = std::get<double>(v); // 예외!
} catch (const std::bad_variant_access& e) {
std::cerr << "Wrong type\n";
}
// 방법 2: std::get<index>
int i = std::get<0>(v); // 첫 번째 타입
// 방법 3: std::get_if
if (auto ptr = std::get_if<int>(&v)) {
std::cout << "int: " << *ptr << "\n";
}
타입 확인
#include <variant>
#include <iostream>
#include <string>
std::variant<int, double, std::string> v = 3.14;
// index()
std::cout << v.index() << "\n"; // 1 (double은 두 번째)
// holds_alternative
if (std::holds_alternative<double>(v)) {
std::cout << "It's a double\n";
}
std::visit
#include <variant>
#include <iostream>
#include <string>
#include <type_traits>
std::variant<int, double, std::string> v = 42;
// 방문자 패턴
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << "\n";
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << arg << "\n";
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << "\n";
}
}, v);
visit의 의미: “지금 variant가 들고 있는 타입이 뭐든, 그에 맞는 동작을 한 번에 적용”하고 싶을 때 std::visit를 씁니다. get<T>나 get_if<T>는 타입을 미리 알고 있을 때 쓰며, visit는 모든 타입을 동일한 방식으로 처리할 때(로깅, 직렬화, 포맷 출력 등) 유리합니다.
오버로드 패턴
#include <variant>
#include <iostream>
#include <string>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
std::variant<int, double, std::string> v = "hello";
std::visit(overloaded{
[](int i) { std::cout << "int: " << i << "\n"; },
[](double d) { std::cout << "double: " << d << "\n"; },
[](const std::string& s) { std::cout << "string: " << s << "\n"; }
}, v);
}
이 패턴이 하는 일: overloaded는 여러 람다(함수 객체)를 하나로 묶어서, visit가 variant에 들어 있는 값의 타입에 따라 해당하는 람다만 호출하게 합니다. 타입별로 get_if로 분기하는 대신, “int면 이렇게, double이면 이렇게, string이면 이렇게”를 한 번에 나열해 두는 방식이라 가독성이 좋습니다.
완전한 variant 예제: Result 타입 (에러 처리)
// g++ -std=c++17 -o variant_result variant_result.cpp && ./variant_result
#include <variant>
#include <iostream>
#include <string>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
struct Error {
std::string message;
int code;
};
template <typename T>
using Result = std::variant<T, Error>;
Result<int> divide(int a, int b) {
if (b == 0) {
return Error{"Division by zero", -1};
}
return a / b;
}
int main() {
auto r1 = divide(10, 2);
std::visit(overloaded{
[](int v) { std::cout << "Result: " << v << "\n"; },
[](const Error& e) { std::cerr << "Error: " << e.message << "\n"; }
}, r1);
auto r2 = divide(10, 0);
std::visit(overloaded{
[](int v) { std::cout << "Result: " << v << "\n"; },
[](const Error& e) { std::cerr << "Error: " << e.message << " (code=" << e.code << ")\n"; }
}, r2);
return 0;
}
완전한 variant 예제: 이벤트 시스템
시나리오 4의 이벤트 처리를 variant로 옮긴 예제입니다. 이벤트 타입은 서로 상속 관계가 없는 평범한 구조체이고, 처리 로직은 handle 한곳에 모입니다.
// g++ -std=c++17 -o variant_event variant_event.cpp && ./variant_event
#include <variant>
#include <vector>
#include <iostream>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
struct MouseClick { int x, y; };
struct KeyPress { char key; };
struct TimerTick { int id; };
using Event = std::variant<MouseClick, KeyPress, TimerTick>;
void handle(const Event& e) {
std::visit(overloaded{
[](const MouseClick& c) { std::cout << "Mouse: (" << c.x << ", " << c.y << ")\n"; },
[](const KeyPress& k) { std::cout << "Key: " << k.key << "\n"; },
[](const TimerTick& t) { std::cout << "Timer: " << t.id << "\n"; }
}, e);
}
int main() {
std::vector<Event> queue{MouseClick{10, 20}, KeyPress{'A'}, TimerTick{1}};
for (const auto& e : queue) {
handle(e);
}
return 0;
}
상속 대신 variant를 고르는 기준: variant는 타입 목록이 닫혀 있는(closed set) 경우에 강합니다. Event에 Resize를 추가하면 overloaded에 해당 람다가 없는 모든 visit 호출부가 컴파일 에러가 나므로, 처리 누락을 빌드 단계에서 잡을 수 있습니다. 값으로 저장되니 std::vector<Event>에 힙 할당 없이 연속으로 담기는 것도 장점입니다. 반대로 플러그인처럼 외부에서 새 타입이 계속 추가되는(open set) 구조라면, 매번 variant 정의를 고쳐야 하므로 가상 함수 기반 상속이 더 자연스럽습니다. 또 variant의 크기는 가장 큰 대안 타입에 맞춰지므로, 대안 하나만 유난히 크다면 그 타입만 std::unique_ptr로 감싸는 방법을 고려합니다.
std::any: 어떤 타입이든 담는 대가
기본 사용법
#include <any>
#include <string>
#include <vector>
std::any value;
value = 42; // int
value = 3.14; // double
value = std::string("hello"); // string
value = std::vector<int>{1, 2, 3}; // vector
값 접근
#include <any>
#include <iostream>
std::any a = 42;
// any_cast
try {
int i = std::any_cast<int>(a); // OK
// double d = std::any_cast<double>(a); // 예외!
} catch (const std::bad_any_cast& e) {
std::cerr << "Wrong type\n";
}
// 포인터 버전 (예외 안 던짐)
if (auto ptr = std::any_cast<int>(&a)) {
std::cout << "int: " << *ptr << "\n";
} else {
std::cout << "Not an int\n";
}
타입 확인
#include <any>
#include <iostream>
#include <typeinfo>
std::any a = 42;
// has_value()
if (a.has_value()) {
std::cout << "Has value\n";
}
// type()
if (a.type() == typeid(int)) {
std::cout << "It's an int\n";
}
any 수정
#include <any>
#include <string>
int main() {
std::any a;
// 값 할당
a = 42;
// 값 제거
a.reset();
// emplace
a.emplace<std::string>("hello");
return 0;
}
완전한 any 예제: 속성 저장소
// g++ -std=c++17 -o any_properties any_properties.cpp && ./any_properties
#include <any>
#include <optional>
#include <iostream>
#include <string>
#include <map>
class PropertyStore {
std::map<std::string, std::any> data;
public:
template <typename T>
void set(const std::string& key, const T& value) {
data[key] = value;
}
template <typename T>
std::optional<T> get(const std::string& key) const {
auto it = data.find(key);
if (it == data.end()) {
return std::nullopt;
}
try {
return std::any_cast<T>(it->second);
} catch (const std::bad_any_cast&) {
return std::nullopt;
}
}
};
int main() {
PropertyStore store;
store.set("port", 8080);
store.set("host", std::string("localhost"));
store.set("ratio", 0.95);
if (auto port = store.get<int>("port")) {
std::cout << "Port: " << *port << "\n";
}
if (auto host = store.get<std::string>("host")) {
std::cout << "Host: " << *host << "\n";
}
if (auto ratio = store.get<double>("ratio")) {
std::cout << "Ratio: " << *ratio << "\n";
}
if (!store.get<int>("missing")) {
std::cout << "Missing key returns nullopt\n";
}
return 0;
}
optional, variant, any 중 고르는 기준
optional vs variant vs any
| 특징 | optional | variant | any |
|---|---|---|---|
| 용도 | 값 있음/없음 | 여러 타입 중 하나 | 임의 타입 |
| 타입 개수 | 1개 | 고정 (컴파일 타임) | 무제한 (런타임) |
| 타입 안전성 | ✅ 높음 | ✅ 높음 | ⚠️ 낮음 |
| 성능 | ✅ 빠름 | ✅ 빠름 | ⚠️ 느림 (힙 할당 가능) |
| 메모리 | 객체 안에 값 보관 | 객체 안에 값 보관 | 작은 값은 내부 버퍼, 큰 값은 힙 |
사용 시나리오
optional 사용:
// 값이 없을 수 있음
std::optional<User> findUser(int id);
std::optional<int> parseInt(const std::string& str);
std::optional<std::string> getEnv(const std::string& key);
variant 사용:
// 여러 타입 중 하나 (고정)
std::variant<int, double, std::string> ConfigValue;
std::variant<Success, Error> Result;
std::variant<Circle, Rectangle, Triangle> Shape;
any 사용:
// 타입을 미리 알 수 없음
std::map<std::string, std::any> properties;
std::vector<std::any> heterogeneousContainer;
bad_optional_access, bad_variant_access, bad_any_cast
문제 1: optional에 값 없는데 value()나 *opt 호출
증상: std::bad_optional_access 예외 또는 정의되지 않은 동작(UB)
// ❌ 잘못된 사용
std::optional<int> opt;
int x = opt.value(); // 예외!
int y = *opt; // UB!
// ✅ 해결
if (opt) {
int x = opt.value();
}
int y = opt.value_or(0);
문제 2: variant에 get으로 잘못된 타입 요청
증상: std::bad_variant_access 예외
// ❌ 잘못된 사용
std::variant<int, double> v = 42;
double d = std::get<double>(v); // 예외!
// ✅ 해결: get_if 또는 holds_alternative 먼저 확인
if (auto ptr = std::get_if<double>(&v)) {
double d = *ptr;
}
문제 3: any_cast로 잘못된 타입 요청
증상: std::bad_any_cast 예외
// ❌ 잘못된 사용
std::any a = 42;
std::string s = std::any_cast<std::string>(a); // 예외!
// ✅ 해결: 포인터 버전 사용 또는 예외 처리
if (auto ptr = std::any_cast<int>(&a)) {
int i = *ptr;
} else {
// 다른 타입 처리
}
문제 4: optional 중첩
증상: std::optional<std::optional<T>>로 불필요한 중첩
// ❌ 혼란스러운 반환
std::optional<std::optional<int>> weird() {
return std::optional<int>(42); // optional을 optional로 감쌈
}
// ✅ 해결: 단일 optional
std::optional<int> clean() {
return 42;
}
문제 5: variant에 void 타입 포함
증상: 컴파일 에러 — variant는 void를 담을 수 없음
// ❌ 잘못된 사용
std::variant<int, void> v; // 컴파일 에러!
// ✅ 해결: std::monostate로 "빈" 상태 표현
std::variant<std::monostate, int> v;
v = std::monostate{}; // "값 없음" 의미
문제 6: any에 참조 저장
증상: std::any는 참조를 decay해서 값으로 저장함
// ❌ 의도와 다름
int x = 42;
std::any a = x; // int 복사본 저장, x와 연결 없음
*std::any_cast<int>(&a) = 100; // a 내부만 변경, x는 여전히 42
// (참고: std::any_cast<int>(a)는 복사본을 반환하므로 여기에 대입할 수도 없음)
// ✅ 참조가 필요하면 std::reference_wrapper
#include <functional>
std::any b = std::ref(x);
std::any_cast<std::reference_wrapper<int>>(b).get() = 100; // x도 100
문제 7: visit에서 빠진 타입이 엉뚱한 람다로 들어감
증상: variant에 새 타입을 추가했는데 컴파일 에러 없이 다른 분기가 실행됨
using V = std::variant<int, bool, std::string>; // bool을 새로 추가
std::visit(overloaded{
[](int i) { std::cout << "int " << i; }, // bool도 여기로 들어옴!
[](const std::string& s) { std::cout << s; }
}, v);
overloaded는 일반 오버로드 해석을 따르므로, 정확히 맞는 람다가 없으면 암시적 변환이 가능한 람다가 선택됩니다. bool→int, char→int, float→double 같은 조합에서 누락이 조용히 숨어 버립니다. [](auto&&) 같은 포괄 람다에 기본 동작을 넣어 두는 경우도 마찬가지로 누락이 드러나지 않습니다. 반대로 이 성질을 이용할 수 있습니다. 포괄 람다는 어떤 타입이든 변환 없이 받으므로 변환이 필요한 람다보다 우선 선택되는데, 여기에 static_assert를 넣어 두면 처리하지 않은 타입이 생기는 순간 컴파일이 실패합니다.
template<class> inline constexpr bool always_false = false;
std::visit(overloaded{
[](int i) { /* ... */ },
[](bool b) { /* ... */ },
[](const std::string& s) { /* ... */ },
[](auto&& other) {
static_assert(always_false<std::decay_t<decltype(other)>>, "처리하지 않은 타입");
}
}, v);
문제 8: optional에 참조를 담고 싶음
증상: std::optional<int&>가 컴파일 에러
C++17·20·23의 optional은 참조 타입을 담을 수 없습니다. “없을 수도 있는 참조”는 지금까지 T*(가장 흔함)나 std::optional<std::reference_wrapper<T>>로 표현해 왔고, std::optional<T&>는 C++26에 들어갔습니다. 컨테이너에서 찾은 원소를 복사 없이 돌려주고 싶다면, C++26 이전에는 널이 될 수 있는 포인터 반환이 여전히 가장 간단한 선택입니다.
#include <functional>
std::optional<std::reference_wrapper<User>> findRef(int id); // C++17 우회
User* findPtr(int id); // 흔한 대안
메모리 레이아웃과 비용 구조 비교
메모리 레이아웃
flowchart TB
subgraph optional[optional<T>]
O1[bool: 값 유무] --> O2[T: 실제 값]
end
subgraph variant[variant<A,B,C>]
V1[union: max size] --> V2[index]
end
subgraph any[any]
A1[힙 할당] --> A2[type_info]
A2 --> A3[소멸자]
end
비용 구조 비교
| 연산 | optional | variant | any |
|---|---|---|---|
| 생성 | T 생성 + 플래그 설정, 힙 없음 | 대안 타입 생성 + 인덱스 설정, 힙 없음 | 작은 타입은 내부 버퍼(SBO), 큰 타입은 힙 할당 |
| 복사 | T 복사 비용 | 현재 담긴 타입의 복사 비용 | 타입 소거된 복사 함수 호출 (+ 힙 할당 가능) |
| 접근 | 플래그 확인 후 직접 접근, 인라인 가능 | 인덱스 기반 분기 | 타입 비교 후 캐스트 |
| 메모리 | sizeof(T) + 플래그(정렬 패딩 포함) | 가장 큰 대안 + 인덱스(정렬 포함) | 구현마다 다른 고정 크기 + 필요 시 힙 |
std::any의 내부 버퍼 크기는 표준이 정하지 않고 구현마다 다르므로, “어떤 크기부터 힙에 가는지”를 코드가 가정하면 안 됩니다. 실제 차이가 궁금하다면 대상 컴파일러에서 sizeof와 프로파일러로 직접 확인하는 것이 정확합니다.
성능 팁
- optional:
std::optional<std::string>보다std::optional<std::string_view>가 작은 문자열에 유리할 수 있음 (복사 비용 감소) - variant:
std::visit는 컴파일 타임에 모든 타입을 처리하므로 인라인·최적화가 잘 됨 - any: 핫 루프에서는 피하며, 설정 로드·플러그인 인터페이스 등에서만 사용
// ❌ any를 반복문에서 매번 사용
for (int i = 0; i < 1000000; ++i) {
std::any val = getValue(i); // 매번 힙 할당 가능
process(val);
}
// ✅ variant로 타입 고정
using Value = std::variant<int, double, std::string>;
for (int i = 0; i < 1000000; ++i) {
Value val = getValueVariant(i); // 스택 할당
process(val);
}
안전한 파싱, 설정 값, 모나딕 연산 체이닝
패턴 1: 안전한 파싱
#include <optional>
#include <iostream>
#include <string>
#include <stdexcept>
std::optional<int> parseInt(const std::string& str) {
try {
return std::stoi(str);
} catch (...) {
return std::nullopt;
}
}
int main() {
auto num = parseInt("123");
if (num) {
std::cout << "Parsed: " << *num << "\n";
}
auto fail = parseInt("abc");
int value = fail.value_or(0); // 기본값
}
패턴 2: 설정 값
#include <variant>
#include <optional>
#include <map>
#include <iostream>
#include <string>
class Config {
std::map<std::string, std::variant<int, double, std::string>> data;
public:
template <typename T>
void set(const std::string& key, const T& value) {
data[key] = value;
}
template <typename T>
std::optional<T> get(const std::string& key) const {
auto it = data.find(key);
if (it == data.end()) {
return std::nullopt;
}
if (auto ptr = std::get_if<T>(&it->second)) {
return *ptr;
}
return std::nullopt;
}
};
int main() {
Config config;
config.set("port", 8080);
config.set("host", std::string("localhost"));
if (auto port = config.get<int>("port")) {
std::cout << "Port: " << *port << "\n";
}
}
패턴 3: 에러 처리
#include <variant>
#include <iostream>
#include <string>
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{"Division by zero"};
}
return a / b;
}
int main() {
auto result = divide(10, 2);
std::visit(overloaded{
[](int value) { std::cout << "Result: " << value << "\n"; },
[](const Error& err) { std::cerr << "Error: " << err.message << "\n"; }
}, result);
}
std::variant<T, E>로 Result를 만들 때 흔히 걸리는 함정이 있습니다. T와 E가 같은 타입이면 성공과 실패를 구분할 수 없습니다. Result<std::string, std::string>은 std::variant<std::string, std::string>이 되어 return "ok";가 모호해지고, std::get<std::string>도 컴파일되지 않습니다. 그래서 위 예제처럼 에러를 Error 같은 전용 구조체로 감싸는 것이 사실상 필수입니다. std::string을 그대로 에러 타입으로 쓰고 싶어지는 순간이 오는데, 그때도 struct Error { std::string message; };로 한 겹 두는 편이 안전합니다.
C++23을 쓸 수 있다면 같은 목적의 표준 타입 std::expected<T, E>가 있습니다. T와 E가 같아도 구분되고(std::unexpected(e)로 에러를 만듦), has_value()·value()·error()와 아래의 모나딕 연산을 그대로 제공하므로, 직접 만든 variant 기반 Result보다 먼저 검토할 만합니다.
패턴 4: 체이닝과 C++23 모나딕 연산
#include <optional>
#include <iostream>
#include <string>
std::optional<std::string> getName(int id) {
if (id == 1) return "Alice";
return std::nullopt;
}
std::optional<int> getAge(const std::string& name) {
if (name == "Alice") return 25;
return std::nullopt;
}
int main() {
auto name = getName(1);
if (name) {
auto age = getAge(*name);
if (age) {
std::cout << *name << " is " << *age << "\n";
}
}
}
C++17에서는 optional을 반환하는 함수를 이어 붙일 때마다 if가 한 단계씩 깊어집니다. C++23은 optional에 세 가지 모나딕 연산을 추가해 이 중첩을 없앴습니다.
| 연산 | 값이 있을 때 | 값이 없을 때 | 넘기는 함수의 반환 타입 |
|---|---|---|---|
and_then(f) | f(값)의 결과(optional)를 그대로 반환 | 빈 optional | std::optional<U> |
transform(f) | f(값)을 optional로 감싸 반환 | 빈 optional | 일반 값 U |
or_else(f) | 자기 자신을 반환 | f()의 결과를 반환 | std::optional<T> (같은 타입) |
// g++ -std=c++23 -o optional_monadic optional_monadic.cpp && ./optional_monadic
#include <optional>
#include <iostream>
#include <string>
std::optional<std::string> getName(int id) {
if (id == 1) return "Alice";
return std::nullopt;
}
std::optional<int> getAge(const std::string& name) {
if (name == "Alice") return 25;
return std::nullopt;
}
int main() {
for (int id : {1, 2}) {
std::string label = getName(id)
.and_then(getAge) // optional<int>
.transform([](int age) { return std::to_string(age) + "세"; }) // optional<string>
.or_else([] { return std::optional<std::string>{"알 수 없음"}; })
.value(); // or_else 뒤라 항상 값이 있음
std::cout << "id " << id << ": " << label << "\n";
}
}
실행 결과: id 1: 25세, id 2: 알 수 없음 두 줄이 출력됩니다.
and_then과 transform을 헷갈려 transform에 optional을 반환하는 함수를 넘기면 std::optional<std::optional<int>>가 만들어집니다(앞에서 본 optional 중첩 문제). 넘기는 함수가 실패할 수 있으면 and_then, 항상 성공하는 변환이면 transform 으로 구분하면 됩니다. 또 or_else에 넘기는 함수는 대체 값이 아니라 같은 타입의 optional을 반환해야 합니다. 단순 기본값이면 체인 끝에서 value_or를 쓰는 편이 짧습니다. 체인이 길어지면 중간 단계에서 왜 비었는지 알 수 없다는 단점도 있으니, 실패 이유를 남겨야 하는 흐름이라면 std::expected(패턴 3)의 같은 연산을 쓰는 것이 낫습니다.
패턴 5: 캐시
#include <any>
#include <optional>
#include <map>
#include <string>
class Cache {
std::map<std::string, std::any> data;
public:
template <typename T>
void put(const std::string& key, const T& value) {
data[key] = value;
}
template <typename T>
std::optional<T> get(const std::string& key) const {
auto it = data.find(key);
if (it == data.end()) {
return std::nullopt;
}
try {
return std::any_cast<T>(it->second);
} catch (...) {
return std::nullopt;
}
}
};
API 응답 래퍼, 설정 검증, DB NULL 처리
패턴 1: API 응답 래퍼
#include <optional>
#include <string>
// REST API 응답: 성공 시 데이터, 실패 시 에러 메시지
template <typename T>
struct ApiResponse {
std::optional<T> data;
std::optional<std::string> error;
static ApiResponse success(T value) {
return {std::move(value), std::nullopt};
}
static ApiResponse failure(std::string msg) {
return {std::nullopt, std::move(msg)};
}
bool ok() const { return data.has_value(); }
};
패턴 2: 설정 검증 (variant + visit)
// 설정값 검증: 타입별로 범위 체크
#include <variant>
#include <string>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
using ConfigValue = std::variant<int, double, std::string>;
bool validate(const ConfigValue& v) {
return std::visit(overloaded{
[](int i) { return i >= 0 && i <= 65535; },
[](double d) { return d >= 0.0 && d <= 1.0; },
[](const std::string& s) { return !s.empty() && s.size() <= 256; }
}, v);
}
패턴 3: 플러그인 콜백 (any 최소화)
#include <variant>
#include <string>
#include <vector>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
// 플러그인: variant로 타입 제한해 any보다 안전하게
using PluginParam = std::variant<int, double, std::string, std::vector<int>>;
void invokePlugin(const std::string& name, const PluginParam& param) {
std::visit(overloaded{
[&](int v) { /* ... */ },
[&](double v) { /* ... */ },
[&](const std::string& v) { /* ... */ },
[&](const std::vector<int>& v) { /* ... */ }
}, param);
}
패턴 4: 데이터베이스 NULL 처리
#include <optional>
#include <string>
// DB 컬럼: NULL 가능
struct UserRow {
int id;
std::optional<std::string> name; // NULL 허용
std::optional<int> age; // NULL 허용
};
optional·variant·any 코드 리뷰 체크리스트
- optional:
value()호출 전has_value()또는if (opt)확인 - variant:
get<T>대신get_if<T>또는std::visit사용 - any: 핫 루프에서는 피하며, 타입이 정해지면 variant로 전환 검토
- 예외:
bad_optional_access,bad_variant_access,bad_any_cast처리 - 로깅: 값 없음/타입 불일치 시 명확한 에러 메시지
-
[[nodiscard]]: optional·Result를 반환하는 함수에 붙여, 호출부가 결과를 버리면 경고가 나게 하기
같이 보면 좋은 글
- C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
- C++ 참조
- C++ 현대적 다형성 설계: 상속 대신 합성·variant
- C++ std::optional vs 포인터
자주 묻는 질문 (FAQ)
Q. optional, variant, any 중 무엇을 골라야 하나요?
A. 값이 있거나 없거나 둘 중 하나면 std::optional, 미리 정해진 몇 가지 타입 중 하나를 담아야 하면 std::variant를 씁니다. variant는 std::visit으로 모든 타입을 처리했는지 컴파일 타임에 확인할 수 있어 에러 처리나 설정 값 표현에 적합합니다. std::any는 플러그인 속성처럼 타입을 미리 알 수 없을 때만 쓰는데, 잘못된 any_cast는 런타임에야 bad_any_cast로 드러나므로 사용 범위를 최소화하는 것이 좋습니다.