C++20 Constraints 실전: requires 절, 커스텀 Concept, 오버로드 선택과 에러 메시지

이 글의 핵심

Concepts와 requires 절로 템플릿 인자를 제약하는 방법, 커스텀 concept 작성, 제약이 오버로드 선택에 미치는 영향, 실무에서 겪는 문제와 해결법을 정리합니다.

들어가며

C++20의 Concepts는 템플릿 타입에 대한 명시적 제약을 정의하는 기능입니다. SFINAE보다 간결하며, 에러 메시지가 명확합니다. 비유로 말씀드리면, Concepts는 입장권 조건을 명확히 적어 놓은 것에 가깝습니다. “18세 이상”이라고 적으면, 17세가 입장하려 할 때 “나이 조건 불만족”이라고 명확히 알려줍니다. SFINAE는 “조건 불만족”이라고만 하고 무엇이 문제인지 알기 어렵습니다.

Concepts 이전에도 템플릿에는 암묵적인 요구 조건이 있었습니다. a + b를 쓰는 함수 템플릿은 “T에 +가 있어야 한다”는 조건을 이미 갖고 있지만, 그 조건이 코드 어디에도 선언되어 있지 않았습니다. 그래서 조건을 만족하지 않는 타입을 넣으면 컴파일러는 템플릿 본문을 인스턴스화하다가 본문 깊은 곳에서 실패하고, 그 실패 지점부터 호출 경로를 거꾸로 수십 줄에 걸쳐 출력했습니다. Concepts는 이 암묵적인 조건을 인터페이스의 일부로 선언하게 만듭니다. 호출 시점에 조건을 먼저 검사하므로 에러가 호출한 줄에서 “이 조건을 만족하지 않는다”는 형태로 나오고, 템플릿을 읽는 사람도 본문을 보지 않고 무엇이 필요한지 알 수 있습니다.


Concepts란?

기본 개념

Concepts는 템플릿 타입에 대한 명시적 제약입니다.

// C++20 이전: 에러 메시지 복잡
template<typename T>
T add(T a, T b) {
    return a + b;
}
add("hello", "world");  // 긴 에러 메시지
// C++20: Concepts
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};
template<Addable T>
T add(T a, T b) {
    return a + b;
}
add("hello", "world");  // 명확한 에러: Addable 만족 안함

add("hello", "world")에서 T는 const char*로 추론되고, 포인터 두 개를 더하는 연산은 C++에서 허용되지 않습니다. 제약이 없는 버전은 본문의 a + b에서 invalid operands of types 'const char*' and 'const char*' to binary 'operator+' 에러를 내는데, 이 정도면 짧은 편이지만 실제 라이브러리 템플릿에서는 이런 에러가 여러 단계의 내부 헬퍼 함수를 거쳐 나와 원인을 찾기 어렵습니다. Concept 버전은 “add의 제약 Addable<const char*>이 만족되지 않았고, 그 이유는 a + b가 유효하지 않기 때문”이라는 형태로 보고합니다.

requires(T a, T b) { ... }는 requires 표현식으로, 중괄호 안의 식들이 컴파일되는지만 검사하고 실제로 실행하지는 않습니다. a, b는 검사용 가상 변수입니다. { a + b } -> std::convertible_to<T>는 “식이 유효하고, 그 결과 타입이 T로 변환 가능해야 한다”는 복합 요구 조건이며, 화살표 오른쪽의 concept에는 식의 타입이 첫 번째 인자로 자동으로 채워집니다(std::convertible_to<decltype((a + b)), T>). 결과 타입 조건을 빼고 a + b;만 쓰면 더하기가 가능한지만 확인합니다.


실전 구현

표준 Concepts

#include <concepts>
#include <iostream>
// 정수 타입만 허용
template<std::integral T>
T square(T value) {
    return value * value;
}
int main() {
    std::cout << square(5) << std::endl;     // 25
    // std::cout << square(3.14) << std::endl;  // 에러: double은 integral 아님
    
    return 0;
}

커스텀 Concepts

