C++20 Concepts: SFINAE를 대체하는 제약 문법과 Subsumption

들어가며

템플릿 함수에 잘못된 타입을 넘기면 컴파일러는 템플릿을 끝까지 인스턴스화해 보다가 본문 깊은 곳에서 실패하고, 그 경로를 따라 긴 에러를 출력합니다. 정작 “이 함수는 어떤 타입을 기대하는가”는 메시지 어디에도 나오지 않습니다.

Concepts는 템플릿 인자가 만족해야 할 조건에 이름을 붙여 선언하는 C++20 기능입니다. 조건을 선언에 적어 두면 컴파일러는 본문을 인스턴스화하기 전에 그 조건부터 검사하고, 실패하면 어떤 제약이 깨졌는지를 먼저 알려 줍니다. 같은 이름의 오버로드를 타입 조건별로 나누는 일도 훨씬 간단해집니다.

#include <vector>
template <typename T>
T add(T a, T b) {
    return a + b;
}
int main() {
    add(std::vector<int>{}, std::vector<int>{});  // vector에는 operator+가 없음
}

add는 어떤 T든 받기 때문에 컴파일러는 T = std::vector<int>로 함수를 인스턴스화한 뒤 a + b에서 실패합니다. Concepts로 제약을 걸면 호출 시점에 바로 거절됩니다.

// g++ -std=c++20 -o concepts_add concepts_add.cpp && ./concepts_add
#include <concepts>
#include <iostream>
template <std::integral T>  // 정수 타입만 허용
T add(T a, T b) {
    return a + b;
}
int main() {
    std::cout << add(3, 5) << "\n";   // 8
    // add(3.14, 2.0);  // 주석을 풀면 컴파일 에러: std::integral 불만족
    return 0;
}

이 글의 예제는 모두 -std=c++20(GCC, Clang) 또는 /std:c++20(MSVC)으로 빌드합니다.

에러 메시지 비교

제약이 없을 때 add(std::vector<int>{}, std::vector<int>{})의 에러는 본문의 a + b를 가리킵니다. GCC 기준으로 대략 다음과 같고, 실제로는 후보 연산자 목록과 인스턴스화 경로가 이어집니다.

In instantiation of 'T add(T, T) [with T = std::vector<int>]':
  required from here
error: no match for 'operator+' (operand types are 'std::vector<int>' and 'std::vector<int>')

std::integral 제약을 건 버전은 호출 지점에서 후보가 없다고 말하고, 그 이유로 제약 조건을 보여 줍니다(문구는 컴파일러와 버전마다 다릅니다).

error: no matching function for call to 'add(std::vector<int>, std::vector<int>)'
note: candidate: 'template<class T>  requires  integral<T> T add(T, T)'
note:   template argument deduction/substitution failed:
note: constraints not satisfied
note:   the expression 'is_integral_v<_Tp> [with _Tp = std::vector<int>]' evaluated to 'false'

이 예제처럼 짧은 함수에서는 길이 차이가 크지 않지만, 템플릿이 여러 단계로 중첩된 라이브러리에서는 차이가 커집니다. 제약이 없으면 에러가 가장 안쪽 템플릿에서 나고, 제약이 있으면 사용자가 호출한 바로 그 자리에서 납니다.

Concepts와 SFINAE/enable_if

C++20 이전에는 SFINAE(Substitution Failure Is Not An Error, 템플릿 인자 치환이 실패하면 그 후보만 제외하고 에러로 보지 않는 규칙)를 std::enable_if와 함께 써서 “이 타입일 때만 이 오버로드를 쓴다”를 표현했습니다.

#include <type_traits>
// 정수만 받는 add: enable_if가 반환 타입에 붙어 있음
template <typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
add(T a, T b) {
    return a + b;
}
// 실수만 받는 add
template <typename T>
typename std::enable_if<std::is_floating_point_v<T>, T>::type
add(T a, T b) {
    return a + b;
}

의도는 “정수일 때만”, “실수일 때만”인데, 그 의도가 반환 타입 자리의 긴 식 속에 묻혀 있습니다. 두 후보가 모두 치환 실패로 빠지면 에러도 “no matching function” 정도로만 나옵니다. 같은 내용을 Concepts로 쓰면 다음과 같습니다.

#include <concepts>
template <std::integral T>
T add(T a, T b) { return a + b; }
template <std::floating_point T>
T add(T a, T b) { return a + b; }
구분SFINAE / enable_ifConcepts (C++20)
가독성typename std::enable_if<std::is_integral_v<T>, T>::type 같은 식이 반환 타입이나 기본 템플릿 인자에 숨음template <std::integral T>처럼 선언에 드러남
에러 메시지후보가 치환 실패로 빠졌다는 정도만 표시어떤 제약이 거짓으로 평가됐는지 표시
조건 조합trait을 std::conjunction 등으로 묶어야 함requires A<T> && B<T>
오버로드 우선순위조건이 겹치면 직접 상호 배타적으로 만들어야 함더 구체적인 제약을 컴파일러가 판별(subsumption)

새로 작성하는 C++20 코드라면 Concepts를 쓰고, 레거시 코드의 enable_if는 같은 의도를 옛 문법으로 적은 것으로 읽으면 됩니다.

Concepts가 도움이 되는 전형적인 상황

process_data(T& data)처럼 템플릿 매개변수에 아무 제약이 없는 함수에 std::unique_ptr<int>를 넘기면, 본문 어딘가의 복사에서 에러가 납니다. 사용하는 쪽은 그 함수가 복사 가능한 타입을 요구한다는 사실을 본문을 읽고서야 알게 됩니다. 선언에 template <std::copyable T>가 있었다면 호출 지점에서 바로 “std::copyable을 만족하지 않음”이 나왔을 것입니다.

std::list의 반복자를 내부에서 std::sort를 호출하는 함수에 넘기는 경우도 비슷합니다. std::sort는 임의 접근 반복자가 필요한데, 에러는 operator-를 찾지 못했다는 식으로 나와 원인을 바로 알기 어렵습니다. template <std::random_access_iterator It>로 제약을 걸면 원인이 그대로 보입니다.

