C++20 Concepts & Requires
개요 — Concepts란 무엇인가
섹션 제목: “개요 — Concepts란 무엇인가”Concepts는 C++20에서 도입된 템플릿 파라미터에 대한 타입 제약 조건 선언 메커니즘입니다. 기존에는 SFINAE(std::enable_if)나 static_assert를 이용해 타입 조건을 걸었지만, 오류 메시지가 수십 줄의 내부 인스턴스화 오류로 출력되어 디버깅이 매우 어려웠습니다.
Concepts를 사용하면:
- 템플릿 파라미터가 어떤 조건을 만족해야 하는지 명시적으로 선언할 수 있습니다.
- 조건을 만족하지 않으면 짧고 명확한 오류 메시지가 출력됩니다.
- 오버로드 해결(overload resolution)에도 Concepts를 활용해 더 세밀한 제어가 가능합니다.
1. Concept 선언 기본 문법
섹션 제목: “1. Concept 선언 기본 문법”#include <concepts>#include <type_traits>
// concept 키워드로 선언 — bool 값을 반환하는 제약 조건template<typename T>concept Arithmetic = std::is_arithmetic_v<T>;
// 복합 조건 — && 와 || 사용 가능template<typename T>concept SignedIntegral = std::integral<T> && std::signed_integral<T>;
// requires 표현식으로 인터페이스 제약template<typename T>concept Printable = requires(T x) { // x에 대해 이 표현식이 유효해야 함 { std::cout << x } -> std::same_as<std::ostream&>;};
template<typename T>concept Comparable = requires(T a, T b) { { a < b } -> std::convertible_to<bool>; { a > b } -> std::convertible_to<bool>; { a == b } -> std::convertible_to<bool>;};2. requires 표현식 네 가지 형태
섹션 제목: “2. requires 표현식 네 가지 형태”template<typename T>concept FullConcept = requires(T x, T y) { // 1. 단순 표현식 — 해당 표현식이 유효해야 함 x + y;
// 2. 타입 요구사항 — 해당 타입이 존재해야 함 typename T::value_type;
// 3. 복합 요구사항 — 표현식 + 반환 타입 제약 { x.size() } -> std::convertible_to<std::size_t>;
// 4. 중첩 requires — 추가적인 bool 조건 requires std::copyable<T>;};// 중첩 requires 활용 예시template<typename Container>concept SizedContainer = requires(Container c) { { c.size() } -> std::convertible_to<std::size_t>; { c.empty() } -> std::same_as<bool>; typename Container::value_type; typename Container::iterator;};
template<SizedContainer C>void PrintInfo(const C& container){ std::cout << "size: " << container.size() << ", empty: " << container.empty() << "\n";}3. Concept 적용 방법 — 네 가지 문법
섹션 제목: “3. Concept 적용 방법 — 네 가지 문법”template<typename T>concept Number = std::integral<T> || std::floating_point<T>;
// 방법 1: template 파라미터 자리에 concept 이름 사용template<Number T>T Add(T a, T b) { return a + b; }
// 방법 2: requires 절 (requires clause)template<typename T> requires Number<T>T Multiply(T a, T b) { return a * b; }
// 방법 3: 축약 함수 템플릿 (abbreviated function template, C++20)Number auto Square(Number auto x) { return x * x; }
// 방법 4: requires 절 (후위)template<typename T>T Subtract(T a, T b) requires Number<T> { return a - b; }4. 표준 라이브러리 제공 Concepts (<concepts>)
섹션 제목: “4. 표준 라이브러리 제공 Concepts (<concepts>)”| Concept | 의미 |
|---|---|
std::same_as<T, U> | T와 U가 동일한 타입 |
std::derived_from<T, Base> | T가 Base의 파생 클래스 |
std::convertible_to<From, To> | From이 To로 암시적 변환 가능 |
std::integral<T> | 정수 타입 |
std::floating_point<T> | 부동소수점 타입 |
std::copyable<T> | 복사 가능한 타입 |
std::movable<T> | 이동 가능한 타입 |
std::equality_comparable<T> | == 비교 가능한 타입 |
std::totally_ordered<T> | 완전 순서 비교(<, >, <=, >=) 가능 |
std::invocable<F, Args...> | F가 Args로 호출 가능 |
std::regular<T> | 기본 생성, 복사, 이동, 비교 모두 가능 |
5. SFINAE vs Concepts — 오류 메시지 비교
섹션 제목: “5. SFINAE vs Concepts — 오류 메시지 비교”SFINAE 방식 (C++14/17)
섹션 제목: “SFINAE 방식 (C++14/17)”// 구식 방식 — 오류 메시지가 장황함template<typename T>std::enable_if_t<std::is_integral_v<T>, T> OldDouble(T x){ return x * 2;}
// OldDouble("hello"); 호출 시 오류:// error: no matching function for call to 'OldDouble(const char[6])'// note: candidate: ... enable_if_t<is_integral_v<T>, T> OldDouble(T)// note: template argument deduction/substitution failed:// ... (수십 줄 내부 오류)Concepts 방식 (C++20)
섹션 제목: “Concepts 방식 (C++20)”template<std::integral T>T NewDouble(T x) { return x * 2; }
// NewDouble("hello"); 호출 시 오류:// error: no matching function for call to 'NewDouble(const char[6])'// note: constraints not satisfied:// 'std::integral<const char*>' evaluated to false// (명확하고 짧은 메시지)6. 여러 Concept 조합 — 실전 예시
섹션 제목: “6. 여러 Concept 조합 — 실전 예시”#include <concepts>#include <iterator>
// 컨테이너를 정렬할 수 있는 조건template<typename Container>concept Sortable = requires(Container c) { { std::begin(c) } -> std::random_access_iterator; { std::end(c) } -> std::random_access_iterator;} && std::totally_ordered<typename Container::value_type>;
template<Sortable C>void Sort(C& container){ std::sort(std::begin(container), std::end(container));}
// 수학적 그룹(더하기 가능, 0 원소 존재) 개념template<typename T>concept Addable = requires(T a, T b) { { a + b } -> std::same_as<T>; { T{} }; // 기본 생성 (0 원소)};
template<Addable T>T Sum(const std::vector<T>& values){ T result{}; for (const auto& v : values) result = result + v; return result;}7. Concept을 이용한 오버로드 선택
섹션 제목: “7. Concept을 이용한 오버로드 선택”Concepts는 오버로드 해결에서 더 구체적인 제약을 가진 후보를 우선 선택합니다.
template<typename T>concept Integral = std::integral<T>;
template<typename T>concept SignedIntegral = Integral<T> && std::signed_integral<T>;
// 일반 정수 처리template<Integral T>void Process(T value){ std::cout << "일반 정수: " << value << "\n";}
// 부호 있는 정수 처리 — 더 구체적인 제약이므로 우선 선택됨template<SignedIntegral T>void Process(T value){ std::cout << "부호 정수: " << value << " (음수 가능)\n";}
Process(42); // SignedIntegral 버전 선택 (int는 signed)Process(42u); // Integral 버전 선택 (unsigned int)8. 커스텀 Concept 설계 패턴
섹션 제목: “8. 커스텀 Concept 설계 패턴”// 직렬화 가능한 타입을 위한 Concepttemplate<typename T>concept Serializable = requires(T obj, std::ostream& os, std::istream& is) { { obj.Serialize(os) } -> std::same_as<void>; { obj.Deserialize(is) } -> std::same_as<void>; { obj.GetTypeId() } -> std::convertible_to<int>;};
// 범위 기반 처리를 위한 범용 함수template<Serializable T>void SaveToFile(const T& obj, const std::string& path){ std::ofstream file(path); obj.Serialize(file);}
// 팩토리 함수 — 기본 생성 + 복사 가능 타입만 허용template<typename T> requires std::default_initializable<T> && std::copyable<T>std::vector<T> CreateN(int n, const T& prototype){ return std::vector<T>(n, prototype);}9. Concept 포함 관계 (Subsumption)
섹션 제목: “9. Concept 포함 관계 (Subsumption)”Concepts는 서로 포함(subsume) 관계를 형성합니다. 오버로드 해결 시 더 구체적인(포함 범위가 좁은) Concept을 가진 후보가 우선 선택됩니다.
// B가 A를 포함(subsume)하면 B가 더 구체적template<typename T>concept Arithmetic = std::integral<T> || std::floating_point<T>;
template<typename T>concept Integral = std::integral<T>; // Arithmetic의 부분집합
template<typename T>concept SignedIntegral = Integral<T> && std::signed_integral<T>; // 더 구체적
// 오버로드 셋template<Arithmetic T> void foo(T) { std::cout << "Arithmetic\n"; }template<Integral T> void foo(T) { std::cout << "Integral\n"; }template<SignedIntegral T> void foo(T) { std::cout << "SignedIntegral\n"; }
foo(42); // SignedIntegral 선택 — 가장 구체적foo(42u); // Integral 선택 — unsigned는 SignedIntegral 미충족foo(3.14); // Arithmetic 선택 — 유일하게 충족포함 관계는 requires 표현식의 논리적 포함으로 판단됩니다.
A && B는 A를 포함(subsume)하고, A || B는 A에 의해 포함됩니다.
10. constrained auto — 축약 제약
섹션 제목: “10. constrained auto — 축약 제약”C++20에서는 auto 자리에 Concept을 붙여 함수 인자·반환 타입·변수를 제약할 수 있습니다.
// 함수 파라미터에 제약된 autovoid PrintNumber(std::integral auto n){ std::cout << "정수: " << n << "\n";}
// 반환 타입에 제약된 autostd::floating_point auto ComputeArea(double r){ return 3.14159 * r * r; // double 반환 — floating_point 만족하므로 OK}
// 변수 타입 제약std::integral auto x = 42; // OK// std::integral auto y = 3.14; // 오류: double은 integral 아님
// 제네릭 람다에서도 사용 가능auto square = [](std::integral auto n) { return n * n; };auto result = square(5); // OK// auto bad = square(3.14); // 오류11. 실전 라이브러리 설계 패턴
섹션 제목: “11. 실전 라이브러리 설계 패턴”수학 벡터 라이브러리
섹션 제목: “수학 벡터 라이브러리”#include <concepts>#include <cmath>
// 수치 타입 제약template<typename T>concept Numeric = std::integral<T> || std::floating_point<T>;
// 벡터 공간 개념 — 스칼라 곱과 덧셈 지원template<typename V>concept VectorSpace = requires(V v, V u, double s) { { v + u } -> std::same_as<V>; { v - u } -> std::same_as<V>; { s * v } -> std::same_as<V>; { v.norm() } -> std::floating_point;};
template<VectorSpace V>V normalize(const V& v){ return (1.0 / v.norm()) * v;}
// 반복 가능한 컨테이너에서 합계template<typename Range> requires std::ranges::input_range<Range> && Numeric<std::ranges::range_value_t<Range>>auto sum(const Range& r){ using T = std::ranges::range_value_t<Range>; T total{}; for (const auto& v : r) total += v; return total;}직렬화 프레임워크
섹션 제목: “직렬화 프레임워크”// 직렬화 지원 타입 판별template<typename T>concept JsonSerializable = requires(const T& obj) { { obj.to_json() } -> std::convertible_to<std::string>;} || std::is_arithmetic_v<T> || std::same_as<T, std::string>;
template<JsonSerializable T>std::string serialize(const T& value){ if constexpr (std::is_arithmetic_v<T>) return std::to_string(value); else if constexpr (std::same_as<T, std::string>) return "\"" + value + "\""; else return value.to_json();}12. 자주 하는 실수와 함정
섹션 제목: “12. 자주 하는 실수와 함정”// 함정 1: requires 절 vs requires 표현식 혼동template<typename T>requires requires(T x) { x + x; } // 바깥 requires = 제약, 안쪽 requires = 표현식T double_val(T x) { return x + x; }
// 함정 2: 포함 관계 없이 모호한 오버로드template<typename T>concept A = std::integral<T>;template<typename T>concept B = std::signed_integral<T>;
// A와 B 사이에 직접적인 subsumption이 없으면 모호 오류 발생 가능// B를 A && std::signed_integral<T>로 정의해야 포함 관계 성립
// 함정 3: bool 반환이지만 constexpr이 아닌 함수를 concept에 사용constexpr bool is_valid(int n) { return n > 0; } // OK// bool runtime_check(int n) { return n > 0; } // 런타임 함수는 concept 내 사용 불가
// 올바른 패턴template<auto N>concept Positive = (N > 0); // 비타입 파라미터 제약13. 정리
섹션 제목: “13. 정리”| 구분 | SFINAE (C++11/14) | Concepts (C++20) |
|---|---|---|
| 오류 메시지 | 장황하고 파악 어려움 | 명확하고 짧음 |
| 가독성 | 낮음 (enable_if 중첩) | 높음 (선언적 문법) |
| 오버로드 우선순위 | 복잡한 규칙 | 제약 포함 관계로 명확 |
| 인터페이스 문서화 | 암묵적 | 명시적 |
| constrained auto | 불가 | 가능 |
| 지원 버전 | C++11 이상 | C++20 이상 |
Concepts는 단순히 문법 편의 기능이 아니라, 템플릿 라이브러리의 인터페이스 계약을 코드로 표현하는 수단입니다. <concepts> 헤더의 표준 Concepts를 먼저 활용하고, 프로젝트 도메인에 맞는 커스텀 Concept을 추가하는 방식으로 접근하면 효과적입니다.