#include <concepts>
#include <iostream>
#include <vector>
// 산술 타입
template<typename T>
concept Arithmetic = std::integral<T> || std::floating_point<T>;
// 평균 계산
template<Arithmetic T>
T average(const std::vector<T>& values) {
    if (values.empty()) return T{};
    
    T sum = 0;
    for (const auto& v : values) {
        sum += v;
    }
    return sum / values.size();
}
int main() {
    std::vector<int> ints = {1, 2, 3, 4, 5};
    std::cout << average(ints) << std::endl;  // 3
    
    std::vector<double> doubles = {1.5, 2.5, 3.5};
    std::cout << average(doubles) << std::endl;  // 2.5
    
    return 0;
}

std::integral과 std::floating_point는 <concepts>에 정의된 표준 concept이고, ||로 묶어 “둘 중 하나”를 표현했습니다. 표준에 이미 비슷한 트레잇(std::is_arithmetic_v)이 있지만 concept으로 이름을 붙여 두면 에러 메시지에 Arithmetic이라는 의도가 드러나고, 뒤에서 설명할 오버로드 우선순위 계산에도 참여할 수 있습니다.

이 예제는 concept이 타입만 검사할 뿐 로직의 정확성은 보장하지 않는다는 점을 보여 주기도 합니다. int 버전의 sum / values.size()에서 values.size()는 부호 없는 size_t라서, 나눗셈 전에 sum이 size_t로 변환됩니다. {-1, -2, -3}의 평균을 구하면 -2가 아니라 아주 큰 양수가 나옵니다. 또 정수 평균은 소수점이 버려지고({1, 2}의 평균이 1), bool도 std::integral을 만족하므로 average(std::vector<bool>)처럼 의도하지 않은 타입까지 통과합니다. static_cast<T>(values.size())로 나누거나 결과를 double로 돌려주고, bool을 빼고 싶다면 std::integral<T> && !std::same_as<T, bool>처럼 제약을 좁혀야 합니다.


requires 절

#include <concepts>
#include <iostream>
// requires 절 (간단)
template<typename T>
requires std::integral<T>
T square(T value) {
    return value * value;
}
// requires 표현식 (복잡)
template<typename T>
requires requires(T t) {
    { t.size() } -> std::convertible_to<size_t>;
}
size_t getSize(const T& container) {
    return container.size();
}
// 축약 함수 템플릿
auto square2(std::integral auto value) {
    return value * value;
}
int main() {
    std::cout << square(5) << std::endl;
    std::cout << square2(10) << std::endl;
    
    return 0;
}

제약을 거는 문법은 세 가지이며 의미는 같습니다. template<std::integral T>처럼 타입 매개변수 자리에 concept을 쓰는 방식이 가장 간결하고, requires std::integral<T>처럼 템플릿 머리 뒤에 requires 절을 두는 방식은 여러 매개변수 사이의 관계(requires std::same_as<T, U>)나 concept으로 이름 붙이지 않은 조건을 표현할 때 씁니다. std::integral auto는 C++20의 축약 함수 템플릿으로, auto 매개변수 하나마다 독립된 템플릿 매개변수가 생깁니다. 그래서 void f(std::integral auto a, std::integral auto b)는 a와 b가 서로 다른 정수 타입이어도 됩니다. 같은 타입을 강제하려면 명시적인 템플릿 매개변수를 써야 합니다.

requires requires라는 이중 표기는 처음 보면 오타처럼 보입니다. 앞의 requires는 requires 절을 시작하는 키워드이고, 뒤의 requires(T t) { ... }는 그 절의 조건으로 쓰인 requires 표현식입니다. 이 형태는 한 번만 쓰는 조건을 즉석에서 적을 때 쓰지만, 뒤의 “불명확한 에러 메시지” 절에서 보듯 이름 있는 concept으로 뽑아 두는 편이 에러 메시지와 재사용 면에서 대개 낫습니다.


복합 Concepts

#include <concepts>
#include <iostream>
// AND
template<typename T>
concept SignedIntegral = std::integral<T> && std::signed_integral<T>;
// OR
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;
// NOT
template<typename T>
concept NotPointer = !std::is_pointer_v<T>;
template<SignedIntegral T>
T abs(T value) {
    return value < 0 ? -value : value;
}
int main() {
    std::cout << abs(-5) << std::endl;  // 5
    
    return 0;
}