정수·실수·문자열을 각각 다르게 처리하는 serialize(T value) 오버로드를 enable_if로 나누면, 조건이 겹치지 않도록 일일이 조정해야 하고 어떤 타입에 어떤 버전이 선택되는지 추적하기 어렵습니다. std::integral, std::floating_point, std::convertible_to<T, std::string>으로 나누면 선택 규칙이 선언에 그대로 드러납니다.


Concept: 템플릿 인자에 거는 이름 있는 조건

타입에 대한 조건

Concept은 타입이 만족해야 하는 조건을 표현합니다. “이 함수는 정수 타입만 받는다”, “이 클래스는 복사 가능한 타입만 받는다”는 요구 사항을 선언에 적어 두면, 컴파일러가 그 조건을 컴파일 타임에 검사합니다.

flowchart TD
  A[템플릿 호출] --> B{Concept 제약 검사}
  B -->|만족| C[템플릿 인스턴스화]
  B -->|불만족| D["후보에서 제외, 모든 후보가 빠지면 어떤 제약이 깨졌는지 표시"]
  C --> E[정상 실행]

아래 square는 std::integral로 정수 타입만 받으므로 int, unsigned, long, char 등에서 동작하고, half는 std::floating_point로 float, double, long double만 받습니다.

#include <concepts>
// 정수 타입만 허용 (int, unsigned, long, char, bool 등)
template <std::integral T>
T square(T x) {
    return x * x;
}
// 실수 타입만 허용 (float, double, long double)
template <std::floating_point T>
T half(T x) {
    return x / 2;
}

square는 실수에서도 문제없이 동작할 연산이지만, 제약을 걸면 “정수 전용”이라는 의도가 드러나고 나중에 실수용 square를 따로 오버로드하기도 쉽습니다. half를 실수로 제한한 이유는 정수 half(3)이 정수 나눗셈으로 1이 되기 때문입니다. 호출하는 쪽이 1.5를 기대했다면 이것은 조용한 버그이므로, 아예 컴파일되지 않게 막는 편이 낫습니다.

두 가지 기본 문법

template <typename T>
requires std::integral<T>   // requires 절: 여러 조건을 조합할 때 유리
T add(T a, T b) { return a + b; }

// 위와 같은 의미 (제약된 템플릿 매개변수)
template <std::integral T>
T add(T a, T b) { return a + b; }

template <Concept T> 형태는 concept 하나를 그 매개변수에 적용할 때 씁니다(template <std::convertible_to<int> T>처럼 추가 인자를 줄 수도 있습니다). std::integral<T> && (sizeof(T) >= 4)처럼 여러 조건을 조합하려면 requires 절을 씁니다.


requires 절과 requires 표현식

requires라는 키워드는 두 가지 다른 용도로 쓰입니다.

  1. requires 절은 템플릿 선언에 붙어 “이 조건이 참일 때만 이 템플릿을 쓴다”를 나타냅니다.
  2. requires 표현식은 “이 타입에 이런 연산이나 멤버가 있는가”를 검사하는 bool 상수 식으로, 주로 새 concept을 정의할 때 씁니다.

여러 조건 조합

표준 concept이나 sizeof, 타입 트레이트를 &&, ||로 묶을 수 있습니다.

#include <concepts>
template <typename T>
requires std::integral<T> && (sizeof(T) >= 4)
T add(T a, T b) {
    return a + b;
}
// 일반적인 데스크톱 플랫폼에서 int, long, long long은 통과하고 char, short는 제외됨

requires 절에는 concept-id(std::integral<T>)나 true, 괄호로 감싼 식 같은 기본 식(primary expression)만 &&, ||로 이어 쓸 수 있습니다. sizeof(T) >= 4처럼 연산자가 들어간 식은 반드시 괄호로 감싸야 합니다. sizeof(int)가 4라는 것은 표준이 보장하지 않으므로(최소 16비트만 보장), 정말 크기가 중요한 코드라면 std::int32_t 같은 고정 폭 타입을 쓰거나 이런 제약을 둔 이유를 주석으로 남겨 두는 것이 좋습니다.

requires 표현식 (연산 요구 사항)

“T 타입에 이 연산이 있는가”를 검사하려면 requires 표현식으로 concept을 정의합니다. requires(매개변수...) { 요구 사항; } 형태이며, 안에 적은 식이 유효한지만 검사하고 실제로 실행하지는 않습니다.

template <typename T>
concept Addable = requires(T a, T b) {
    a + b;  // a + b가 유효한 식이어야 함
};
template <Addable T>
T add(T a, T b) {
    return a + b;
}

int, double은 +가 있으므로 Addable을 만족하고, std::vector<int>는 만족하지 않으므로 add 호출이 제약 불만족으로 거절됩니다. 표준에 맞는 concept이 없을 때 이렇게 도메인 조건을 정의해 재사용하면 됩니다. 커스텀 concept 작성은 다음 글 #22-2에서 자세히 다룹니다.

타입 요구 사항 (멤버 타입 존재 여부)

T 안에 특정 타입이 있는지만 검사할 때는 requires 표현식 안에 typename T::이름을 씁니다. STL 스타일 컨테이너에 value_type, iterator를 요구할 때 유용합니다.

#include <iostream>
#include <typeinfo>
template <typename T>
concept HasValueType = requires {
    typename T::value_type;  // T::value_type이 존재해야 함
};
// vector, list, set 등은 모두 value_type을 가짐
template <HasValueType C>
void print_element_type() {
    std::cout << typeid(typename C::value_type).name() << '\n';
}

T::value_type은 템플릿 매개변수에 의존하는 이름이라 컴파일러가 그것이 타입인지 값인지 알 수 없으므로, typename을 붙여 타입이라고 알려 줘야 합니다.


표준 concept과 계층 구조

<concepts>, <iterator>, <ranges> 헤더에는 타입 분류와 연산 가능 여부를 나타내는 concept이 정의되어 있습니다. 직접 조건을 짜기 전에 표준에 맞는 concept이 있는지부터 확인하는 것이 좋습니다. 표준 concept은 이름만으로 의미가 통하고, 표준이 정의한 포함 관계 덕분에 오버로드 우선순위도 자연스럽게 정해집니다.

자주 쓰는 표준 concept

