Featured image of post [Optimization(C++) 13] std::variant/optional/expected

[Optimization(C++) 13] std::variant/optional/expected

타입 안전 유니온과 옵셔널 타입인 std::variant, std::optional, std::expected의 성능 특성과 오버헤드를 분석합니다. 포인터·공용체 대비 비용과 예외 없이 실패를 표현하는 패턴의 성능을 다루며, 메모리 레이아웃과 visit/if-let 비용을 정리합니다.

std::variant·std::optional·std::expected는 타입 안전 유니온·“있음/없음”·성공/실패 전달을 위한 표준 타입입니다. 본 챕터에서는 이들의 성능 특성과 오버헤드를 분석하고, 포인터·공용체·예외 대비 비용과 선택 기준을 다룹니다.

이 장을 읽기 전에

완전한 초보자? 이 장은 11장: 예외 처리 심화에서 본 “예외 vs 에러 코드” 트레이드오프와 03장: 추상화 비용 분석을 전제로 합니다. 유니온·“값이 있거나 없거나"가 무엇인지 정도만 알면 충분합니다.

이 장의 깊이: 이 장은 중급~전문가를 포괄합니다. variant·optional·expected의 의미와 사용법부터 시작해, 전문가 구간에서는 sizeof 레이아웃·접근 비용을 따지고 포인터·공용체·예외 대비 선택 기준을 다룹니다. 다루지 않는 것: 예외 메커니즘 내부(11장)와 타입 소거 일반론(19장)입니다.

당신의 수준에 맞는 경로

수준읽을 부분핵심 목표
초보자“std::variant” ~ “std::optional”세 타입의 의미·사용법 이해
중급자“std::expected (C++23)” ~ “sizeof 레이아웃 노트”레이아웃·접근 비용 파악
전문가“선택 가이드” ~ “비판적 시각”포인터·예외 대비 선택 판단

variant/optional/expected 표준화 (역사·배경)

std::variantstd::optional은 C++17에서 표준에 추가되었습니다. variant는 타입 안전 유니온, optional은 “값이 있거나 없거나"를 표현합니다. std::expected는 C++23에서 도입되어 성공 타입 T 또는 실패 타입 E를 담고, 예외 없이 에러 전달을 표준화합니다. 이들로 RTTI·포인터·예외 대신 타입 안전하고 비용이 예측 가능한 패턴을 쓸 수 있어, Low-latency에서 선택지가 됩니다.

std::variant

std::variant타입 안전 유니온입니다. 여러 타입 중 하나만 활성화되어 있고, **인덱스(타입 식별)**와 정렬된 저장소(가장 큰 타입 크기·정렬에 맞춤)로 표현됩니다. std::visit로 값을 방문할 때는 활성 타입에 따라 디스패치가 일어나며, 컴파일러가 visit 대상을 인라인할 수 있으면 switch/테이블 점프 수준으로 최적화됩니다. 포인터 하나로 간접 접근하는 방식보다 값을 직접 담아 캐시에 유리할 수 있습니다.

아래 예시는 std::holds_alternative로 활성 타입을 검사하고, std::visit로 활성 값을 한 번에 방문합니다. 대입으로 활성 타입을 전환하면 index()가 바뀌는 것을 확인할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <variant>
#include <string>
#include <iostream>

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

    if (std::holds_alternative<std::string>(v))
        std::cout << "string: " << std::get<std::string>(v) << '\n';

    std::visit([](auto&& x) { std::cout << "visit: " << x << '\n'; }, v);

    v = 42;                                       // 활성 타입 전환
    std::cout << "index=" << v.index() << '\n';   // 0 (int)
}

포인터·수동 union 대비: 포인터는 힙 할당과 간접 접근 비용이 있고, 수동 union은 타입 안전성이 없습니다. variant는 스택/멤버에 직접 담고 visit로 타입을 검사하므로 할당 없이 타입 안전하게 “여러 타입 중 하나"를 표현할 수 있습니다.

std::optional

**std::optional<T>**는 “값이 있거나 없거나"를 표현합니다. 내부적으로는 T값 존재 여부(bool 판별자)를 갖습니다. 크기는 “sizeof(T) + 판별자 1바이트 + 정렬 패딩“으로 결정되며, 정확한 값은 구현 정의입니다. 예를 들어 T의 정렬이 1인 경우(optional<char>)는 2바이트가 흔하지만, optional<int>는 판별자 1바이트가 4바이트 경계로 패딩되어 보통 8바이트이고, optional<double>은 보통 16바이트입니다. 즉 “sizeof(T)+1"로 단정하지 말고 정렬 패딩을 함께 봐야 합니다. 값에 접근할 때는 “값이 있는지” 한 번의 분기가 있고, 있으면 저장된 객체를 반환합니다. 대체로 오버헤드는 “한 번의 분기 + 정렬용 패딩” 수준입니다.

value_or는 값이 없을 때 기본값을 반환해 분기를 간결하게 합니다. 아래 함수는 빈 문자열이면 std::nullopt를 돌려주고, 호출 측은 value_or(8080)로 기본 포트를 사용합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <optional>
#include <string>
#include <iostream>

std::optional<int> parsePort(const std::string& s) {
    if (s.empty()) return std::nullopt;   // 값 없음
    return std::stoi(s);
}

int main() {
    int port = parsePort("").value_or(8080);  // 없으면 기본값
    std::cout << "port=" << port << '\n';      // 8080
}