SignedIntegral은 설명을 위해 std::integral<T> && std::signed_integral<T>로 썼지만, 표준의 std::signed_integral은 이미 std::integral<T> && std::is_signed_v<T>로 정의되어 있어서 앞의 조건은 중복입니다. NotPointer의 !는 주의해서 써야 합니다. 부정된 조건은 컴파일러가 포함 관계를 계산할 때 하나의 불투명한 조건으로 취급되어, 뒤에서 설명할 오버로드 우선순위에서 기대한 대로 동작하지 않을 수 있습니다.

abs라는 이름도 실무에서는 피하는 편이 좋습니다. <iostream>이 구현에 따라 <cstdlib>를 함께 포함하면 전역 ::abs(int)가 보이는데, 오버로드 해석에서 템플릿이 아닌 함수는 똑같이 잘 맞는 템플릿보다 우선하므로 abs(-5)는 우리 템플릿이 아니라 C 라이브러리의 abs를 호출할 수 있습니다. 결과가 같아서 드러나지 않지만, 템플릿에 로그를 넣어 보면 호출되지 않는 것을 확인할 수 있습니다. 또 -value는 INT_MIN에서 오버플로가 나는 정의되지 않은 동작이라는 점도 abs 구현에서 흔히 놓치는 부분입니다.


고급 활용

컨테이너 Concept

#include <concepts>
#include <iostream>
#include <vector>
template<typename T>
concept Container = requires(T t) {
    typename T::value_type;
    { t.begin() } -> std::same_as<typename T::iterator>;
    { t.end() } -> std::same_as<typename T::iterator>;
    { t.size() } -> std::convertible_to<size_t>;
};
template<Container C>
void printContainer(const C& container) {
    for (const auto& elem : container) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
}
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    printContainer(vec);
    
    return 0;
}

typename T::value_type;은 타입 요구 조건으로, 그 중첩 타입이 존재하는지만 확인합니다. 이 concept은 표준 컨테이너에는 잘 맞지만 생각보다 많은 타입을 거부합니다. begin()의 반환 타입을 T::iterator와 정확히 같게 요구하므로 const std::vector<int>(begin이 const_iterator를 반환)는 통과하지 못하고, C 배열이나 std::span처럼 size()는 있지만 조건이 다른 타입, 멤버 begin() 대신 자유 함수 begin()만 제공하는 타입도 탈락합니다. 범위 기반 for에 쓸 수 있는지 확인하는 것이 목적이라면 직접 만들기보다 C++20 표준 라이브러리의 std::ranges::range나 std::ranges::sized_range를 쓰는 편이 이런 경계 사례를 모두 올바르게 처리합니다. printContainer는 원소를 std::cout으로 출력하므로 원소 타입이 출력 가능한지도 제약에 넣어야 에러 메시지가 완결됩니다.


정렬 가능 Concept

#include <concepts>
#include <iostream>
#include <vector>
#include <algorithm>
template<typename T>
concept Sortable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
};
template<Sortable T>
void bubbleSort(std::vector<T>& vec) {
    for (size_t i = 0; i < vec.size(); ++i) {
        for (size_t j = 0; j + 1 < vec.size(); ++j) {  // 빈 벡터에서 size()-1 언더플로 방지
            if (vec[j + 1] < vec[j]) {
                std::swap(vec[j], vec[j + 1]);
            }
        }
    }
}
int main() {
    std::vector<int> nums = {5, 2, 8, 1, 9};
    bubbleSort(nums);
    
    for (int n : nums) {
        std::cout << n << " ";
    }
    std::cout << std::endl;  // 1 2 5 8 9
    
    return 0;
}