concept의미쓰는 곳
std::integral<T>정수 타입 (bool, char 포함)정수 전용 연산
std::floating_point<T>float, double, long double실수 전용 연산
std::copyable<T>복사·이동 생성과 대입 가능값을 복제해 저장하는 코드
std::movable<T>이동 생성·대입과 swap 가능한 객체 타입소유권을 넘겨받는 코드
std::default_initializable<T>기본 초기화 가능T t;가 필요한 코드
std::same_as<T, U>T와 U가 같은 타입두 타입이 반드시 같아야 할 때
std::convertible_to<T, U>T가 U로 암시적·명시적 변환 가능다른 타입으로 바꿔 쓸 때

std::default_constructible이라는 이름은 C++20 표준 확정 직전에 std::default_initializable로 바뀌었으므로, 오래된 자료에서 전자를 보면 후자로 읽으면 됩니다.

<concepts>에는 “정수 또는 실수”를 뜻하는 concept이 없습니다. std::arithmetic이라는 이름은 표준에 존재하지 않고, 비슷한 것은 타입 트레이트 std::is_arithmetic_v뿐입니다. 정수·실수를 모두 받는 제약이 필요하면 직접 정의합니다. 이 글의 이후 예제에서 쓰는 arithmetic은 모두 아래 정의를 가리킵니다.

template <typename T>
concept arithmetic = std::integral<T> || std::floating_point<T>;

concept arithmetic = std::is_arithmetic_v<T>;로 정의해도 받는 타입은 같지만, 그렇게 하면 std::integral과의 포함 관계를 컴파일러가 알 수 없어서 뒤에서 볼 “정수용 특화 오버로드”가 모호한 호출 에러를 냅니다. 표준 concept을 ||로 묶어 두어야 std::integral<T>가 arithmetic<T>보다 더 구체적이라는 관계가 성립합니다. 또 bool과 char도 std::integral을 만족하므로, 수치 연산에서 이들을 빼고 싶다면 std::integral<T> && !std::same_as<T, bool>처럼 좁혀야 합니다.

std::copyable<T>는 정의상 std::movable<T>를 포함합니다. 이동은 되지만 복사는 안 되는 타입만 받고 싶다면 std::movable<T> && !std::copyable<T>처럼 조합합니다.

표준 concept과 requires 절을 함께 쓰는 예제

// concepts_demo.cpp
#include <concepts>
#include <iostream>
#include <list>
#include <ranges>
#include <vector>
template <typename T>
concept arithmetic = std::integral<T> || std::floating_point<T>;
// 정수만: % 연산이 정의된 타입
template <std::integral T>
T mod(T a, T b) {
    return a % b;
}
// 실수만
template <std::floating_point T>
T reciprocal(T x) {
    return T{1} / x;
}
// 여러 조건 조합
template <typename T>
requires std::integral<T> && (sizeof(T) >= 4)
T multiply(T a, T b) {
    return a * b;  // 오버플로 검사는 하지 않음
}
// 복사 가능한 타입만
template <std::copyable T>
void push_twice(std::vector<T>& v, const T& x) {
    v.push_back(x);
    v.push_back(x);
}
// 순회 가능한 것 (vector, list, C 배열 등)
template <std::ranges::range R>
void print_range(const R& r) {
    for (const auto& x : r)
        std::cout << x << " ";
    std::cout << "\n";
}
// range이면서 요소가 산술 타입
template <typename R>
requires std::ranges::range<R> && arithmetic<std::ranges::range_value_t<R>>
double sum(const R& r) {
    double acc = 0;
    for (const auto& x : r) acc += static_cast<double>(x);
    return acc;
}
int main() {
    std::cout << mod(7, 3) << "\n";           // 1
    std::cout << reciprocal(2.0) << "\n";     // 0.5
    std::cout << multiply(5, 6) << "\n";      // 30
    std::vector<int> v;
    push_twice(v, 42);
    print_range(v);                           // 42 42
    std::cout << sum(std::list<int>{1,2,3}) << "\n"; // 6
}
1
0.5
30
42 42
6

multiply는 제약으로 타입만 거를 뿐 곱셈 결과가 범위를 넘는지는 검사하지 않습니다. 제약은 타입에 대한 조건이고 값에 대한 조건이 아니라는 점을 기억해 두세요.

표준 concept 사이의 관계

표준 concept 중 일부는 다른 concept을 정의 안에 포함하는 방식으로 쌓여 있습니다. 화살표는 “오른쪽이 왼쪽을 포함한다(더 구체적이다)“는 뜻입니다.

flowchart TD
  subgraph type[타입 분류]
    A["std integral"] --> C["arithmetic (직접 정의)"]
    B["std floating_point"] --> C
  end
  subgraph object[객체 속성]
    E["std movable"] --> F["std copyable"]
    F --> G["std semiregular"]
    G --> H["std regular"]
  end

이 그림에서 arithmetic은 표준이 아니고 위에서 직접 정의한 것이며, 화살표 방향이 반대입니다(std::integral을 만족하면 arithmetic도 만족). std::semiregular는 std::copyable에 기본 초기화 가능 조건을 더한 것이고, std::regular는 여기에 == 비교 가능 조건을 더한 것입니다. std::totally_ordered는 <, <=, >, >=, ==, !=가 모두 정의되고 전순서를 이루는 타입을 뜻합니다. 산술 타입은 이를 만족하지만 arithmetic concept과 포함 관계가 있는 것은 아닙니다.

ranges 관련 concept 계층

<ranges>의 concept은 반복자 종류에 따라 단계적으로 구체화됩니다.

flowchart TD
  R["std ranges range"] --> IR["std ranges input_range"]
  IR --> FR["std ranges forward_range"]
  FR --> BR["std ranges bidirectional_range"]
  BR --> RAR["std ranges random_access_range"]
  RAR --> CR["std ranges contiguous_range"]
concept의미예시
input_range앞으로 한 번 읽기std::ranges::istream_view<int>
forward_range여러 번 순회 가능std::forward_list
bidirectional_range앞뒤로 이동 가능std::list, std::map
random_access_range상수 시간 임의 접근std::deque
contiguous_range연속 메모리std::vector, std::array, C 배열

