C++23에서 실제로 쓸 기능: std::expected, mdspan, deducing this, auto(x)
들어가며: C++20 다음은? — C++23으로 한 발 더
최신 표준을 선점하는 글
22~25번 시리즈에서 C++20을 다뤘으니, 이번에는 C++23에서 실무 코드에 바로 영향을 주는 기능만 골라 봅니다. 중심은 std::expected(예외 대신 반환값으로 실패를 표현하는 타입), std::mdspan(연속 메모리를 2차원·3차원 배열처럼 보는 뷰), 람다 관련 개선, deducing this이고, 짧게 auto(x)와 [[assume]]도 다룹니다. 컴파일러가 지원하는 환경이라면 바로 시험해 볼 수 있는 예제로 구성했습니다.
이 글에서 다루는 것:
- std::expected: “값 또는 에러”를 담는 타입, 예외의 대안
- std::mdspan: 다차원 배열을 뷰로 다루기
- 람다 개선: static operator(), 람다 속성 (그리고 C++20에서 먼저 들어온 팩 init-capture)
- deducing this: 멤버 함수에서 호출 객체의 실제 타입 추론
- auto(x) decay-copy와
[[assume]]속성
예외 금지 환경, 1D로 펼친 2D 배열, CRTP clone: C++23이 필요한 순간
시나리오 1: 게임 엔진에서 예외를 쓸 수 없는 환경
문제: 게임 루프는 60fps로 돌아가며, 예외가 발생하면 스택 언와인딩 비용과 예측 불가능한 지연이 발생합니다. 설정 파일 파싱, 리소스 로딩처럼 “실패할 수 있는 정상 경로”가 많은데, 예외로 처리하면 프로파일러에 스파이크가 찍힙니다.
해결: std::expected로 “성공 시 값, 실패 시 에러”를 반환값으로 명시적으로 전달하면 예외 비용 없이 에러 경로를 처리할 수 있습니다.
시나리오 2: 수치 연산에서 2D/3D 배열을 1D로 다루는 번거로움
문제: std::vector<double>에 64×64 행렬을 저장할 때, data[i * cols + j]처럼 인덱스를 직접 계산해야 합니다. 행/열 stride가 다른 외부 라이브러리(OpenCV, cuBLAS)와 연동할 때 레이아웃 변환이 반복됩니다.
해결: std::mdspan으로 연속 메모리를 2D/3D 뷰로 해석하면 mat[i, j]처럼 직관적으로 접근하며, 레이아웃 정책만 바꿔서 외부 API와 맞출 수 있습니다.
시나리오 3: 가변 인자로 함수를 지연 호출할 때의 보일러플레이트
문제: std::tuple과 std::apply를 써서 람다로 인자 팩을 캡처하려면 코드가 길어지고 가독성이 떨어집니다.
해결: 팩 init-capture [...args = std::move(args)]로 한 줄에 표현할 수 있습니다. 이 문법은 C++20에서 들어왔으므로 C++23으로 올리지 않아도 쓸 수 있습니다.
시나리오 4: CRTP로 파생 타입을 반환하는 clone()의 반복
문제: CRTP(Curiously Recurring Template Pattern)를 쓰면 Derived 타입을 템플릿 인자로 넘겨야 하며, 보일러플레이트가 많아집니다.
해결: deducing this로 this Self&& self를 쓰면 컴파일러가 호출 객체의 실제 타입을 자동 추론해, CRTP 없이 clone()이 실제 타입을 반환하도록 구현할 수 있습니다.
std::expected: 값 또는 에러
에러 처리의 새로운 패러다임
예외는 정말 예외적인 상황에 쓰기 좋지만, 파싱 실패나 파일 없음처럼 실패할 수 있는 정상 경로는 반환값으로 표현하고 싶을 때가 많습니다. std::expected<T, E>는 성공하면 T, 실패하면 E를 담는 타입입니다. std::optional이 값의 있음과 없음만 표현한다면, expected는 값이 없을 때 그 이유(에러 정보)까지 담습니다.
flowchart TD
subgraph expected["std::expected<T, E>"]
A[함수 호출] --> B{성공?}
B -->|예| C[값 T 반환]
B -->|아니오| D[에러 E 반환]
C --> E["result.value()"]
D --> F["result.error()"]
end
기본 사용
에러는 return std::unexpected("...")로 반환합니다. 호출부에서는 if (result)로 성공 여부를 확인한 뒤, 성공이면 *result나 result.value()로 값을, 실패면 result.error()로 에러를 꺼냅니다. 예외 없이도 에러 경로가 코드에 명시적으로 드러나고, optional과 달리 왜 실패했는지까지 전달할 수 있습니다.
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(const std::string& s) {
if (s.empty()) return std::unexpected("empty string");
// 파싱 시도...
if (/* 실패 */) return std::unexpected("invalid number");
return 42;
}
int main() {
auto result = parse_int("123");
if (result) {
int value = *result; // 42
} else {
std::string err = result.error(); // 에러 메시지
}
}
value(): 값이 있으면 반환하고, 에러 상태면std::bad_expected_access<E>예외를 던집니다.operator*: 값을 확인 없이 꺼냅니다. 에러 상태에서 호출하면 미정의 동작입니다.error(): 에러 상태일 때E를 반환합니다. 값이 있는 상태에서 호출하면 미정의 동작입니다.operator bool()/has_value(): 값이 있으면 true입니다.value_or(v): 에러 상태면 기본값v를 돌려줍니다.
C++23 모나딕 연산: and_then, or_else, transform
std::expected에는 모나딕 연산이 함께 들어 있어서, 단계마다 if로 에러를 검사하지 않고 연산을 이어 붙일 수 있습니다. 같은 C++23에서 std::optional에도 and_then, or_else, transform이 추가되었습니다.
#include <expected>
#include <string>
#include <iostream>
#include <cmath>
std::expected<int, std::string> parse_int(const std::string& s) {
if (s.empty()) return std::unexpected("empty");
try {
return std::stoi(s);
} catch (...) {
return std::unexpected("invalid: " + s);
}
}
std::expected<double, std::string> sqrt_safe(int x) {
if (x < 0) return std::unexpected("negative");
return std::sqrt(static_cast<double>(x));
}
int main() {
// and_then: 성공 시 다음 연산, 실패 시 에러 전파
auto result = parse_int("16")
.and_then(sqrt_safe)
.transform([](double d) { return d * 2; });
if (result) {
std::cout << *result << "\n"; // 8.0
} else {
std::cerr << result.error() << "\n";
}
// or_else: 실패 시 복구 시도
auto recovered = parse_int("abc")
.or_else([](const std::string&) {
return std::expected<int, std::string>(0); // 기본값
});
}
| 연산 | 설명 |
|---|---|
and_then(f) | 값이 있으면 f(값)을 호출합니다. f는 expected를 반환해야 하며, 에러 상태면 에러를 그대로 전파합니다 |
or_else(f) | 에러가 있으면 f(에러)를 호출합니다. f는 expected를 반환해야 하며, 값이 있으면 그대로 둡니다 |
transform(f) | 값이 있으면 f(값)의 결과를 새 값으로 감쌉니다 |
transform_error(f) | 에러가 있으면 f(에러)의 결과를 새 에러로 감쌉니다 |
왜 쓰는가
예외 비용을 피하면서도 에러 정보를 호출자에게 넘기고 싶을 때 씁니다. 함수 시그니처만 봐도 “성공하면 T, 실패하면 E”라는 계약이 드러나므로, 문서를 따로 찾아보지 않아도 호출부가 실패를 처리해야 한다는 사실을 알 수 있습니다. 반대로 예외는 시그니처에 드러나지 않아, 어떤 함수가 무엇을 던지는지 코드를 따라가 봐야 알 수 있습니다.
expected vs optional vs 예외 선택 가이드
| 상황 | 추천 방법 |
|---|---|
| 실패가 예외적이고 드문 경우 | 예외 |
| 실패가 정상 흐름이고 에러 정보가 필요 | std::expected |
| 실패가 정상이지만 에러 정보는 불필요 | std::optional |
| 실패가 잦고 지연 편차가 중요한 경로 | std::expected |
실무 예시: 파일 파싱은 실패가 흔하므로 expected<Data, ParseError>, 설정 값 조회는 없을 수 있으므로 optional<string>, 메모리 할당 실패는 드물고 복구 불가능하므로 예외를 던지는 것이 적합합니다.
실전 코드: expected로 에러 체이닝
#include <expected>
#include <string>
#include <fstream>
enum class FileError { NotFound, PermissionDenied, InvalidFormat };
std::expected<std::string, FileError> readFile(const std::string& path) {
std::ifstream file(path);
if (!file) return std::unexpected(FileError::NotFound);
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
return content;
}
std::expected<int, FileError> parseConfig(const std::string& path) {
auto content = readFile(path);
if (!content) return std::unexpected(content.error()); // 에러 전파
// 파싱 로직...
return 42;
}
int main() {
auto result = parseConfig("config.txt");
if (result) {
std::cout << "Value: " << *result << "\n";
} else {
switch (result.error()) {
case FileError::NotFound:
std::cerr << "File not found\n"; break;
case FileError::PermissionDenied:
std::cerr << "Permission denied\n"; break;
case FileError::InvalidFormat:
std::cerr << "Invalid format\n"; break;
}
}
}
이 패턴은 예외 없이도 에러를 명시적으로 전파하고 처리할 수 있어, -fno-exceptions로 빌드하는 임베디드나 게임 코드에서 유용합니다. 다만 switch에서 enum 값을 하나 빠뜨려도 에러가 조용히 무시되기 쉬우므로, -Wswitch(GCC/Clang에서는 -Wall에 포함) 경고를 켜 두면 새 에러 종류를 추가했을 때 처리하지 않은 곳을 컴파일러가 알려 줍니다. 한 가지 더, 이 예제에서 readFile은 파일을 열지 못하면 권한 문제여도 NotFound를 반환합니다. std::ifstream은 실패 이유를 구분해 주지 않기 때문에, 이유를 나눠야 한다면 std::filesystem으로 먼저 확인하거나 errno를 봐야 합니다.
관련 글: optional·variant(#12-3), 에러 처리(#8-1)
std::mdspan: 다차원 배열 뷰
다차원 배열을 뷰로
std::mdspan은 이미 있는 연속 메모리를 다차원 뷰로 보게 해 줍니다. 메모리를 소유하지 않고, 인덱스를 메모리 위치로 바꾸는 방법(레이아웃)만 정하는 뷰 타입입니다. std::span이 1차원 뷰라면, mdspan은 템플릿 인자로 차원별 크기(extents)와 레이아웃 정책을 받아 2차원, 3차원 이상을 다룹니다.
flowchart LR
subgraph mem[연속 메모리]
M[data: 0,1,2,3,4,5,...]
end
subgraph view[mdspan 뷰 2x3]
V["(0,0)=0 (0,1)=1 (0,2)=2<br/>(1,0)=3 (1,1)=4 (1,2)=5"]
end
mem --> view
기본 사용
data.data(), rows, cols를 넘기면 mat[i, j]로 (i, j) 원소에 접근할 수 있으며, 메모리는 복사하지 않습니다. 쉼표로 구분한 여러 인덱스를 받는 operator[]는 C++23에서 새로 허용된 문법(P2128)이고, 표준 mdspan은 이 형태만 제공합니다. 표준화 이전의 참조 구현(Kokkos mdspan)이나 오래된 예제에는 mat(i, j) 형태가 보이는데, 표준 std::mdspan에는 operator()가 없어서 libstdc++나 MSVC STL에서는 컴파일되지 않습니다.
#include <mdspan>
#include <vector>
#include <iostream>
int main() {
const size_t rows = 3, cols = 4;
std::vector<double> data(rows * cols);
for (size_t i = 0; i < data.size(); ++i) data[i] = static_cast<double>(i);
// 2차원 뷰: 행 rows, 열 cols (layout_right: 행 우선)
std::mdspan mat(data.data(), rows, cols);
// mat[i, j]로 (i,j) 원소 접근
for (size_t i = 0; i < rows; ++i) {
for (size_t j = 0; j < cols; ++j) {
std::cout << mat[i, j] << " ";
}
std::cout << "\n";
}
}
레이아웃과 extent
- layout_right: C 스타일, 마지막 차원이 연속 (기본값)
- layout_left: Fortran 스타일, 첫 차원이 연속
- extents:
std::extents<size_t, 3, 4>처럼 컴파일 타임 고정 크기, 또는std::dextents<size_t, 2>처럼 동적 크기
#include <mdspan>
#include <vector>
// 동적 2차원 (행·열 모두 런타임)
using Matrix2D = std::mdspan<double, std::dextents<size_t, 2>>;
// 3x4 고정 크기
using FixedMatrix = std::mdspan<double, std::extents<size_t, 3, 4>>;
int main() {
std::vector<double> buf(12);
Matrix2D mat(buf.data(), 3, 4);
FixedMatrix fixed(buf.data()); // 크기 이미 지정됨
}
실전 예제: 이미지 버퍼를 2D로 처리
#include <mdspan>
#include <vector>
#include <cstdint>
// RGBA 이미지: height x width x 4
void process_image(std::vector<uint8_t>& pixels, size_t width, size_t height) {
// 3차원 뷰: [height][width][4]
std::mdspan img(pixels.data(), height, width, size_t(4));
for (size_t y = 0; y < height; ++y) {
for (size_t x = 0; x < width; ++x) {
uint8_t& r = img[y, x, 0];
uint8_t& g = img[y, x, 1];
uint8_t& b = img[y, x, 2];
uint8_t& a = img[y, x, 3];
// 그레이스케일 변환
uint8_t gray = static_cast<uint8_t>(0.299*r + 0.587*g + 0.114*b);
r = g = b = gray;
}
}
}
강화된 람다
패킹 선언 (Lambda pack capture)
init-capture에서 팩을 확장하는 문법 [...xs = std::move(args)]은 사실 C++20(P0780)에서 들어온 기능입니다. C++23 기능을 소개하는 글에서도 자주 C++23으로 잘못 소개되는데, C++20 모드에서 바로 쓸 수 있습니다. 줄임표(...)는 식별자 앞에 옵니다.
#include <utility>
#include <iostream>
template <class F, class... Args>
auto delay_invoke(F f, Args... args) {
// C++20: 팩 init-capture
return [f = std::move(f), ...args = std::move(args)]() -> decltype(auto) {
return std::invoke(f, args...);
};
}
int add(int a, int b) { return a + b; }
int main() {
auto delayed = delay_invoke(add, 3, 5);
std::cout << delayed() << "\n"; // 8
}
C++17까지는 std::tuple과 std::apply를 써서 구현해야 했습니다.
// C++17 방식 (번거로움)
template <class F, class... Args>
auto delay_invoke_old(F f, Args... args) {
auto tup = std::make_tuple(std::move(args)...);
return [f = std::move(f), tup = std::move(tup)]() -> decltype(auto) {
return std::apply(f, tup);
};
}
정적 operator()
C++23에서는 캡처가 없는 람다에 static을 붙여 호출 연산자를 static 멤버 함수로 만들 수 있습니다(P1169). 호출할 때 숨은 this 포인터를 넘길 필요가 없어서, 람다를 함수 객체로 여러 계층에 넘기는 코드에서 불필요한 인자 전달이 사라집니다.
// C++23: static 람다 (캡처 없음) — static은 파라미터 뒤, 본문 앞
auto identity = [](int x) static { return x; };
static_assert(identity(42) == 42);
// 캡처가 있으면 static 불가
// auto bad = [x](int i) static { return i + x; }; // 컴파일 에러
속성
C++23(P2173)부터 람다의 호출 연산자에 [[nodiscard]] 같은 속성을 붙일 수 있습니다. 위치는 캡처 목록 바로 뒤, 파라미터 목록 앞입니다. 파라미터 목록 뒤에 쓰는 속성은 연산자가 아니라 함수 타입에 붙으므로 의도한 효과가 없습니다.
auto get_value = [] [[nodiscard]] () { return 42; };
// get_value(); // 경고: 반환값 무시
int x = get_value(); // OK
deducing this
멤버 함수에서 “자기 타입” 자동 추론
deducing this(P0847)는 멤버 함수의 첫 번째 파라미터로 객체 자신을 명시적으로 받게 하는 기능입니다. 이 파라미터를 explicit object parameter라고 부르고, 타입을 템플릿으로 두면 호출에 쓰인 객체의 타입과 값 범주가 추론됩니다.
this Self&& self에서 Self는 컴파일러가 호출 표현식의 정적 타입으로 채웁니다. Derived d; d.f();라면 Self는 Derived&가 되므로, CRTP처럼 파생 타입을 템플릿 인자로 넘기지 않아도 멤버 함수 안에서 파생 타입을 쓸 수 있습니다. 이 함수 안에서는 this를 쓸 수 없고, 멤버에 접근할 때도 self.value처럼 반드시 self를 거쳐야 합니다.
struct Base {
template <typename Self>
void f(this Self&& self) {
// Self는 호출 표현식의 정적 타입 (여기서는 Derived&)
}
};
struct Derived : Base {};
Derived d;
d.f(); // Self = Derived&
clone() 패턴: CRTP 대체
#include <memory>
struct Base {
virtual ~Base() = default;
template <typename Self>
auto clone(this Self&& self) {
return std::make_unique<std::remove_cvref_t<Self>>(
static_cast<std::remove_cvref_t<Self> const&>(self)
);
}
};
struct Derived : Base {
int value{};
};
int main() {
auto d = std::make_unique<Derived>();
d->value = 42;
auto copy = d->clone(); // std::unique_ptr<Derived> 반환 (CRTP 불필요)
}
이 clone에는 주의할 함정이 있습니다. Self는 정적 타입으로 추론되므로, Base* p = new Derived;에서 p->clone()을 호출하면 Self는 Base&가 되어 Derived가 아니라 Base를 복사합니다(슬라이싱). 템플릿 멤버 함수는 가상 함수가 될 수 없기 때문입니다. 기반 클래스 포인터로 다루는 계층 구조에서 다형적 복사가 필요하다면 여전히 virtual std::unique_ptr<Base> clone() const가 필요합니다. deducing this가 대체하는 것은 CRTP의 정적 다형성이지 가상 함수가 아닙니다.
왜 쓰는가
CRTP 없이 파생 타입에 기반한 로직을 멤버 함수 하나로 표현할 수 있어 코드가 짧아지고 의도가 분명해집니다. 실제로 더 자주 이득을 보는 곳은 const/비const, lvalue/rvalue 오버로드를 하나로 합치는 경우입니다. 예를 들어 value()의 &, const&, &&, const&& 네 가지 버전을 template <class Self> auto&& value(this Self&& self) { return std::forward<Self>(self).value_; } 하나로 줄일 수 있습니다.
auto(x)와 [[assume]]
auto(x): 명시적 decay-copy
auto(x)와 auto{x}는 x를 복사한 prvalue를 만듭니다(P0849). 템플릿에서 typename std::decay_t<decltype(x)>(x)처럼 길게 쓰던 것을 줄여 주고, 무엇보다 “이 값의 사본이 필요하다”는 의도를 드러냅니다. 대표적인 예는 컨테이너의 원소를 참조로 받는 함수에 같은 컨테이너의 원소를 넘길 때입니다.
#include <vector>
void remove_all_like_first(std::vector<int>& v) {
// ❌ std::erase는 value를 const T&로 받음. v.front()를 참조로 넘기면
// erase 도중 그 위치의 원소가 덮어써져 비교 기준이 바뀔 수 있음
// std::erase(v, v.front());
// ✅ 사본을 넘김
std::erase(v, auto(v.front()));
}
[[assume(expr)]]
[[assume(expr)]](P1774)는 그 지점에서 expr이 참이라고 컴파일러에게 알려 주는 속성입니다. 식은 실행되지 않고 최적화 힌트로만 쓰입니다.
int div32(int x) {
[[assume(x >= 0)]];
return x / 32; // 음수 처리 없이 시프트 한 번으로 컴파일될 수 있음
}
가정이 실제로 거짓이면 미정의 동작이므로, assert처럼 “검사”의 의미로 쓰면 안 됩니다. 디버그 빌드에서는 assert로 검증하고 릴리스 빌드에서만 [[assume]]을 쓰는 매크로로 감싸는 방식이 흔합니다.
bad_expected_access, mdspan 범위 초과, 뷰 수명 같은 에러
std::expected 관련
에러 1: value() 호출 시 bad_expected_access
원인: 에러가 담긴 expected에서 value()를 호출하면 예외가 발생합니다.
// ❌ 잘못된 사용
auto result = parse_int("");
int x = result.value(); // std::bad_expected_access 발생!
해결:
// ✅ 올바른 사용: 먼저 성공 여부 확인
if (result) {
int x = result.value();
} else {
handle_error(result.error());
}
에러 2: and_then에 void 반환 함수 전달
원인: and_then에 전달하는 함수는 std::expected를 반환해야 합니다.
// ❌ 잘못된 사용
result.and_then([](int x) { std::cout << x; }); // void 반환, 컴파일 에러
해결:
// ✅ transform 사용 (값 변환만 할 때)
result.transform([](int x) {
std::cout << x;
return x;
});
// ✅ and_then은 expected 반환 함수에 사용
result.and_then([](int x) -> std::expected<int, Error> {
if (x < 0) return std::unexpected(Error::Negative);
return x;
});
std::mdspan 관련
에러 3: 범위 초과 접근
원인: mdspan은 기본적으로 경계 검사를 하지 않습니다. 잘못된 인덱스로 접근하면 미정의 동작입니다.
// ❌ 위험한 사용
std::mdspan mat(data.data(), 3, 4);
double x = mat[5, 5]; // 미정의 동작!
해결:
// ✅ extent로 직접 검증 (accessor는 인덱스를 보지 못하므로
// 경계 검사를 accessor로 구현할 수는 없음)
if (i < mat.extent(0) && j < mat.extent(1)) {
double x = mat[i, j];
}
에러 4: 뷰 수명 문제
원인: mdspan은 메모리를 소유하지 않습니다. 원본 버퍼가 파괴된 뒤 뷰를 사용하면 dangling reference입니다.
// ❌ 잘못된 사용
std::mdspan<double, std::dextents<size_t, 2>> get_view() {
std::vector<double> data(100);
return std::mdspan(data.data(), 10, 10); // data가 파괴되면 dangling!
}
해결: 버퍼를 소유하는 타입을 따로 두고, 뷰는 필요할 때마다 그 버퍼에서 만듭니다. 버퍼와 뷰를 pair로 함께 반환하는 방법은 위험합니다. 반환 과정에서 vector가 복사되거나 이동되면 뷰는 여전히 옛 버퍼를 가리키기 때문입니다.
// ✅ 버퍼는 소유 타입이 가지고, 뷰는 요청할 때 생성
struct Matrix {
std::vector<double> data;
size_t rows, cols;
auto view() { return std::mdspan(data.data(), rows, cols); }
};
Matrix make_matrix() { return {std::vector<double>(100), 10, 10}; }
람다 패킹 캡처 관련
에러 5: ellipsis 위치 오류
원인: init-capture 팩에서 줄임표는 식별자 앞에 와야 합니다. 일반 팩 확장(args...)과 순서가 반대라서 자주 헷갈립니다.
// ❌ 잘못된 사용: ...이 식별자 뒤에 오면 문법 오류
// [args... = std::move(args)] // 컴파일 에러
// ✅ C++20 이후 올바른 문법: ...이 식별자 앞
[...args = std::move(args)]
deducing this 관련
에러 6: 캡처 람다에서 deducing this와 무관한 타입 사용
원인: 캡처가 있는 람다에서 this std::any처럼 람다 타입과 무관한 explicit object parameter를 쓰면 컴파일 에러가 납니다.
// ❌ 잘못된 사용 (캡처 람다 + 무관한 타입)
int x = 0;
auto f = [x](this std::any self) { }; // 컴파일 에러
해결: 캡처가 있는 람다에서는 explicit object parameter의 타입이 클로저 타입 자신이거나 그로부터 파생된 타입이어야 합니다. 보통은 [x](this auto&& self) { ... }처럼 auto로 두면 됩니다.
에러 7: mdspan에 nullptr 전달
원인: mdspan 생성 시 data가 nullptr이면, 이후 operator() 호출 시 미정의 동작이 발생합니다.
// ❌ 잘못된 사용
std::vector<double> empty;
std::mdspan mat(empty.data(), 0, 0); // empty가 비어 있으면 data()가 nullptr
mat[0, 0]; // 미정의 동작
해결: extent가 0인 뷰는 만들 수 있지만 어떤 인덱스로도 접근하면 안 됩니다. 접근하기 전에 mat.empty()나 extent를 확인하고, extent가 0이 아니라면 반드시 그 크기 이상의 유효한 버퍼를 넘겨야 합니다.
C++20/17 코드에서 C++23으로 옮기기
체크리스트
| 단계 | 작업 | 비고 |
|---|---|---|
| 1 | 컴파일러 버전 확인 | 기능별로 다름 (아래 참고) |
| 2 | 빌드 플래그 변경 | -std=c++23 또는 /std:c++latest |
| 3 | expected 마이그레이션 | optional + 에러 코드 → expected<T, E> |
| 4 | mdspan 도입 | 2D 인덱스 계산 [i*cols+j] → mat[i,j] |
| 5 | CRTP·오버로드 정리 | deducing this로 CRTP와 const/ref 오버로드 단순화 |
컴파일러 지원은 기능마다 다릅니다. 대략 std::expected는 GCC 12, Clang(libc++) 16, MSVC 2022 17.3부터, deducing this는 GCC 14, Clang 18, MSVC 2022 17.2부터, std::mdspan은 GCC 14, MSVC 2022 17.9부터 사용할 수 있습니다. 표준 라이브러리 기능은 컴파일러가 아니라 함께 쓰는 라이브러리(libstdc++/libc++/MSVC STL) 버전에 달려 있으므로, Clang으로 빌드하더라도 libstdc++를 쓰면 GCC 쪽 지원 시점을 따릅니다. 가장 확실한 방법은 __cpp_lib_expected, __cpp_lib_mdspan, __cpp_explicit_this_parameter 같은 기능 테스트 매크로로 확인하는 것입니다.
optional → expected 마이그레이션 예시
// C++20: optional + 별도 에러 로깅
std::optional<int> parse_old(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
log_error("parse failed");
return std::nullopt;
}
}
// C++23: expected로 에러 정보까지 전달
std::expected<int, std::string> parse_new(const std::string& s) {
try {
return std::stoi(s);
} catch (const std::exception& e) {
return std::unexpected(std::string("parse failed: ") + e.what());
}
}
2D 배열 → mdspan 마이그레이션 예시
// C++20: 수동 인덱스 계산
void process_old(std::vector<double>& data, size_t rows, size_t cols) {
for (size_t i = 0; i < rows; ++i) {
for (size_t j = 0; j < cols; ++j) {
data[i * cols + j] *= 2;
}
}
}
// C++23: mdspan으로 직관적 접근
void process_new(std::vector<double>& data, size_t rows, size_t cols) {
std::mdspan mat(data.data(), rows, cols);
for (size_t i = 0; i < rows; ++i) {
for (size_t j = 0; j < cols; ++j) {
mat[i, j] *= 2;
}
}
}
Result 별칭, expected 로깅, CRTP 대신 deducing this
패턴 1: Result 타입 별칭으로 API 일관성
// 프로젝트 전역 에러 타입
struct AppError {
int code;
std::string message;
};
template <typename T>
using Result = std::expected<T, AppError>;
Result<std::string> loadConfig(const std::string& path);
Result<Image> loadTexture(const std::string& path);
패턴 2: expected와 로깅 연동
template <typename T, typename E>
auto log_on_error(std::expected<T, E> r, std::string_view context) {
if (!r) {
spdlog::error("{}: {}", context, r.error());
}
return r;
}
auto result = log_on_error(parseConfig(path), "config load");
패턴 3: mdspan으로 다중 레이아웃 뷰
// 같은 메모리를 행 우선 vs 열 우선으로 해석
std::vector<double> buf(rows * cols);
std::mdspan<double, std::dextents<size_t, 2>, std::layout_right> row_major(
buf.data(), rows, cols);
std::mdspan<double, std::dextents<size_t, 2>, std::layout_left> col_major(
buf.data(), rows, cols);
// 외부 API 요구사항에 맞게 선택
패턴 4: deducing this로 CRTP 제거
// Before: CRTP
template <typename Derived>
struct BaseCRTP {
void doit() {
static_cast<Derived*>(this)->impl();
}
};
// After: deducing this
struct Base {
template <typename Self>
void doit(this Self&& self) {
std::forward<Self>(self).impl();
}
};
패턴 5: 모나딕 체이닝으로 파이프라인 구성
auto pipeline = read_file(path)
.and_then(parse_json)
.transform(parse_config)
.or_else([](const auto&) {
return std::expected<Config, Error>(default_config());
});
설정 로더 + 이미지 처리 통합 예제
아래 예제는 std::expected, std::mdspan, 팩 init-capture, deducing this를 한 프로그램에서 함께 사용하는 예입니다.
#include <algorithm>
#include <functional>
#include <expected>
#include <mdspan>
#include <vector>
#include <string>
#include <fstream>
#include <memory>
// 1. expected로 설정 로딩
enum class ConfigError { FileNotFound, ParseError };
struct Config {
size_t width = 640, height = 480;
};
std::expected<Config, ConfigError> load_config(const std::string& path) {
std::ifstream f(path);
if (!f) return std::unexpected(ConfigError::FileNotFound);
Config c;
// 간단한 파싱 (실제로는 JSON 등 사용)
f >> c.width >> c.height;
return c;
}
// 2. mdspan으로 이미지 버퍼 2D 뷰
void brighten_image(std::vector<uint8_t>& pixels, size_t width, size_t height) {
std::mdspan img(pixels.data(), height, width);
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x)
img[y, x] = static_cast<uint8_t>(std::min(255, img[y, x] + 30));
}
// 3. deducing this로 다형적 clone
struct ImageProcessor {
template <typename Self>
auto clone(this Self&& self) {
return std::make_unique<std::remove_cvref_t<Self>>(
static_cast<std::remove_cvref_t<Self> const&>(self));
}
};
struct BrightnessProcessor : ImageProcessor {};
// 4. 람다 패킹으로 지연 실행
template <class F, class... Args>
auto defer(F f, Args... args) {
return [f = std::move(f), ...args = std::move(args)]() mutable {
return std::invoke(f, args...);
};
}
int main() {
auto config = load_config("config.txt");
if (!config) return 1;
std::vector<uint8_t> pixels(config->width * config->height, 128);
brighten_image(pixels, config->width, config->height);
auto task = defer(brighten_image, std::ref(pixels), config->width, config->height);
task(); // 나중에 실행
}
expected vs 예외, mdspan의 비용
std::expected vs 예외
| 항목 | std::expected | 예외 |
|---|---|---|
| 성공 경로 비용 | 거의 없음 (분기 1회) | 없음 |
| 실패 경로 비용 | O(1), 에러 값 복사 | 스택 언와인딩, 예측 어려움 |
| 인라인 | 용이 | 복잡 |
| 적합 환경 | 게임, 임베디드, 고빈도 API | 일반 애플리케이션 |
요약: “실패가 정상인” 경로(파싱, IO, 검증)에서는 expected가 예외보다 예측 가능하고 비용이 낮습니다.
std::mdspan 오버헤드
mdspan 자체는 포인터와 extent 정보만 가진 얇은 뷰이고, 최적화를 켜면 mat[i, j] 접근은 data[i * cols + j]와 같은 인덱스 계산으로 컴파일됩니다. 기본 레이아웃인 layout_right는 C 스타일 행 우선 배치와 같습니다. 캐시 효율은 레이아웃 자체보다 순회 순서가 레이아웃과 맞는지에 달려 있으므로, layout_right라면 안쪽 루프가 마지막 인덱스(j)를 돌게 해야 합니다. 차원 크기를 std::extents<size_t, 3, 4>처럼 컴파일 타임 상수로 주면 extent 저장 공간이 사라지고 인덱스 계산도 상수로 접힐 수 있습니다.
C++23 도입 전 확인할 것
실무에서 C++23 기능을 도입할 때 참고할 체크리스트입니다.
- 대상 컴파일러와 표준 라이브러리가 쓰려는 기능을 지원하는지 기능 테스트 매크로로 확인
- 빌드 플래그를
-std=c++23(MSVC는/std:c++latest)로 변경 - 기존
optional+ 에러 코드 조합을expected<T, E>로 바꿀 곳 검토 - 2D/3D 인덱스 계산이 많은 모듈에 mdspan 적용
- CRTP와 const/ref 오버로드를
this Self&&패턴으로 옮길 수 있는지 검토 (가상 함수 대체는 불가) - 모든 타깃 플랫폼의 CI에서 C++23 빌드와 기존 테스트 통과 확인
같이 보면 좋은 글
- C++23 std::expected: 예외 없이 에러 전달하기와 and_then 체이닝
- std::optional로 ‘값 없음’ 표현하기
- Windows에서만 파일을 못 찾을 때
- C++ 파일 I/O 방식 비교
- C++ 현대적인 C++ GUI: Dear ImGui로 디버깅 툴·대시보드 만들기 [#36-1]
- Qt로 C++ 첫 GUI 만들기
자주 묻는 질문 (FAQ)
Q. std::expected에서 value()를 호출했는데 예외가 나는 이유는 무엇인가요?
A. 에러 상태인 std::expected에서 value()를 호출하면 std::bad_expected_access 예외가 발생해, 예외 없이 에러를 다루려던 설계가 무너집니다. has_value()나 bool 변환으로 먼저 확인한 뒤 *나 ->로 접근하거나, 기본값이 있다면 value_or를 씁니다. 여러 단계를 거치며 에러를 전달해야 한다면 and_then, or_else, transform 같은 모나딕 연산으로 연결합니다.
Q. std::expected와 std::optional을 같이 쓸 수 있나요?
A. 네. expected<T, E>에서 E를 std::monostate로 두면 “에러는 있지만 상세 정보 없음”을 표현할 수 있어, optional과 비슷한 용도로 쓰이기도 합니다. 다만 에러 정보가 필요하면 expected<T, Error> 형태가 더 적합합니다.
Q. mdspan은 std::span과 어떤 관계인가요?
A. std::span은 1차원 연속 시퀀스 뷰이며, std::mdspan은 2차원 이상의 다차원 뷰입니다. mdspan에 extents<size_t, N>처럼 1차원을 주면 span과 유사하게 쓸 수 있지만, 1차원만 필요하면 span이 더 단순합니다.
이전 글: [C++ GUI #36-2] 크로스 플랫폼 GUI: Qt 기초 찍어먹기
다음 글: [C++ 아키텍처 #38-1] C++ 클린 코드 기초: const, noexcept, [[nodiscard]]로 의도 명확히 하기