원래 코드의 안쪽 루프 조건은 j < vec.size() - 1이었는데, 빈 벡터에서는 vec.size() - 1이 부호 없는 정수 언더플로로 SIZE_MAX가 되어 범위를 한참 벗어난 메모리에 접근합니다. Sortable 제약은 이런 버그를 전혀 막아 주지 못합니다. concept은 인터페이스(연산이 존재하는가)를 검사할 뿐 올바르게 쓰였는지는 검사하지 않기 때문입니다. 또 a < b만 요구하는 것은 문법적 조건일 뿐, 정렬에 필요한 의미적 조건(엄격 약순서: a < a가 거짓이고 추이적일 것)은 표현하지 못합니다. NaN이 섞인 double은 < 연산은 되지만 순서 조건을 깨뜨려 정렬 결과를 망가뜨립니다. 표준의 std::totally_ordered나 std::sortable도 의미적 조건은 문서상의 약속일 뿐 컴파일러가 검사하지 않는다는 점은 같습니다.


해시 가능 Concept

#include <concepts>
#include <iostream>
#include <optional>
#include <string>
#include <unordered_map>
template<typename T>
concept Hashable = requires(T t) {
    { std::hash<T>{}(t) } -> std::convertible_to<size_t>;
};
template<Hashable K, typename V>
class SimpleMap {
private:
    std::unordered_map<K, V> data_;
    
public:
    void insert(const K& key, const V& value) {
        data_[key] = value;
    }
    