순회만 하면 되는 함수는 std::ranges::input_range(또는 range)를, 인덱스로 접근해야 하는 함수는 random_access_range를, 메모리를 통째로 넘겨야 하는 함수(예: 바이너리 직렬화, C API 호출)는 contiguous_range를 요구하면 됩니다.

복사 가능 타입만 저장, 변환 가능한 타입만 받기

#include <concepts>
#include <vector>
template <std::copyable T>
void store(T value) {
    std::vector<T> v;
    v.push_back(value);  // 복사
}
// U는 인자에서 추론할 수 없으므로 앞에 두고 호출 시 명시
template <typename U, typename T>
requires std::convertible_to<T, U>
U convert(T t) {
    return static_cast<U>(t);
}
// auto n = convert<int>(3.14);  // U = int, T = double로 추론 → 3

반환 타입에만 쓰이는 템플릿 매개변수는 인자로부터 추론되지 않으므로, 호출할 때 명시해야 하는 매개변수를 템플릿 매개변수 목록의 앞쪽에 두는 것이 관례입니다.


오버로드와 auto 반환에 Concept 적용하기

정수용과 실수용 오버로드 나누기

같은 함수 이름으로 타입별로 다른 구현을 두고 싶을 때 concept으로 오버로드를 나눕니다.

template <std::integral T>
T add(T a, T b) {
    return a + b;
}
template <std::floating_point T>
T add(T a, T b) {
    return a + b;
}
// add(3, 5) → integral 버전, add(3.0, 5.0) → floating_point 버전

std::integral과 std::floating_point를 동시에 만족하는 타입은 없으므로, 어떤 호출이든 제약을 만족하는 후보는 많아야 하나입니다. 우선순위를 따질 필요가 없는 가장 단순한 경우입니다. 두 제약이 겹치는 경우는 뒤의 subsumption 절에서 다룹니다.

auto 반환과 정수 승격

제약이 걸린 템플릿에서도 auto 반환을 쓸 수 있습니다.

template <std::integral T>
auto square(T x) {
    return x * x;
}
// square(3)    → int
// square(3L)   → long
// square(short{3}), square('a') → int (정수 승격)

int보다 작은 정수 타입은 산술 연산 전에 int로 승격되므로, T가 short나 char이면 auto로 추론되는 반환 타입은 T가 아니라 int입니다. 반환 타입을 반드시 T로 맞추고 싶다면 앞의 예제처럼 반환 타입을 T로 적고, 그 경우 결과가 T로 다시 좁혀진다는 점을 감안해야 합니다.


수치 라이브러리, range 제약, 반복자 제약 예시

예시 1: 정수·실수 모두 받는 함수

#include <cmath>
#include <concepts>
// 산술 타입(정수·실수) 모두 받는 절댓값
template <arithmetic T>
T my_abs(T x) {
    if constexpr (std::integral<T>) {
        return x < 0 ? -x : x;  // 부호 있는 타입의 최솟값(INT_MIN 등)은 표현 불가: UB
    } else {
        return std::abs(x);     // 실수용 std::abs 오버로드
    }
}
// my_abs(-3)    → 3
// my_abs(-3.14) → 3.14

std::integral만 쓰면 double을, std::floating_point만 쓰면 int를 받을 수 없으므로 둘을 합친 arithmetic을 쓰고, 본문에서 if constexpr로 갈라 처리합니다. 정수 버전의 주석처럼, -x는 2의 보수에서 최솟값을 표현할 수 없어 정의되지 않은 동작입니다. 제약은 이런 값 수준의 문제를 막아 주지 않습니다.

예시 2: 순회 가능한 것만 받기

std::ranges::range는 std::ranges::begin/end를 호출할 수 있는 타입을 뜻하며, 표준 컨테이너, C 배열, begin/end를 제공하는 사용자 정의 타입이 모두 해당합니다.

#include <iostream>
#include <list>
#include <ranges>
#include <vector>
template <typename R>
requires std::ranges::range<R>
void print(const R& r) {
    for (const auto& x : r) {
        std::cout << x << " ";
    }
    std::cout << "\n";
}
int main() {
    print(std::vector<int>{1, 2, 3});
    print(std::list<double>{1.0, 2.0});
    int arr[] = {10, 20, 30};
    print(arr);  // C 배열도 range
}

정확히는 이 함수가 const R&로 받으므로 const R이 range여야 합니다. 일부 view(std::views::filter의 결과 등)는 const로 순회할 수 없어서, view까지 받는 범용 함수는 R&&(전달 참조)로 받는 것이 일반적입니다.

예시 3: 반복자 구간 [first, last) 받기

#include <iterator>
template <std::input_iterator It, std::sentinel_for<It> S>
void process(It first, S last) {
    for (; first != last; ++first) {
        // *first로 요소 접근
    }
}

C++20의 반복자 concept은 끝을 나타내는 타입이 시작 반복자와 달라도 되도록 설계되어 있어서, 범용 알고리즘이라면 끝 타입을 별도 매개변수 S로 두고 std::sentinel_for<S, It>로 제약합니다. 같은 타입이면 충분한 코드라면 It first, It last로 써도 됩니다.


Subsumption, enable_if가 여전히 필요한 경우

Subsumption: 겹치는 제약 사이의 우선순위

두 오버로드의 제약이 겹칠 때, 한쪽 제약이 다른 쪽을 포함(subsume)하면 컴파일러는 더 구체적인 쪽을 고릅니다.

#include <concepts>
#include <iostream>
template <typename T>
concept arithmetic = std::integral<T> || std::floating_point<T>;

template <arithmetic T>
void process(T value) {
    std::cout << "Arithmetic: " << value << '\n';
}

// std::integral<T>는 arithmetic<T>를 포함하므로 더 구체적
template <std::integral T>
void process(T value) {
    std::cout << "Integral: " << value << '\n';
}

int main() {
    process(3);      // Integral (둘 다 만족하지만 더 구체적인 쪽)
    process(3.14);   // Arithmetic (integral 버전은 제약 불만족)
}

컴파일러는 제약을 concept 정의까지 펼쳐 &&, ||로 연결된 원자 제약(atomic constraint)들로 정규화한 뒤 비교합니다. arithmetic<T>는 std::integral<T> || std::floating_point<T>로 펼쳐지므로, std::integral<T>가 참이면 arithmetic<T>도 참이라는 것을 구조만 보고 알 수 있습니다.

