C++17·20·23 주요 기능 정리: 구조화 바인딩부터 Concepts·Ranges·std::print까지
이 글의 핵심
새 표준 기능을 한꺼번에 익히려 하면 무엇을 먼저 써야 할지 흐려집니다. 버전별 핵심 기능을 문제와 해법 중심으로 묶고, Concepts로 템플릿 에러를 읽기 쉽게 만드는 예제와 optional로 에러 값을 표현하는 예제를 통해 기존 코드에 바로 옮길 부분을 고를 수 있게 합니다. 컴파일러 지원과 호환성 질문도 FAQ에서 다룹니다.
C++17 핵심 기능
Structured Binding
구조화 바인딩 이전에는 pair나 tuple의 각 원소를 꺼내려면 p.first, p.second나 std::get<0>(t)처럼 인덱스·이름 기반 접근을 써야 했는데, 이는 그 값이 실제로 무엇을 의미하는지 코드만 봐서는 알기 어렵게 만듭니다. auto [id, name] = p는 튜플 형태의 값을 즉시 의미 있는 이름에 바인딩해, 이후 코드에서 p.first 대신 id라는 직접적인 이름을 쓸 수 있게 합니다. map 순회에서 [name, score]가 특히 유용한 이유는, 이전에는 it->first, it->second로 접근해야 했던 것을 key, value에 대응하는 직접적인 이름으로 바꿔 코드의 가독성을 크게 높이기 때문입니다.
#include <map>
#include <string>
using namespace std;
int main() {
// pair 분해
pair<int, string> p = {1, "one"};
auto [id, name] = p;
cout << id << ": " << name << endl;
// map 순회
map<string, int> scores = {{"Alice", 90}, {"Bob", 85}};
for (const auto& [name, score] : scores) {
cout << name << ": " << score << endl;
}
// 배열 분해
int arr[] = {1, 2, 3};
auto [a, b, c] = arr;
}
if constexpr
일반 if는 두 분기 모두 코드가 실제로 컴파일되어야 하므로, T가 포인터가 아닐 때 *t를 시도하는 분기가 여전히 컴파일되어야 해 getValue처럼 타입에 따라 완전히 다른 연산을 해야 하는 템플릿에서는 컴파일 에러가 납니다. if constexpr는 조건이 컴파일 타임 상수(is_pointer_v<T>)일 때, 그 조건에 따라 선택되지 않은 분기를 아예 인스턴스화 대상에서 제외해 버립니다 — T가 int라면 *t가 들어간 분기는 인스턴스화되지 않으므로 int에 *를 적용하는 에러가 나지 않습니다. 다만 버려진 분기도 문법 자체는 파싱되고, 템플릿 매개변수에 의존하지 않는 코드는 여전히 검사됩니다. 그래서 else { static_assert(false, "지원하지 않는 타입"); }처럼 쓰면 C++23 이전 컴파일러에서는 어떤 타입으로 호출하든 에러가 나는데, 이는 C++23에서 규칙이 완화되기 전까지 static_assert(sizeof(T) == 0, ...)처럼 조건을 T에 의존하게 만드는 우회가 필요했던 유명한 함정입니다. 또 if constexpr는 템플릿 안에서만 분기를 버리며, 템플릿이 아닌 일반 함수에서는 버려진 분기도 모두 컴파일됩니다.이것이 일반 런타임 if와 근본적으로 다른 지점이며, 서로 호환되지 않는 연산을 타입별로 분기해야 하는 제네릭 코드를 작성할 때 C++17 이전에는 함수 오버로딩이나 태그 디스패치로 우회해야 했던 문제를 훨씬 간결하게 해결합니다.
template <typename T>
auto getValue(T t) {
if constexpr (is_pointer_v<T>) {
return *t; // 포인터면 역참조
} else {
return t; // 아니면 그대로
}
}
int main() {
int x = 10;
int* ptr = &x;
cout << getValue(x) << endl; // 10
cout << getValue(ptr) << endl; // 10
}
std::optional
std::optional이 해결하는 문제는 “값이 없을 수도 있다”는 것을 표현할 때 흔히 쓰이던 두 가지 나쁜 방법입니다 — 포인터를 반환하고 nullptr로 부재를 표시하면 힙 할당과 수명 관리 부담이 생기고, 특수한 값(-1, 빈 문자열 등)으로 부재를 표시하면 그 값이 실제로 유효한 결과와 우연히 겹칠 위험이 있습니다. optional<string>은 “문자열이 있거나, 아예 없거나”라는 상태를 타입 시스템에 직접 새겨, findUser의 시그니처만 보고도 이 함수가 실패할 수 있다는 것을 호출자가 알 수 있게 합니다. if (user)처럼 불리언 문맥에서 값의 존재 여부를 확인하고, value_or로 부재 시 기본값을 즉시 지정할 수 있는 것도 널 포인터 검사나 예외 처리보다 훨씬 간결한 코드를 만듭니다.
#include <optional>
#include <string>
using namespace std;
optional<string> findUser(int id) {
if (id == 1) {
return "Alice";
}
return nullopt; // 값 없음
}
int main() {
auto user = findUser(1);
if (user) {
cout << "찾음: " << *user << endl;
} else {
cout << "없음" << endl;
}
// 또는
cout << user.value_or("Unknown") << endl;
}
std::variant
variant<int, double, string>은 전통적인 C 스타일 union과 달리 “지금 어떤 타입이 실제로 담겨 있는지”를 스스로 기억합니다 — union은 이 정보를 전혀 추적하지 않아 잘못된 멤버를 읽으면 정의되지 않은 동작으로 이어지지만, variant는 get<T>로 실제 담긴 타입과 다른 타입을 요청하면 bad_variant_access 예외로 즉시 알려줍니다. visit은 방문자가 모든 대안(int, double, string)을 처리할 수 있어야 컴파일되므로, 타입별로 다른 처리를 하는 오버로드 집합을 방문자로 쓰면 variant에 새 타입이 추가되었을 때 처리가 빠진 곳이 컴파일 에러로 드러납니다. 흔히 template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; 헬퍼로 타입별 람다를 묶어 이 효과를 얻습니다. 반대로 아래 예제처럼 auto를 받는 제네릭 람다 하나로 방문하면 어떤 타입이든 받아들이므로 이런 누락 검사는 일어나지 않는다는 점을 구분해야 합니다.
variant를 처음 쓸 때 부딪히는 함정은 변환 규칙입니다. C++17 초기 구현에서는 variant<string, bool> v = "abc";가 string이 아니라 bool을 선택했습니다. 문자열 리터럴(const char*)에서 bool로의 변환이 표준 변환이라 사용자 정의 변환인 string보다 우선했기 때문입니다. 이 동작은 C++20의 변경(P0608)으로 좁혀지는 변환을 막아 해결되었지만, 오래된 컴파일러에서는 여전히 볼 수 있으므로 문자열은 std::string{"abc"}나 "abc"s처럼 명시하는 편이 안전합니다.
#include <variant>
#include <string>
using namespace std;
int main() {
variant<int, double, string> v;
v = 10;
cout << get<int>(v) << endl;
v = 3.14;
cout << get<double>(v) << endl;
v = "Hello";
cout << get<string>(v) << endl;
// 방문자 패턴
visit([](auto arg) {
cout << arg << endl;
}, v);
}
std::filesystem
C++17 이전에는 파일 시스템을 다루려면 플랫폼별 API(POSIX의 dirent.h, Windows의 FindFirstFile 등)를 직접 호출하거나 Boost.Filesystem 같은 서드파티 라이브러리에 의존해야 했습니다. std::filesystem은 이 기능을 표준 라이브러리로 끌어와, fs::directory_iterator로 디렉토리를 순회하고 fs::exists/fs::file_size로 파일 정보를 조회하는 코드가 운영체제와 무관하게 동일하게 동작하도록 만듭니다. fs::path가 내부적으로 경로 구분자(윈도우의 \, 유닉스의 /)를 알아서 처리해 주는 것도 중요한 실용적 이점입니다 — 크로스플랫폼 코드에서 경로 문자열을 수동으로 조합하며 구분자 실수를 저지르는 흔한 버그를 원천적으로 없애 줍니다.
#include <filesystem>
#include <iostream>
namespace fs = std::filesystem;
int main() {
// 디렉토리 순회
for (const auto& entry : fs::directory_iterator(".")) {
cout << entry.path() << endl;
}
// 파일 존재 확인
if (fs::exists("file.txt")) {
cout << "파일 있음" << endl;
}
// 파일 크기
auto size = fs::file_size("file.txt");
cout << "크기: " << size << " bytes" << endl;
}
C++20 핵심 기능
Concepts
C++20 이전에는 템플릿 매개변수에 제약을 걸고 싶으면 enable_if나 static_assert처럼 읽기 어려운 SFINAE(치환 실패는 에러가 아님) 트릭을 써야 했고, 잘못된 타입으로 템플릿을 인스턴스화하면 수십 줄에 걸친 난해한 템플릿 에러 메시지가 쏟아졌습니다. concept Numeric = integral<T> || floating_point<T>는 “이 템플릿이 요구하는 타입의 조건”을 이름 붙은 하나의 개념으로 명시적으로 선언하게 해 주며, add("a", "b")처럼 조건을 만족하지 않는 호출은 “Numeric 조건을 만족하지 않습니다”라는 훨씬 명확한 에러 메시지로 즉시 거부됩니다. Concepts는 결국 템플릿의 요구사항을 코드가 아니라 문서처럼 읽을 수 있게 만드는 것이 핵심 가치입니다.
#include <concepts>
#include <iostream>
using namespace std;
// 개념 정의
template <typename T>
concept Numeric = integral<T> || floating_point<T>;
// 개념 사용
template <Numeric T>
T add(T a, T b) {
return a + b;
}
int main() {
cout << add(1, 2) << endl; // OK
cout << add(1.5, 2.5) << endl; // OK
// cout << add("a", "b"); // 컴파일 에러!
}
Ranges
v | vw::filter(...) | vw::transform(...)라는 파이프 문법은 함수형 언어의 데이터 파이프라인을 C++로 가져온 것이며, 기존 <algorithm> 방식(std::copy_if로 걸러낸 결과를 임시 벡터에 담고, 다시 std::transform으로 변환해 또 다른 벡터에 담는)과 비교하면 중간 임시 컨테이너가 전혀 생기지 않는다는 점이 결정적으로 다릅니다. views(뷰)는 원본 데이터를 복사하지 않고 “이 필터와 변환을 적용하면 이렇게 보일 것이다”라는 지연 평가된 계산만 표현하므로, 실제 계산은 for 루프가 각 원소를 실제로 순회할 때 그 자리에서 이루어집니다. 이는 앞서 다룬 Python이나 함수형 언어의 지연 시퀀스와 같은 발상이며, 중간 결과를 저장할 필요가 없어 메모리 효율적일 뿐 아니라 필터·변환의 연쇄를 자연스럽게 표현할 수 있게 해 줍니다.
#include <ranges>
#include <vector>
#include <iostream>
namespace rng = std::ranges;
namespace vw = std::views;
int main() {
vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수만 필터링하고 2배
auto result = v
| vw::filter([](int x) { return x % 2 == 0; })
| vw::transform([](int x) { return x * 2; });
for (int x : result) {
cout << x << " "; // 4 8 12 16 20
}
}
Coroutines
co_yield가 호출되면 함수 실행이 그 지점에서 완전히 멈추고, 호출자가 다시 재개(resume())할 때까지 함수의 지역 변수와 실행 위치가 그대로 보존됩니다 — 이것이 일반 함수와 코루틴의 근본적 차이입니다. promise_type이라는 다소 장황한 보일러플레이트가 필요한 이유는, 컴파일러가 코루틴을 “일시 정지와 재개가 가능한 상태 기계”로 변환할 때 그 상태(현재 값, 정지 지점, 예외 처리 방법)를 저장할 장소가 필요하기 때문입니다 — current_value에 값을 저장하고, suspend_always로 매번 값을 만들 때마다 멈추도록 지정하는 것이 그 메커니즘의 핵심입니다. 이 예제의 Generator는 결국 파이썬의 제너레이터나 자바스크립트의 function*와 같은 개념을 C++로 재현한 것이며, 전체 시퀀스를 미리 계산해 메모리에 담아두지 않고 필요한 값을 그때그때 하나씩 만들어내는 지연 평가 패턴입니다.
이 최소 구현을 그대로 쓰면 두 가지 문제가 있습니다. 첫째, Generator에 복사 생성자가 기본으로 생기므로 auto g2 = gen; 한 줄로 두 객체가 같은 코루틴 핸들을 갖게 되고, 둘 다 소멸자에서 destroy()를 호출해 이중 해제가 됩니다. 복사는 = delete하고 이동 생성자에서 원본 핸들을 비워야 합니다. 둘째, unhandled_exception()이 비어 있어 코루틴 안에서 던진 예외가 조용히 사라집니다. 보통 std::current_exception()을 저장해 두었다가 move_next()에서 다시 던집니다. C++23에는 이런 세부 사항을 모두 처리한 std::generator가 표준으로 들어왔으므로, 지원하는 컴파일러라면 직접 만들기보다 표준 타입을 쓰는 편이 낫습니다. 코루틴 프레임은 기본적으로 힙에 할당되고, 컴파일러가 수명을 증명할 수 있을 때만 이 할당을 생략한다는 점도 성능이 중요한 코드에서는 알아 둘 만합니다.
#include <coroutine>
#include <iostream>
using namespace std;
struct Generator {
struct promise_type {
int current_value;
Generator get_return_object() {
return Generator{handle_type::from_promise(*this)};
}
suspend_always initial_suspend() { return {}; }
suspend_always final_suspend() noexcept { return {}; }
suspend_always yield_value(int value) {
current_value = value;
return {};
}
void return_void() {}
void unhandled_exception() {}
};
using handle_type = coroutine_handle<promise_type>;
handle_type coro;
Generator(handle_type h) : coro(h) {}
~Generator() { if (coro) coro.destroy(); }
bool move_next() {
coro.resume();
return !coro.done();
}
int current_value() {
return coro.promise().current_value;
}
};
Generator counter() {
for (int i = 0; i < 5; i++) {
co_yield i;
}
}
int main() {
auto gen = counter();
while (gen.move_next()) {
cout << gen.current_value() << " ";
}
}
Modules (간단 예제)
전통적인 #include 기반 헤더는 컴파일러가 매 번역 단위마다 그 헤더의 내용을 텍스트 그대로 다시 파싱해야 하는 구조라, 큰 프로젝트에서 같은 헤더가 수백 개의 .cpp 파일에 include되면 그 파싱 작업이 그만큼 반복되어 빌드 시간이 크게 늘어납니다. export module math로 정의된 모듈은 한 번만 컴파일되어 이진 형태의 인터페이스로 캐시되고, 이를 import math로 가져오는 다른 파일들은 텍스트 재파싱 없이 그 캐시된 인터페이스를 재사용합니다. 이는 헤더가 안고 있던 또 다른 고질적 문제(매크로 오염, include 순서에 따른 재정의 에러, include guard의 번거로움)도 함께 없애 주지만, 실제 빌드 시스템과 컴파일러 지원이 아직 완전히 성숙하지 않아 실무 도입은 신중하게 검토해야 하는 영역입니다. 모듈은 “import하는 쪽보다 모듈 인터페이스를 먼저 컴파일해야 한다”는 빌드 순서가 생기므로, 빌드 시스템이 소스를 스캔해 의존성 그래프를 만들어야 합니다(CMake 3.28 이상 + Ninja 조합이 대표적). 아래 예제의 import std;는 C++20이 아니라 C++23에서 추가된 표준 라이브러리 모듈이며, 컴파일러와 표준 라이브러리 버전에 따라 별도 설정이 필요합니다.
// math.cppm
export module math;
export int add(int a, int b) {
return a + b;
}
// main.cpp
import math;
import std;
int main() {
std::cout << add(1, 2) << std::endl;
}
Three-way Comparison (<=>)
C++20 이전에는 완전한 비교가 가능한 타입을 만들려면 ==, !=, <, >, <=, >= 여섯 개를 모두 손으로 작성해야 했는데(또는 앞서 CRTP 글에서 다룬 것처럼 두 개만 구현하고 나머지를 유도하는 믹스인을 써야 했는데), operator<=>는 이 여섯 개를 컴파일러가 단 하나의 선언으로부터 자동 생성하게 해 줍니다. = default로 선언하면 컴파일러가 멤버들을 선언된 순서대로 비교하는 사전식 순서를 자동으로 구현해 주며, Point{1,2} < Point{1,3}이 x가 같으므로 y를 비교해 참이 되는 것이 그 결과입니다. 이는 CRTP 믹스인 같은 “비교 연산자 자동 생성” 기법이 해결하려던 문제를 언어 차원에서 훨씬 간결하게 대체한 것입니다.
한 가지 주의할 점은 <=>를 = default가 아니라 직접 구현하면 ==는 자동으로 생기지 않는다는 것입니다. ==는 <=>보다 훨씬 싸게 구현할 수 있는 경우가 많아서(예: 문자열은 길이가 다르면 바로 거짓) 표준이 둘을 분리해 두었기 때문입니다. 직접 <=>를 쓴 타입에서 a == b가 컴파일되지 않는다면 bool operator==(const Point&) const = default;를 따로 선언해 주면 됩니다. 또 C++20에서는 a != b가 !(a == b)로, a < b가 (a <=> b) < 0으로 재작성되기 때문에, 예전 코드에서 인자 순서가 비대칭인 비교 연산자를 정의해 두었다면 C++20으로 올리는 순간 모호성 에러가 나기도 합니다.
#include <compare>
#include <iostream>
using namespace std;
struct Point {
int x, y;
auto operator<=>(const Point&) const = default;
};
int main() {
Point p1{1, 2};
Point p2{1, 3};
cout << (p1 < p2) << endl; // 1
cout << (p1 == p2) << endl; // 0
cout << (p1 != p2) << endl; // 1
}
C++23 핵심 기능
std::print
std::cout << "x = " << x << ", y = " << y << "\n"처럼 <<를 반복적으로 연결하는 스트림 방식은 코드가 장황해지고, C의 printf("x = %d, y = %d\n", x, y)는 짧지만 형식 문자열과 실제 인자 타입이 일치하는지 컴파일러가 검사해 주지 않아 타입 불일치가 런타임 크래시로 이어질 수 있었습니다. std::print("x = {}, y = {}\n", x, y)는 C++20의 std::format 문법({})을 그대로 가져와 printf만큼 간결하면서도, 내부적으로 std::format을 사용하므로 타입 안전성이 컴파일 타임에 검증됩니다 — 두 세계의 장점을 모두 취한 것이 이 기능의 핵심 가치입니다.
#include <print>
int main() {
std::print("Hello, {}!\n", "World");
std::print("x = {}, y = {}\n", 10, 20);
}
Multidimensional Subscript
C++23 이전에는 operator[]가 정확히 하나의 인자만 받을 수 있어, 행렬처럼 2차원 인덱싱이 필요한 타입은 m(i, j)처럼 operator()를 대신 오버로딩하는 우회가 흔했습니다. 참고로 C++20에서 a[i, j]처럼 첨자 안에 쉼표 연산자를 쓰는 것이 deprecated된 것도 C++23의 이 문법을 위한 준비였습니다.C++23은 operator[]가 여러 인자를 받을 수 있도록 언어 규칙을 확장해, m[0, 1]처럼 수학적 표기와 더 가까운 인덱싱 문법을 []로 직접 표현할 수 있게 했습니다 — 함수 호출 연산자를 인덱싱 용도로 “빌려 쓰던” 우회가 이제는 필요 없어진 것입니다.
struct Matrix {
int data[3][3];
int& operator[](int i, int j) {
return data[i][j];
}
};
int main() {
Matrix m;
m[0, 1] = 5; // C++23
}
if consteval
constexpr 함수는 컴파일 타임과 런타임 양쪽에서 호출될 수 있는데,두 문맥에서 서로 다른 코드(예: 컴파일 타임에는 순수 계산으로, 런타임에는 SIMD 명령어를 쓴 최적화 경로로)를 실행하고 싶을 때가 있습니다. if consteval은 “지금 이 코드가 실제로 상수 표현식으로 평가되고 있는가”를 함수 본문 안에서 직접 분기할 수 있게 해 주며, 이는 앞서 다룬 if constexpr(템플릿 매개변수에 따른 분기)와는 별개의 메커니즘으로, 같은 함수 하나가 호출되는 문맥(컴파일 타임 vs 런타임)에 따라 다른 코드를 타도록 만듭니다.
#include <cmath>
consteval int sqr(int n) { // consteval: 반드시 컴파일 타임에만 호출 가능
return n * n;
}
constexpr double my_sqrt(double x) {
if consteval {
// 상수 평가 중: 순수 C++로 계산 (뉴턴 방법)
double r = x;
for (int i = 0; i < 20; ++i) r = 0.5 * (r + x / r);
return r;
} else {
// 런타임: 하드웨어 명령을 쓰는 라이브러리 함수
return std::sqrt(x);
}
}
int main() {
constexpr int a = sqr(5); // 컴파일 타임
constexpr double b = my_sqrt(2.0); // if consteval의 첫 분기
double c = my_sqrt(a); // 런타임 호출 → else 분기
(void)b; (void)c;
}
if consteval은 constexpr 함수 안에서 써야 의미가 있습니다. main 같은 일반 함수에서는 항상 런타임에 평가되므로 else 분기만 실행됩니다. C++20의 std::is_constant_evaluated()도 같은 목적이지만, if constexpr (std::is_constant_evaluated())처럼 잘못 쓰면 조건이 항상 참이 되는 함정이 있었고, if consteval은 그런 실수가 불가능한 문법으로 이를 대체합니다. 또 if consteval 분기 안에서는 consteval 함수를 호출할 수 있다는 점도 차이입니다.
실전 예시
예시 1: 모던 C++ 스타일
이 예시는 Ranges의 “파이프라인” 스타일이 전통적인 명령형 코드와 실제로 어떻게 다른지 보여줍니다 — “짝수만 걸러서 제곱한 뒤 합산한다”는 하나의 문장이 그대로 filter | transform | fold_left로 번역되어, 중간 결과를 담을 임시 벡터나 누적 변수를 위한 반복문을 직접 작성할 필요가 없습니다. fold_left가 표준 std::accumulate의 Ranges 버전인 것도 눈여겨볼 만합니다 — Ranges가 기존 <algorithm>의 함수들을 대체하는 것이 아니라, 그 함수들을 뷰(view) 기반 파이프라인과 자연스럽게 조합될 수 있도록 재구성한 것이라는 점을 보여줍니다.
#include <iostream>
#include <vector>
#include <ranges>
#include <algorithm>
namespace rng = std::ranges;
namespace vw = std::views;
int main() {
vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수만 필터링, 제곱, 합계
auto sum = numbers
| vw::filter([](int x) { return x % 2 == 0; })
| vw::transform([](int x) { return x * x; })
| vw::common;
int result = rng::fold_left(sum, 0, std::plus<>());
cout << "결과: " << result << endl; // 220
}
vw::common은 여기서 꼭 필요하지는 않습니다. 이 어댑터는 시작 반복자와 끝(sentinel)의 타입이 같아야 하는 옛 <algorithm> 함수(std::accumulate 등)에 뷰를 넘길 때 필요하고, rng::fold_left 같은 ranges 알고리즘은 서로 다른 타입의 끝도 받아들입니다.
뷰를 쓰면서 가장 자주 부딪히는 함정은 filter_view입니다. filter_view는 첫 번째 조건 만족 원소를 찾는 비용을 줄이려고 begin() 결과를 내부에 캐시하기 때문에 begin()이 const 멤버가 아닙니다. 그래서 void print(const auto& r)처럼 뷰를 const 참조로 받는 함수에 필터 뷰를 넘기면 no matching function for call to 'begin' 류의 컴파일 에러가 납니다. 뷰는 값으로 받거나(auto r), 전달 참조(auto&& r)로 받는 것이 관례입니다. 또 필터 뷰를 순회하면서 원소를 수정해 조건의 결과가 바뀌게 만드는 것은 정의되지 않은 동작이므로, 걸러낸 원소를 바꿔야 한다면 조건에 영향을 주지 않는 필드만 수정해야 합니다.
예시 2: Concepts로 안전한 코드
requires 절 안의 { t.begin() } -> same_as<typename T::iterator>같은 문법은 “이 타입이 begin()을 호출할 수 있고, 그 결과가 정확히 T::iterator 타입이어야 한다”는 것을 검사하는 요구사항 표현식(requires expression)입니다 — 앞서 본 Numeric처럼 기존 개념을 조합하는 대신, 이번에는 “이런 멤버 함수들을 갖고 있어야 한다”는 구조적 요구사항을 직접 정의한 것입니다. printContainer(42)가 컴파일 에러가 되는 이유는 int가 begin()이나 size() 같은 멤버를 전혀 갖고 있지 않아 Container 개념을 만족하지 못하기 때문이며, 이 검증이 함수 본문이 실제로 인스턴스화되기 전에 개념 선언 단계에서 이루어지므로 에러 메시지가 “Container 요구사항을 만족하지 않습니다”처럼 명확하게 나옵니다 — Concepts가 없었다면 int에 .begin()을 호출하려는 시도가 함수 본문 깊숙한 곳에서 훨씬 알아보기 어려운 에러로 나타났을 것입니다.
#include <concepts>
#include <vector>
#include <iostream>
using namespace std;
template <typename T>
concept Container = requires(T t) {
{ t.begin() } -> same_as<typename T::iterator>;
{ t.end() } -> same_as<typename T::iterator>;
{ t.size() } -> convertible_to<size_t>;
};
template <Container C>
void printContainer(const C& container) {
for (const auto& item : container) {
cout << item << " ";
}
cout << endl;
}
int main() {
vector<int> v = {1, 2, 3};
printContainer(v); // OK
// printContainer(42); // 컴파일 에러!
}
예시 3: Optional로 안전한 에러 처리
or_else와 transform은 C++23에서 optional에 추가된 모나딕(monadic) 연산으로, “값이 있으면 변환하고, 없으면 대체값을 쓴다”는 흐름을 if/else 분기 없이 표현할 수 있게 해 줍니다 — parseInteger("abc")가 실패해 nullopt를 반환하면 or_else가 그 자리를 optional<int>{0}으로 채우고, 이어지는 transform이 (값이 있든 없든 상관없이 이제는 항상 있으므로) 그 값을 2배로 변환합니다. 이는 함수형 언어의 Maybe/Option 모나드에서 흔히 쓰이는 체이닝 패턴을 C++로 가져온 것이며, 값이 있는 경우와 없는 경우를 매번 명시적으로 분기하는 대신 그 두 경우를 관통하는 하나의 파이프라인으로 표현할 수 있게 해 줍니다.
#include <optional>
#include <string>
#include <iostream>
using namespace std;
optional<int> parseInteger(const string& str) {
try {
return stoi(str);
} catch (...) {
return nullopt;
}
}
int main() {
auto result = parseInteger("123");
if (result) {
cout << "파싱 성공: " << *result << endl;
} else {
cout << "파싱 실패" << endl;
}
// 체이닝
auto value = parseInteger("abc")
.or_else([] { return optional<int>{0}; })
.transform([](int x) { return x * 2; });
cout << value.value() << endl; // 0
}
FAQ
Q1: 어떤 표준 버전을 기본으로 쓰면 되나요?
A: 새 프로젝트라면 팀이 쓰는 모든 컴파일러가 안정적으로 지원하는 가장 높은 버전을 고르되, 현실적으로는 C++20이 무난합니다. 모듈·std::print·std::generator 같은 C++23 기능은 컴파일러와 표준 라이브러리 버전에 따라 지원 상태가 크게 다르므로 cppreference의 컴파일러 지원 표로 확인하고, 코드에서는 __cpp_lib_print 같은 기능 테스트 매크로로 분기하는 편이 안전합니다.
Q2: -std=c++20으로 올렸더니 멀쩡하던 코드가 컴파일되지 않습니다.
A: 하위 호환이 완전하지는 않습니다. 대표적으로 비교 연산자 재작성 규칙 때문에 비대칭 operator==가 모호해지고, u8"..." 리터럴의 타입이 char8_t 배열로 바뀌어 const char*에 대입이 안 되며, 사용자 선언 생성자가 있는 클래스는 더 이상 집합체 초기화(T{...})가 되지 않습니다. 에러 메시지에서 이런 패턴을 찾아 하나씩 고치면 됩니다.
Q3: 필터 뷰를 const 참조로 받는 함수에 넘기면 에러가 납니다.
A: filter_view는 begin()을 캐시하느라 const로 순회할 수 없습니다. 뷰는 값(auto r)이나 전달 참조(auto&& r)로 받으세요.
Q4: 직접 만든 코루틴 Generator를 복사했더니 크래시가 납니다.
A: 기본 복사 생성자가 코루틴 핸들을 복사해 두 객체가 같은 프레임을 destroy()하기 때문입니다. 복사를 삭제하고 이동만 허용하거나, C++23의 std::generator를 쓰세요.