    std::optional<V> get(const K& key) const {
        auto it = data_.find(key);
        if (it != data_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
};
int main() {
    SimpleMap<std::string, int> map;
    map.insert("age", 30);
    
    auto value = map.get("age");
    if (value) {
        std::cout << *value << std::endl;  // 30
    }
    
    return 0;
}

클래스 템플릿에 concept을 거는 효과는 이 예제에서 특히 분명합니다. 해시를 지원하지 않는 사용자 정의 타입으로 std::unordered_map<MyKey, int>를 만들면, 에러는 unordered_map 내부 구현의 깊은 곳에서 std::hash<MyKey>의 기본 생성자가 삭제되었다는 메시지로 나옵니다. SimpleMap<MyKey, int>는 선언하는 줄에서 곧바로 “Hashable<MyKey>를 만족하지 않는다”고 알려 줍니다. std::hash의 특수화가 없는 타입에 대해 표준은 std::hash<T>를 “비활성화된” 특수화로 정의해 기본 생성이 불가능하게 하므로, std::hash<T>{} 식이 유효하지 않게 되어 concept이 거짓이 됩니다. 이 concept을 통과시키려면 namespace std에 template<> struct hash<MyKey>를 특수화하거나, 맵에 사용자 정의 해시 함수 객체를 따로 넘기도록 설계를 바꾸면 됩니다.


성능 비교

Concepts vs SFINAE

#include <concepts>
#include <iostream>
#include <type_traits>
// SFINAE (복잡)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
squareSFINAE(T value) {
    return value * value;
}
// Concepts (간단)
template<std::integral T>
T squareConcepts(T value) {
    return value * value;
}
int main() {
    std::cout << squareSFINAE(5) << std::endl;
    std::cout << squareConcepts(5) << std::endl;
    
    return 0;
}

비교:

방법가독성에러 메시지오버로드 우선순위
SFINAE반환 타입에 조건이 섞여 읽기 어려움”후보가 없다”는 목록 위주조건 간 우열 표현 불가
Concepts조건이 선언부에 이름으로 드러남만족하지 못한 조건을 직접 지목포함 관계로 더 구체적인 쪽 선택

결론: 런타임 성능은 두 방식이 같고(둘 다 컴파일 시점에만 작동), 차이는 가독성·에러 메시지·오버로드 표현력에서 납니다.

컴파일 시간은 코드와 컴파일러에 따라 달라서 어느 쪽이 항상 빠르다고 말하기 어렵습니다. 일반적으로 enable_if는 오버로드 후보마다 치환을 시도하며 타입을 만들어 내야 하고, concept은 조건을 원자적 제약 단위로 캐시할 수 있어 조건이 복잡한 라이브러리에서는 컴파일이 빨라지는 경향이 보고되기도 합니다. 하지만 concept 검사도 결국 템플릿 인스턴스화를 동반하므로 과도한 제약을 쌓으면 느려질 수 있습니다. 컴파일 시간이 중요하다면 -ftime-trace(Clang) 같은 도구로 직접 측정하는 것이 맞습니다.

SFINAE 버전에서 조건이 반환 타입 std::enable_if_t<...>에 숨어 있다는 점도 가독성 차이를 만듭니다. 함수 이름보다 조건이 먼저 나오고, 조건이 여러 개가 되면 std::enable_if_t<A && !B, T>를 오버로드마다 서로 배타적으로 맞춰 줘야 합니다. 조건을 겹치게 쓰면 모호한 호출 에러가 나기 때문입니다. concept은 다음 절들에서 보듯 겹치는 조건 중 더 구체적인 쪽을 자동으로 고를 수 있습니다.


실무 사례

사례 1: 제네릭 알고리즘

#include <concepts>
#include <iostream>
#include <vector>
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<Numeric T>
T sum(const std::vector<T>& values) {
    T result = 0;
    for (const auto& v : values) {
        result += v;
    }
    return result;
}
template<Numeric T>
T product(const std::vector<T>& values) {
    T result = 1;
    for (const auto& v : values) {
        result *= v;
    }
    return result;
}
int main() {
    std::vector<int> ints = {1, 2, 3, 4, 5};
    std::cout << "Sum: " << sum(ints) << std::endl;        // 15
    std::cout << "Product: " << product(ints) << std::endl; // 120
    
    return 0;
}

사례 2: 반복자 제약

#include <concepts>
#include <iostream>
#include <vector>
template<typename T>
concept Iterator = requires(T it) {
    { *it };
    { ++it } -> std::same_as<T&>;
    { it++ } -> std::same_as<T>;
};
template<Iterator It>
void advance(It& it, int n) {
    for (int i = 0; i < n; ++i) {
        ++it;
    }
}
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    auto it = vec.begin();
    
    advance(it, 2);
    std::cout << *it << std::endl;  // 3
    
    return 0;
}

{ ++it } -> std::same_as<T&>는 전위 증가가 자기 자신의 참조를 돌려줘야 한다는, 표준 반복자 요구 사항을 그대로 옮긴 것입니다. 이 예제에서 조심할 것은 이름입니다. advance는 표준 라이브러리에 std::advance로 이미 있고, vec.begin()의 반환 타입은 std 네임스페이스와 연관되어 있어 인자 의존 조회(ADL)로 std::advance도 후보에 오릅니다. 이 경우에는 두 번째 매개변수가 int로 고정된 우리 함수가 더 구체적이라 선택되지만, 시그니처를 조금만 바꾸면 모호한 호출 에러가 나거나 조용히 표준 함수가 호출될 수 있습니다. 표준 이름과 겹치는 함수나 concept(Iterator)을 만들지 말고, 반복자 제약이 필요하면 std::input_or_output_iterator, std::forward_iterator 같은 표준 concept을 쓰는 편이 안전합니다.


사례 3: 출력 가능 타입

#include <concepts>
#include <iostream>
#include <string>
#include <vector>
template<typename T>
concept Printable = requires(std::ostream& os, T t) {
    { os << t } -> std::convertible_to<std::ostream&>;
};
template<Printable T>
void print(const T& value) {
    std::cout << value << std::endl;
}
template<Printable T>
void printVector(const std::vector<T>& vec) {
    for (const auto& elem : vec) {
        print(elem);
    }
}
int main() {
    print(42);
    print(3.14);
    print("Hello");
    
    std::vector<int> vec = {1, 2, 3};
    printVector(vec);
    
    return 0;
}

사례 4: 비교 가능 타입

#include <concepts>
#include <iostream>
#include <vector>
template<typename T>
concept Comparable = std::totally_ordered<T>;
template<Comparable T>
T findMax(const std::vector<T>& values) {
    if (values.empty()) {
        throw std::invalid_argument("빈 벡터");
    }
    
    T maxVal = values[0];
    for (const auto& v : values) {
        if (v > maxVal) {
            maxVal = v;
        }
    }
    return maxVal;
}
int main() {
    std::vector<int> nums = {5, 2, 8, 1, 9};
    std::cout << "Max: " << findMax(nums) << std::endl;  // 9
    
    return 0;
}

트러블슈팅

문제 1: 순환 의존

증상: 컴파일 에러

// ❌ 순환 의존
template<typename T>
concept A = B<T>;
template<typename T>
concept B = A<T>;
// ✅ 명확한 정의
template<typename T>
concept A = std::integral<T>;
template<typename T>
concept B = A<T> && std::signed_integral<T>;

concept은 자기 자신을 직접 또는 간접적으로 참조할 수 없고, 사용하기 전에 선언되어 있어야 합니다. 위의 잘못된 예는 A를 정의하는 시점에 B가 아직 없어서 'B' was not declared in this scope 에러로 끝납니다. 재귀적인 조건(예: “원소 타입도 다시 이 concept을 만족해야 한다”)이 필요하다면 concept 대신 재귀 타입 트레잇을 만들고 concept은 그것을 감싸는 방식으로 풀어야 합니다.


문제 2: 과도한 제약

증상: 불필요하게 엄격한 제약

// ❌ 너무 엄격
template<typename T>
concept StrictContainer = requires(T t) {
    typename T::value_type;
    typename T::iterator;
    { t.begin() };
    { t.end() };
    { t.size() };
    { t.empty() };
    { t.clear() };
    { t.push_back(typename T::value_type{}) };
    // ...
};
// ✅ 필요한 것만
template<typename T>
concept Iterable = requires(T t) {
    { t.begin() };
    { t.end() };
};

제약은 “함수가 실제로 사용하는 연산”만 요구해야 합니다. 원소를 순회만 하는 함수에 push_back과 clear까지 요구하면 std::set, std::array, std::string_view처럼 순회는 되지만 그 연산이 없는 타입을 쓸 수 없게 됩니다. 반대로 너무 느슨하게 요구하면 에러가 다시 본문 깊숙한 곳에서 나오게 됩니다. 기준은 “본문에서 쓰는 식은 모두 제약에 있고, 제약에 있는 식은 모두 본문에서 쓴다”입니다. 처음 concept을 도입할 때 흔히 겪는 문제가 이 균형인데, 표준 concept(std::ranges::input_range 등)이 이미 이 균형을 고민해 설계되어 있으므로 먼저 표준에서 찾아보고 부족할 때만 직접 만드는 순서가 시행착오를 줄입니다.


문제 3: 불명확한 에러 메시지

증상: 에러 메시지가 여전히 복잡

// ❌ 불명확한 에러
template<typename T>
requires requires(T t) { t + t; }
void func(T value) {}
// ✅ 명확한 Concept
template<typename T>
concept Addable = requires(T t) {
    { t + t } -> std::convertible_to<T>;
};
template<Addable T>
void func(T value) {}

즉석 requires 표현식의 에러 메시지는 “requires 절이 만족되지 않았다”와 실패한 식을 보여 주는 정도라, 조건이 여러 개 모이면 무엇을 의도한 제약인지 드러나지 않습니다. 이름 있는 concept은 에러 메시지에 Addable 같은 이름이 나오고, 같은 조건을 여러 함수에서 재사용할 수 있으며, 다음 절에서 설명할 포함 관계 계산에도 쓰일 수 있습니다.


문제 4: Concept 오버로드

증상: 모호한 오버로드

// ✅ 서로 겹치지 않는 제약: 모호하지 않음 (어떤 타입도 둘 다 만족하지 않음)
template<std::integral T>
void process(T value) {
    std::cout << "정수" << std::endl;
}
template<std::floating_point T>
void process(T value) {
    std::cout << "실수" << std::endl;
}
// ✅ 정수를 부호로 나눈 경우도 겹치지 않음
template<typename T>
requires std::integral<T> && std::signed_integral<T>
void process(T value) {
    std::cout << "부호 있는 정수" << std::endl;
}
template<typename T>
requires std::integral<T> && std::unsigned_integral<T>
void process(T value) {
    std::cout << "부호 없는 정수" << std::endl;
}

std::integral과 std::floating_point는 어떤 타입도 동시에 만족하지 않으므로, 두 오버로드가 모두 후보가 되는 경우가 없어 모호함도 생기지 않습니다. 모호함이 실제로 문제가 되는 것은 조건이 겹칠 때입니다. 예를 들어 template<std::integral T> void f(T)와 template<std::signed_integral T> void f(T)가 있을 때 f(5)는 두 쪽을 모두 만족합니다. 이때 컴파일러는 포함(subsumption) 관계를 계산합니다. std::signed_integral은 정의상 std::integral<T> && ...이므로 std::integral을 포함하고, 더 구체적인 쪽인 signed_integral 버전이 선택됩니다. 부호 없는 정수는 integral 버전으로 갑니다. SFINAE로는 이런 “더 구체적인 쪽 우선”을 표현하려면 조건을 서로 배타적으로 다시 써야 했습니다.

포함 관계는 concept 이름을 통해서만 인식됩니다. requires std::is_integral_v<T>와 requires std::is_integral_v<T> && std::is_signed_v<T>처럼 타입 트레잇 식을 직접 쓰면, 사람 눈에는 두 번째가 첫 번째를 포함하지만 컴파일러는 두 선언의 std::is_integral_v<T>를 서로 다른 원자적 제약으로 보아 포함 관계를 계산하지 못하고, f(5) 호출이 call of overloaded 'f(int)' is ambiguous로 실패합니다. 공통 조건을 concept으로 정의하고 그 이름을 조합해 쌓아 올리는 것이 오버로드를 설계하는 올바른 방법입니다.


마무리

Concepts는 템플릿 타입 제약을 명시적으로 정의하며, 명확한 에러 메시지를 제공합니다.

핵심 요약