반면 std::is_integral_v<T> 같은 bool 식은 그 자체가 원자 제약이어서, 컴파일러는 식의 내용을 들여다보지 않습니다. 게다가 두 원자 제약이 같다고 인정되려면 소스의 같은 위치에 쓰인 같은 식이어야 합니다. 그래서 다음 코드는 모호한 호출이 됩니다.

#include <type_traits>
// 모호함: 두 bool 식 사이의 포함 관계를 컴파일러가 알 수 없음
template <typename T>
requires std::is_arithmetic_v<T>
void process(T value) { /* 일반 */ }

template <typename T>
requires std::is_integral_v<T>
void process(T value) { /* 정수 특화 */ }

// process(3);  // 에러: ambiguous

// 해결: concept으로 표현해 포함 관계가 드러나게 함
template <arithmetic T>
void process2(T value) { /* 일반 */ }

template <std::integral T>
void process2(T value) { /* 정수 특화 */ }

문제는 requires 절을 쓴 것이 아니라 트레이트 식을 쓴 것입니다. requires std::integral<T>처럼 requires 절에 concept을 적으면 subsumption은 정상적으로 동작합니다. 겹치는 오버로드를 만들 때는 조건을 concept으로 정의하고, 더 구체적인 쪽이 덜 구체적인 쪽의 concept을 &&로 포함하도록 만들면 됩니다.

enable_if가 아직 필요한가

대부분의 경우 Concepts가 SFINAE를 대체하지만, 둘 다 해결하지 못하는 경우가 있어 헷갈리기 쉽습니다. 제약을 검사하다가 다른 템플릿의 인스턴스화 안에서 에러가 나면, SFINAE든 Concepts든 그 에러를 “조건 불만족”으로 바꿔 주지 못하고 컴파일이 그대로 실패합니다. 대표적인 예가 std::make_unsigned입니다. 정수(bool 제외)나 열거형이 아닌 타입을 넣으면 make_unsigned 내부에서 에러가 나기 때문에, 선언부의 바로 그 자리(immediate context)에서 검사되지 않습니다.

// T = std::string이면 make_unsigned 내부에서 에러 → 조건 false가 아니라 컴파일 실패
template <typename T>
concept HasUnsigned = requires { typename std::make_unsigned_t<T>; };

// enable_if로 같은 검사를 해도 마찬가지
template <typename T, typename = std::make_unsigned_t<T>>
auto to_unsigned_bad(T val);

해결법은 enable_if로 돌아가는 것이 아니라 전제 조건을 먼저 검사하는 것입니다. Concepts의 &&는 왼쪽이 거짓이면 오른쪽을 아예 검사하지 않으므로 이런 경우에 오히려 더 편합니다.

#include <concepts>
#include <type_traits>
// bool은 std::integral이지만 make_unsigned의 대상이 아니므로 함께 제외
template <typename T>
concept UnsignedConvertible =
    std::integral<T> && !std::same_as<std::remove_cv_t<T>, bool>;

template <UnsignedConvertible T>
auto to_unsigned(T val) { return static_cast<std::make_unsigned_t<T>>(val); }

C++17 이하에서 enable_if로 쓸 때도 원리는 같습니다. std::enable_if_t<std::is_integral_v<T>>처럼 먼저 걸러 내고, make_unsigned_t는 함수 본문에서만 쓰면 됩니다. 새 코드에서 enable_if가 필요한 경우는 사실상 C++20을 쓸 수 없을 때뿐입니다.

상황방법예시
타입 분류 검사표준 conceptstd::integral<T>
멤버 함수 존재 검사requires 표현식requires(T t) { t.foo(); }
내부에서 에러가 나는 trait 사용전제 조건을 먼저 &&로 검사std::integral<T> && ...
C++17 이하 코드베이스enable_if, void_tstd::enable_if_t<...>
실패 이유를 자세히 보여 주고 싶을 때concept + 본문의 static_assert아래 예제 참고

제약을 이름 있는 concept으로 빼 두는 이유

requires (std::is_integral_v<T> && sizeof(T) >= 4 && std::is_signed_v<T>)처럼 제약을 매번 풀어 쓰는 대신 concept BigSignedInt = ...;로 이름을 붙여 두면 세 가지가 달라집니다.

  • 컴파일러가 “BigSignedInt<short>를 만족하지 않음”처럼 이름으로 알려 주므로, 긴 식 전체를 읽지 않아도 무엇이 틀렸는지 보입니다.
  • 앞에서 본 것처럼, 두 오버로드의 제약이 같은 concept을 포함 관계로 쓰고 있어야 컴파일러가 더 구체적인 쪽을 고를 수 있습니다. 같은 식을 두 곳에 풀어 쓰면 서로 다른 제약으로 취급되어 모호한 호출이 됩니다.
  • 제약은 함수 템플릿 시그니처의 일부라 이를 이름 맹글링에 반영하는 컴파일러에서는 긴 식이 심볼에 그대로 들어갑니다. 이름을 붙이면 심볼과 스택 트레이스가 짧고 읽기 쉬워집니다. 다만 이것이 바이너리 크기나 링크 시간에 주는 영향은 프로젝트마다 달라서, 이 이유만으로 선택할 정도는 아닙니다.

concept과 static_assert 조합

concept은 만족 여부만 알려 주므로, 여러 조건 중 무엇이 빠졌는지를 사람이 읽기 좋은 문장으로 보여 주고 싶다면 본문의 static_assert로 보완할 수 있습니다.

#include <concepts>
template <typename T>
concept Container = requires(T c) {
    typename T::value_type;
    { c.begin() } -> std::same_as<typename T::iterator>;
    { c.end() } -> std::same_as<typename T::iterator>;
};

template <Container T>
void process_container(T& container) {
    static_assert(std::copyable<typename T::value_type>,
        "Container value_type must be copyable for this algorithm");

    static_assert(requires(T& c) { c.size(); },
        "Container must have size()");

    // 실제 로직...
}