null 포인터·스마트 포인터와 비교하면, optional은 값을 직접 보관하므로 할당이 없고, null 체크 한 번의 분기만 있습니다. 포인터는 간접 접근과 (힙 사용 시) 할당 비용이 있으므로, “값이 없을 수 있는 작은 객체"에는 optional이 성능·의도 표현 모두 유리한 경우가 많습니다.

std::expected (C++23)

**std::expected<T, E>**는 “성공하면 T, 실패하면 E"를 담는 타입입니다. 에러 전파를 예외 없이 표현할 수 있어 예외 대안으로 쓰입니다. 에러 전달 비용은 E의 복사/이동 비용과 “성공/실패” 분기 한 번입니다. 인라인 가능하면 호출 오버헤드는 작고, 실패 경로가 예외보다 예측 가능한 비용을 가집니다.

아래 예시는 실패 시 std::unexpected로 에러 코드를 돌려줍니다. 호출 측은 if (r)로 성공/실패를 분기하고, 실패 경로에서 r.error()로 이유를 읽습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <expected>   // C++23
#include <string>
#include <cctype>
#include <iostream>

enum class Err { Empty, NotNumber };

std::expected<int, Err> parse(const std::string& s) {
    if (s.empty()) return std::unexpected(Err::Empty);
    for (unsigned char c : s)
        if (!std::isdigit(c)) return std::unexpected(Err::NotNumber);
    return std::stoi(s);
}

int main() {
    auto r = parse("abc");
    if (r) std::cout << "ok: " << *r << '\n';
    else   std::cout << "err code=" << static_cast<int>(r.error()) << '\n';
}

sizeof 레이아웃 노트

레이아웃은 직접 출력해 보면 가장 확실합니다. optional은 판별자 1바이트가 정렬 경계로 패딩되고, variant는 “인덱스 + 가장 큰 멤버 + 정렬 패딩"이 됩니다. 아래 값은 흔한 64비트 구현 기준 예시이며 정확한 값은 구현 정의입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <optional>
#include <variant>
#include <string>
#include <cstdio>

int main() {
    std::printf("opt<char>=%zu opt<int>=%zu opt<double>=%zu\n",
        sizeof(std::optional<char>),    // 흔히 2
        sizeof(std::optional<int>),     // 흔히 8 (패딩)
        sizeof(std::optional<double>)); // 흔히 16
    std::printf("variant=%zu\n",
        sizeof(std::variant<int, double, std::string>)); // string 크기 + 인덱스
}

선택 가이드

  • 단일 “있음/없음”: 값이 있거나 비어 있거나만 구분하면 optional.
  • 여러 타입 중 하나: 고정된 타입 집합 중 하나만 활성화되면 variantvisit.
  • 실패 전파: 반환값으로 성공/실패를 전달하고 예외를 피하고 싶으면 expected. 예외는 정상 경로 비용이 거의 없지만 실패 경로가 비싸므로, 실패가 자주 나오는 경로에는 expected가 유리할 수 있습니다.
flowchart TD
    Start["반환/저장할 값의 형태?"] --> Q1{"여러 타입 중 하나?"}
    Q1 -->|"예"| Variant["std::variant + std::visit"]
    Q1 -->|"아니오"| Q2{"실패를 표현?"}
    Q2 -->|"있음/없음만"| Optional["std::optional"]
    Q2 -->|"실패 이유 E 전달"| Q3{"실패가 빈번?"}
    Q3 -->|"예"| Expected["std::expected<T,E>"]
    Q3 -->|"드묾"| Except["예외 고려"]

비판적 시각: 한계와 트레이드오프

  • variant는 타입 집합이 컴파일 타임에 고정되어야 합니다. 런타임에 타입이 열려 있으면 RTTI·상속이 나을 수 있습니다.
  • expected는 에러 타입 E의 복사/이동 비용이 있으므로, E를 가볍게 두는 것이 좋습니다.

핵심 요약

항목비용·이점활용 기준
variant인덱스+저장소, visit 디스패치, 할당 없음고정 타입 집합 중 하나
optional값+존재 플래그, 할당 없음, 분기 한 번있음/없음
expected성공 T/실패 E, 실패 경로 예측 가능실패 빈번 시, E 가볍게

자주 묻는 질문 (FAQ)

Q: variant vs 포인터·수동 union?
A: variant는 타입 안전 유니온으로 인덱스+저장소 레이아웃을 가집니다. visit 디스패치 비용이 있으나 할당 없이 타입 안전성·표현력을 얻습니다. 핫 경로에서는 마이크로벤치마크로 비교합니다.

Q: optional은 언제 쓰나요?
A: “값이 있거나 없거나"만 구분할 때입니다. 값을 직접 보관하므로 할당이 없고, null 포인터 대비 성능·의도 표현 모두 유리한 경우가 많습니다.

Q: expected vs 예외?
A: 실패가 예외적이면 예외, 실패가 빈번한 경로면 expected로 실패 비용을 예측 가능하게 합니다(챕터 11와 연계).

적용 체크리스트

  • “있음/없음"은 optional, “여러 타입 중 하나"는 variant로 선택했는가?
  • 실패가 자주 나면 expected를 검토했는가?
  • 핫 경로에서 할당·분기·visit 디스패치를 벤치마크했는가?
  • expected의 에러 타입 E를 가볍게 두었는가?

다음 장에서는

이전 장: 인라이닝 유도 기법 (챕터 12)

std::span과 뷰 패턴을 다룹니다. 안전한 연속 구간 뷰인 span·string_view 활용과 성능 이점, non-owning 패턴을 정리합니다. → std::span과 뷰 패턴 (챕터 14)