  1. Concepts란?
    • 템플릿 타입에 대한 명시적 제약
    • C++20부터 지원
    • SFINAE보다 간결
  2. 표준 Concepts
    • integral, floating_point
    • equality_comparable, totally_ordered
    • invocable, predicate
    • convertible_to, same_as
  3. 커스텀 Concepts
    • requires 절로 정의
    • 복합 Concepts (AND, OR, NOT)
    • 명확한 에러 메시지
  4. 성능
    • 컴파일 타임에만 체크
    • 런타임 성능 영향 없음
    • 컴파일 시간은 코드에 따라 다르므로 측정 필요

선택 가이드

상황권장이유
C++20 이상Concepts간결, 명확
C++17 이하SFINAEConcepts 미지원
표준 제약표준 Concepts재사용
커스텀 제약커스텀 Concepts도메인 특화

코드 예제 치트시트

// 표준 Concepts
template<std::integral T>
T square(T value) {
    return value * value;
}
// 커스텀 Concepts
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};
// requires 절
template<typename T>
requires std::integral<T>
T square(T value) {
    return value * value;
}
// 축약 함수 템플릿
auto square(std::integral auto value) {
    return value * value;
}
// 복합 Concepts
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;

다음 단계

참고 자료

  • “C++20 The Complete Guide” - Nicolai M. Josuttis
  • cppreference: https://en.cppreference.com/w/cpp/language/constraints
  • “Effective Modern C++” - Scott Meyers 한 줄 정리: Concepts는 템플릿 타입 제약을 명시적으로 정의하고 명확한 에러 메시지를 제공하여, SFINAE보다 간결하고 읽기 쉬운 코드를 작성할 수 있게 합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 두 오버로드가 모두 제약을 만족하면 어느 쪽이 선택되나요?

A. 제약이 더 강한 쪽, 즉 다른 쪽의 제약을 포함(subsume)하는 오버로드가 선택됩니다. 다만 컴파일러는 concept 이름으로 묶인 원자적 제약이 같을 때만 포함 관계를 인식하므로, requires 절에 같은 표현식을 각각 직접 적어 두면 포함 관계를 알아보지 못해 모호한 호출 에러가 납니다. 공통 조건을 concept으로 뽑아 concept B = A<T> && ...처럼 쌓아 올려야 본문의 Concept 오버로드 예시처럼 의도한 쪽이 골라집니다.