차이는 static_assert가 오버로드 선택에는 관여하지 않는다는 점입니다. 함수가 후보로 선택된 뒤 본문이 인스턴스화될 때 실패하므로, 다른 오버로드로 넘어가야 하는 조건이라면 반드시 선언의 제약에 넣어야 합니다.

제약을 적는 네 가지 문법

// 1. 제약된 템플릿 매개변수
template <std::integral T>
void foo(T x);

// 2. requires 절 (template 선언 뒤)
template <typename T> requires std::integral<T>
void foo(T x);

// 3. 후행 requires (함수 선언자 뒤)
template <typename T>
void foo(T x) requires std::integral<T>;

// 4. 축약 함수 템플릿 (constrained auto)
void foo(std::integral auto x);

네 문법이 표현할 수 있는 조건은 같고, 위 네 선언은 모두 같은 함수 템플릿을 선언합니다. 제약된 템플릿 매개변수는 조건이 하나일 때 가장 읽기 쉽고, requires 절은 여러 조건을 조합할 때 명확합니다. 후행 requires는 클래스 템플릿의 멤버 함수처럼 함수 자체는 템플릿이 아니지만 클래스의 템플릿 매개변수에 조건을 걸어야 할 때 필요합니다. 축약 문법은 짧은 함수나 람다에 어울립니다. 팀 안에서는 한 가지 스타일로 통일하고, 복잡한 제약은 named concept으로 빼는 편이 읽기 좋습니다.

축약 문법에는 한 가지 다른 점이 있습니다. auto 매개변수 하나마다 독립된 템플릿 매개변수가 생기므로, void f(std::integral auto a, std::integral auto b)는 a가 int, b가 long이어도 호출됩니다. 두 인자가 같은 타입이어야 한다면 template <std::integral T> void f(T a, T b)처럼 명시적인 템플릿 매개변수를 써야 합니다.

concept이 검사하는 것은 문법적 조건(그 식이 컴파일되는가)뿐이라는 점도 기억해야 합니다. { a < b } -> std::convertible_to<bool>을 요구해도 정렬에 필요한 엄격 약순서(a < a가 거짓이고 추이적일 것) 같은 의미적 조건은 컴파일러가 확인하지 않습니다. NaN이 섞인 double은 < 연산은 되지만 순서 조건을 깨뜨려 정렬 결과를 망가뜨리고, 표준의 std::totally_ordered나 std::sortable도 의미적 조건은 문서상의 약속일 뿐입니다. 마찬가지로 std::integral<T>로 제약한 평균 함수 안에서 sum / values.size()처럼 부호 없는 size_t와 섞어 나누면 음수 합이 큰 양수로 바뀌는 버그는 제약이 전혀 막아 주지 못합니다.


예시 4: 비교 가능한 타입

#include <concepts>
template <std::totally_ordered T>
const T& max_of(const T& a, const T& b) {
    return a < b ? b : a;
}

예시 5: 데이터 처리 파이프라인

#include <numeric>
#include <ranges>
#include <vector>
// 요소 타입이 산술 타입인 range만 받는 평균
template <std::ranges::forward_range R>
requires arithmetic<std::ranges::range_value_t<R>>
double mean(const R& r) {
    auto n = std::ranges::distance(r);
    if (n == 0) return 0.0;
    double sum = std::accumulate(std::ranges::begin(r), std::ranges::end(r), 0.0);
    return sum / static_cast<double>(n);
}
// mean(std::vector<int>{1,2,3,4,5}) → 3.0

std::ranges::range_value_t<R>는 range R의 요소 타입입니다. arithmetic으로 정수 또는 실수만 허용하므로 문자열 컨테이너가 넘어오면 컴파일 시점에 에러가 납니다. 합계의 초깃값을 요소 타입(Val{})으로 두면 int 합이 넘칠 수 있고 결과도 int로 계산되므로, 처음부터 0.0으로 시작해 double로 누적합니다. 거리 계산 후 다시 순회하므로 한 번만 읽을 수 있는 input_range가 아니라 forward_range를 요구했습니다.

예시 6: 클래스 템플릿에 Concept 적용

#include <concepts>
#include <cstddef>
#include <vector>
template <std::copyable T>
class TypedBuffer {
    std::vector<T> data_;
public:
    void push(const T& value) {
        data_.push_back(value);  // 복사
    }
    const T& at(std::size_t i) const { return data_.at(i); }
};
// TypedBuffer<std::unique_ptr<int>> b1;  // 에러: unique_ptr는 copyable이 아님
// TypedBuffer<int> b2;                   // OK

std::unique_ptr는 이동만 가능하고 복사는 불가능하므로, 값을 복사해 보관하는 클래스에 실수로 넣는 것을 선언 시점에 막을 수 있습니다.


자주 만나는 제약 관련 에러

정수와 실수를 섞어 호출

add(3, 5.0)처럼 정수와 실수를 섞어 add(T, T) 오버로드를 호출하면, 첫 인자에서 T = int, 둘째 인자에서 T = double로 추론이 충돌해 두 후보 모두 탈락합니다. 에러는 모호한 호출이 아니라 “no matching function”과 “deduced conflicting types for parameter ‘T‘“로 나옵니다. 제약이 검사되기 전에 템플릿 인자 추론 단계에서 실패하는 것입니다.

// 방법 1: 호출 시 명시적 캐스팅
add(3.0, 5.0);   // 또는 add(3, static_cast<int>(5.0));
// 방법 2: 서로 다른 타입을 받는 오버로드 추가
template <typename T, typename U>
requires arithmetic<T> && arithmetic<U>
auto add(T a, U b) {
    return a + b;  // 반환 타입은 일반 산술 변환 결과 (int + double → double)
}

방법 2의 오버로드를 추가해도 add(3, 5)는 여전히 add(T, T) 버전이 선택됩니다. 두 매개변수가 같은 타입이어야 하는 쪽이 부분 순서 규칙상 더 특수화된 템플릿이기 때문입니다.

컨테이너에 원소용 제약을 건 경우

std::vector<int>를 넘겼는데 “does not satisfy std::integral” 에러가 난다면, 원소 타입에 걸어야 할 제약을 컨테이너 타입에 건 것입니다. 컨테이너를 받으려면 std::ranges::range로 제약하고, 원소 타입 조건은 std::ranges::range_value_t<R>에 따로 겁니다.

#include <concepts>
#include <ranges>
template <std::ranges::range R>
requires std::integral<std::ranges::range_value_t<R>>
void process_range(const R& r) {
    for (const auto& x : r) { /* ... */ }
}

requires 절에서 괄호가 필요한 경우

requires std::integral<T> && std::copyable<T>는 괄호 없이도 올바른 코드입니다. 괄호가 필요한 것은 concept-id나 true 같은 기본 식이 아닌 식을 쓸 때입니다.

template <typename T>
requires std::integral<T> && sizeof(T) >= 4     // 에러: sizeof 식은 괄호 필요
void f(T t) {}

template <typename T>
requires std::integral<T> && (sizeof(T) >= 4)   // OK
void g(T t) {}

requires 표현식의 반환 타입 제약이 너무 엄격한 경우

{ a + b } -> std::same_as<T>는 a + b의 타입이 정확히 T여야 한다는 뜻입니다. T가 short면 short + short는 정수 승격으로 int가 되므로 이 concept을 만족하지 못합니다.

#include <concepts>
// 결과를 T로 변환할 수 있으면 충분하다면
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};
// 식이 유효한지만 보면 된다면
template <typename T>
concept Addable2 = requires(T a, T b) {
    a + b;
};

-> std::same_as<T>처럼 쓰는 반환 타입 요구 사항에서 concept의 첫 인자는 식의 decltype((식))으로 자동으로 채워진다는 점도 알아 두면 좋습니다. 식이 lvalue를 돌려주면 참조 타입이 들어가므로, { *it } -> std::same_as<T>는 *it가 T&를 돌려주는 반복자에서 실패합니다.

range_value_t를 쓸 수 없다는 에러

std::ranges::range_value_t<R>는 R이 range일 때만 의미가 있습니다. <ranges> 헤더를 포함했는지, 그리고 R에 std::ranges::range 제약을 먼저 걸었는지 확인합니다. 위의 process_range처럼 range 제약을 왼쪽에 두면, 앞에서 본 &&의 단락 평가 덕분에 range가 아닌 타입에서는 range_value_t가 아예 검사되지 않습니다.

제약 있는 오버로드와 없는 오버로드

template <typename T> void f(T)와 template <std::integral T> void f(T)를 함께 정의하고 f(3)을 호출하면 제약이 있는 쪽이 선택됩니다. 두 템플릿의 시그니처가 같을 때는 더 제약된 쪽이 더 특수화된 것으로 취급되기 때문이고, 모호하지 않습니다.

모호한 호출이 나는 것은 시그니처가 다를 때입니다. 예를 들어 template <typename T> void f(T)와 template <std::integral T> void f(const T&)는 매개변수 타입이 달라서 컴파일러가 먼저 타입 기준 부분 순서를 따지는데, 그 단계에서 우열이 갈리지 않으면 제약 비교까지 가지 못하고 모호한 호출이 됩니다. 제약으로 우선순위를 정하려면 매개변수 형태를 똑같이 맞춰야 합니다. 상호 배타적으로 나누고 싶다면 다음처럼 반대 조건을 명시합니다.

#include <concepts>
template <std::integral T>
void f(T t) { /* 정수 전용 */ }

template <typename T>
requires (!std::integral<T>)
void f(T t) { /* 그 외 */ }

std::list에 std::ranges::sort 적용

std::ranges::sort는 std::ranges::random_access_range와 std::sortable을 요구합니다. std::list는 bidirectional_range이므로 사용할 수 없습니다.

#include <algorithm>
#include <list>
#include <vector>
std::list<int> lst = {3, 1, 2};
// std::ranges::sort(lst);  // 에러: random_access_range 불만족
// 방법 1: vector로 복사 후 정렬
std::vector<int> vec(lst.begin(), lst.end());
std::ranges::sort(vec);
// 방법 2: list::sort 멤버 함수 (노드 링크만 바꾸는 병합 정렬)
lst.sort();

movable만 요구했는데 복사를 사용

template <std::movable T> 함수 안에서 T t2 = t1;처럼 복사하면 std::unique_ptr 같은 타입에서 본문 인스턴스화가 실패합니다. 제약은 본문에서 쓰는 연산을 모두 포함해야 합니다.

#include <concepts>
#include <utility>
// 복사가 필요하면 std::copyable
template <std::copyable T>
void duplicate(const T& t) {
    T t2 = t;
}
// 이동만 필요하면 std::movable, 값으로 받아 소유권을 가져옴
template <std::movable T>
void take_ownership(T t) {
    T moved = std::move(t);
}

take_ownership(T&& t)처럼 전달 참조로 받으면 lvalue를 넘겼을 때 T가 U&로 추론되는데, std::movable은 객체 타입만 만족하므로 참조 타입인 U&는 제약을 통과하지 못합니다. 전달 참조에 제약을 걸 때는 std::movable<std::remove_cvref_t<T>>처럼 참조를 벗겨서 검사해야 합니다.

concept은 자기 자신을 참조할 수 없다

“원소도 같은 조건을 만족하는 컨테이너”처럼 재귀적인 조건을 concept 하나로 쓰고 싶어지는데, 표준은 concept이 자기 자신을 참조하는 것을 금지합니다(재귀 concept, concept에 대한 제약 모두 불가). 재귀가 필요하면 constexpr 변수 템플릿이나 타입 트레이트로 재귀를 구현하고, concept은 그 결과를 감싸기만 합니다.

#include <concepts>
#include <ranges>
#include <vector>
// 컴파일 에러: concept은 자기 자신을 참조할 수 없음
// template <typename T>
// concept Nested = std::ranges::range<T> && Nested<std::ranges::range_value_t<T>>;

// 재귀는 변수 템플릿으로, concept은 그 결과를 사용
template <typename T> constexpr bool is_nested_int = std::integral<T>;
template <std::ranges::range R> constexpr bool is_nested_int<R> = is_nested_int<std::ranges::range_value_t<R>>;

template <typename T>
concept NestedInts = is_nested_int<T>;
static_assert(NestedInts<std::vector<std::vector<int>>>);

제약을 설계할 때의 원칙

표준 concept부터 찾기

std::integral, std::copyable, std::ranges::range 같은 표준 concept은 코드베이스 전체에서 같은 의미로 읽히고, 표준이 정해 둔 포함 관계 덕분에 오버로드 우선순위도 예측 가능합니다. 표준 concept을 그대로 감싸기만 하는 별칭 concept(concept MyCopyable = std::copyable<T>;)은 읽는 사람이 정의를 한 번 더 찾아봐야 하므로, 도메인 의미를 더하는 경우가 아니라면 표준 이름을 그대로 쓰는 편이 낫습니다.

필요한 만큼만 요구하기

제약은 본문이 실제로 쓰는 연산까지만 요구하는 것이 좋습니다. 한 번 순회만 하는 함수에 random_access_range를 요구하면 std::list나 std::forward_list를 넘길 수 없게 됩니다.

#include <ranges>
// 한 번 순회하면 충분하면 input_range
template <std::ranges::input_range R>
void process(R&& r);
// random_access_range를 요구하면 list, forward_list는 사용 불가
template <std::ranges::random_access_range R>
void process_indexed(R&& r);

반대로 본문이 쓰는 연산보다 제약이 느슨하면, 제약은 통과하고 본문 인스턴스화에서 실패하는 원래의 문제가 그대로 돌아옵니다.

겹치는 커스텀 concept은 포함 관계로 설계하기

std::integral과 std::floating_point는 겹치지 않아 안전합니다. 커스텀 concept 여러 개로 오버로드를 나눌 때는 서로 겹치지 않게 만들거나, 겹친다면 더 구체적인 쪽이 덜 구체적인 쪽을 &&로 포함하도록 정의해야 합니다. 그렇지 않으면 두 조건을 모두 만족하는 타입에서 모호한 호출 에러가 납니다.

주석 대신 제약으로 의도 표현

“T는 정수여야 합니다”라는 주석은 코드가 바뀌어도 검사되지 않지만, template <std::integral T>는 컴파일러가 매번 검사합니다. 주석과 구현이 어긋날 일이 없습니다.


수치 API, 직렬화 모듈, 점진적 마이그레이션 패턴

패턴 1: 수치 함수 API

수학 함수 라이브러리에서는 sqrt, log는 실수만, gcd, lcm은 정수만 받는 것이 자연스럽습니다.

#include <cmath>
#include <concepts>
#include <numeric>
#include <stdexcept>
// 실수 전용
template <std::floating_point T>
T checked_sqrt(T x) {
    if (x < 0) throw std::domain_error("sqrt of negative");
    return std::sqrt(x);
}
// 정수 전용 (C++17 std::gcd)
template <std::integral T>
T int_gcd(T a, T b) {
    return std::gcd(a, b);
}

std::gcd 자체도 bool을 인자로 받으면 프로그램이 ill-formed가 된다고 명시되어 있으므로, bool이 들어올 수 있는 코드라면 앞에서처럼 bool을 제외하는 조건을 더해야 합니다.

패턴 2: 직렬화 가능한 타입

“직렬화 가능한 타입”은 표준에 없는 도메인 조건이므로 커스텀 concept으로 정의합니다. 자세한 작성법은 #22-2 커스텀 Concepts에서 다룹니다.

#include <concepts>
#include <ostream>
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
template <Serializable T>
void save(const T& obj, std::ostream& out) {
    obj.serialize(out);
}

패턴 3: 정렬·검색 알고리즘

#include <algorithm>
#include <concepts>
template <std::totally_ordered T>
void sort_range(T* first, T* last) {
    std::sort(first, last);
}

엄밀히는 std::sort가 요소의 이동과 swap도 요구하므로, 반복자와 비교 함수까지 일반화한 함수라면 표준의 std::sortable<I>를 쓰는 것이 정확합니다.

패턴 4: C++17/20 분기로 점진적 마이그레이션

레거시 프로젝트를 C++20으로 옮기는 중이라면 기능 검사 매크로로 Concepts 버전과 enable_if 버전을 나눌 수 있습니다.

#include <type_traits>
#if defined(__cpp_concepts) && __cpp_concepts >= 201907L
#include <concepts>
template <std::integral T>
T add(T a, T b) { return a + b; }
#else
template <typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
add(T a, T b) { return a + b; }
#endif

__cplusplus 값으로 분기하는 방법도 있지만, MSVC는 /Zc:__cplusplus 옵션을 주지 않으면 __cplusplus를 언어 버전과 무관하게 199711L로 정의하므로 기능 검사 매크로(__cpp_concepts)를 쓰는 편이 안전합니다.

요구 사항별 표준 concept

요구 사항concept비고
정수만std::integralbool, char 포함
실수만std::floating_pointfloat, double, long double
정수·실수 모두arithmetic (직접 정의)표준에 없음
복사 가능한 타입std::copyable값으로 보관할 때
이동 가능한 타입std::movableunique_ptr도 만족
기본 초기화 가능std::default_initializableT t; 필요할 때
순회 가능한 것std::ranges::rangefor-range, begin/end
반복자 구간std::input_iterator + std::sentinel_forSTL 스타일 알고리즘
비교 가능std::totally_ordered<, == 등
T를 U로 변환 가능std::convertible_to<T, U>변환 전 검사

컴파일 시간에 대해

Concepts는 컴파일 타임에만 존재하므로 런타임 비용은 없습니다. 컴파일 시간에 대해서는 일반화하기 어렵습니다. 제약을 만족하지 못한 후보는 함수 본문은 물론 반환 타입 같은 시그니처 일부도 치환하지 않고 탈락하므로(CWG 2369 결함 보고를 반영한 컴파일러 기준), enable_if로 복잡한 반환 타입 식을 매번 치환하던 코드보다 가벼워질 수 있습니다. 반면 깊게 중첩된 requires 표현식이나 많은 concept 조합은 그 자체로 검사 비용이 듭니다. 컴파일 시간이 중요하다면 바꾸기 전후를 직접 측정해 보는 것이 확실합니다. Clang의 -ftime-trace 옵션을 쓰면 템플릿 인스턴스화와 제약 검사에 쓰인 시간을 따로 볼 수 있습니다.

참고 자료


같이 보면 좋은 글