C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]

들어가며: “우리 타입만 받고 싶어요”

표준 개념만으로는 부족할 때

정렬 가능한 타입, 직렬화 가능한 타입(데이터를 저장·전송용 형태로 바꿀 수 있는 타입)처럼 도메인별 조건을 템플릿에 걸고 싶었습니다. 표준에는 없는 제약이라 직접 Concept을 정의해야 했습니다.
커스텀 concept은 “이 타입이 이 연산/멤버를 지원한다”를 requires 표현식(requires { ... }, 타입이 특정 연산·멤버를 가졌는지 검사하는 식)으로 적어 둔 것입니다. 표준 concept과 &&, ||로 조합하면 도메인에 맞는 제약을 짧게 만들 수 있습니다. 예제는 모두 C++20 기준이므로 g++ -std=c++20(또는 clang++ -std=c++20)으로 빌드합니다.


concept 선언: 타입과 반환 타입 요구 사항

기본 형태

Sortable은 “두 값 a, b에 대해 a < b가 가능하며, std::swap(a, b)가 호출 가능한 타입”을 요구합니다. requires(T a, T b) 안에 나열한 표현식이 컴파일만 되면 해당 요구 사항을 만족한 것으로 간주됩니다. 즉 std::sort에 넘길 수 있는 타입(비교·스왑 가능)을 라이브러리 내부에서 “Sortable”이라는 이름으로 재사용할 때 유용합니다. 표준에는 std::sortable이 있지만, 도메인별로 “우리 프로젝트에서 쓰는 정렬 가능 타입”을 이렇게 정의해 두면 API 의도가 분명해집니다.

template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;           // 비교 가능
    std::swap(a, b); // swap 가능 (또는 using std::swap; swap(a,b);)
};

타입 요구 사항

HasIterator는 “T::iterator와 T::value_type이라는 타입이 존재한다”만 검사합니다. typename T::iterator처럼 typename으로 중첩 타입을 요구하면, std::vector, std::map 같은 STL 컨테이너는 만족하며, T가 그런 멤버 타입이 없으면 제약 불만족으로 걸러집니다. “반복자로 순회 가능한 컨테이너만 받고 싶다”는 제약을 std::ranges::range와 함께 쓰거나, 이렇게 단순히 타입 존재만 요구할 때 사용할 수 있습니다.

template <typename T>
concept HasIterator = requires {
    typename T::iterator;
    typename T::value_type;
};

반환 타입 요구 사항

{ expr } -> Concept 형태로 “이 표현식의 반환 타입이 Concept을 만족해야 한다”를 적을 수 있습니다. ReturnsInt는 “f()를 호출했을 때 반환 타입이 int와 동일한 타입”이어야 한다는 제약입니다. std::same_as<int>는 “완전히 같은 타입”만 허용하며, std::convertible_to<int>를 쓰면 int로 변환 가능한 타입(short, long 등)도 허용할 수 있습니다. 콜백·함수 객체가 “정수만 반환한다”를 컴파일 타임에 검사할 때 유용합니다.

template <typename T>
concept ReturnsInt = requires(T f) {
    { f() } -> std::same_as<int>;
};

requires 표현식: 여러 표현식, 반환 타입, 중첩 요구

여러 표현식 나열

requires(T obj) 안에 obj.draw(), obj.getBounds()처럼 여러 표현식을 나열하면, “T 타입의 객체가 이 멤버 함수들을 호출 가능해야 한다”는 제약이 됩니다. 반환 타입은 검사하지 않으며, 문법적으로 호출만 가능한지 확인합니다. GUI에서 “그릴 수 있는 객체”, “경계를 반환하는 객체”만 받고 싶을 때 이렇게 Concept을 두면, 인터페이스 상속 없이도 “draw·getBounds가 있는 타입”만 허용할 수 있습니다.

template <typename T>
concept Drawable = requires(T obj) {
    obj.draw();           // 멤버 함수
    obj.getBounds();      // 반환 타입은 검사 안 함 (호출 가능만)
};

반환 타입 검사

{ t.toString() } -> std::convertible_to<std::string>는 “t.toString()의 반환 타입이 std::string으로 변환 가능해야 한다”는 뜻입니다. same_as는 타입이 완전히 같아야 하며, convertible_to는 암시적 변환이 가능하면 됩니다. 예: 반환 타입이 const std::string&이거나 std::string이어도 만족합니다. 로깅·직렬화에서 “문자열로 바꿀 수 있는 타입”만 받고 싶을 때 쓰기 좋습니다.

template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::convertible_to<std::string>;
};

중첩 요구 사항 (nested requirement)

requires 블록 안에 requires 조건; 형태로 쓰면 중첩 요구 사항(nested requirement)이 됩니다. 단순 요구 사항이 “식이 컴파일되는가”만 보는 것과 달리, 중첩 요구 사항은 그 조건의 값이 true인지 평가합니다. 아래 Allocator는 “allocate(n)이 T::value_type*를 반환하고, 그 포인터로 deallocate를 호출할 수 있으며, T는 기본 생성 가능해야 한다”는 제약입니다. 마지막 조건은 블록 밖에서 && std::default_constructible<T>로 이어 붙여도 같은 의미입니다.

template <typename T>
concept Allocator = requires(T a, std::size_t n, typename T::value_type* p) {
    { a.allocate(n) } -> std::same_as<typename T::value_type*>;
    a.deallocate(p, n);
    requires std::default_constructible<T>;  // 중첩 요구 사항
};

논리 조합과 기존 개념 기반 정의

논리 조합

Number는 std::integral이거나 std::floating_point인 타입만 허용합니다. ||로 두 Concept을 묶으면 “둘 중 하나만 만족해도 된다”는 뜻이 되어, add는 정수형·부동소수점 모두 받을 수 있습니다. &&로 묶으면 “모두 만족해야 한다”입니다. 표준 개념을 이렇게 조합해 “숫자 타입”, “반복 가능한 컨테이너” 같은 도메인 개념을 짧게 정의할 수 있습니다.

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template <Number T>
T add(T a, T b) {
    return a + b;
}

기존 개념 기반

template <typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::same_as<bool>;
    { a > b } -> std::same_as<bool>;
};
template <typename T>
concept SortableContainer = std::ranges::range<T> && Comparable<std::ranges::range_value_t<T>>;

Serializable, Hashable, SmartPointer 같은 커스텀 Concept 예제

예제 1: Serializable — 직렬화 가능 타입

파일·네트워크로 객체를 저장할 때 “serialize 메서드가 있는 타입만 받겠다”를 Concept으로 두면, 템플릿 함수 안에서 t.serialize(os)를 안전하게 호출할 수 있습니다.

#include <iostream>
#include <fstream>
#include <concepts>
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
// 사용 예: Player 클래스
struct Player {
    std::string name;
    int level;
    void serialize(std::ostream& os) const {
        os << name << " " << level;
    }
};
template <Serializable T>
void saveToFile(const T& obj, const std::string& path) {
    std::ofstream f(path);
    obj.serialize(f);
}
int main() {
    Player p{"Hero", 10};
    saveToFile(p, "player.dat");  // ✅ OK
    // saveToFile(42, "x.dat");   // ❌ 에러: int는 Serializable 아님
}

예제 2: Hashable — 해시 가능 타입

해시맵의 키로 쓸 수 있는 타입을 제약합니다. std::hash<T>가 특수화되어 있으며, operator==가 있어야 합니다.

#include <functional>
#include <concepts>
template <typename T>
concept Hashable = requires(T a, T b) {
    { std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
    { a == b } -> std::same_as<bool>;
};
template <Hashable K, typename V>
class SimpleHashMap {
    // K를 키로 사용하는 해시맵 구현
};

예제 3: CallableWith — 특정 인자로 호출 가능

콜백이나 함수 객체가 주어진 인자 타입으로 호출 가능한지 검사합니다. 표준에는 같은 역할의 std::invocable<F, Args...>가 있으며, 멤버 함수 포인터까지 다루므로 실무에서는 표준 쪽을 쓰는 편이 낫습니다. 아래는 원리를 보여 주기 위한 직접 구현입니다.

#include <concepts>
#include <utility>
template <typename F, typename... Args>
concept InvocableWith = requires(F f, Args&&... args) {
    { f(std::forward<Args>(args)...) };
};
template <typename F, typename R, typename... Args>
concept ReturnsWhenCalledWith = requires(F f, Args&&... args) {
    { f(std::forward<Args>(args)...) } -> std::same_as<R>;
};
// 사용 예
void process(int x, InvocableWith<int> auto callback) {
    callback(x);
}

예제 4: SmartPointer — 스마트 포인터류

역참조, operator->, get()을 지원하면서 raw pointer가 아닌 타입을 요구합니다. raw pointer는 p.get()이 없으므로 requires 블록만으로도 걸러지지만, 의도를 드러내기 위해 !std::is_pointer_v<T>를 앞에 두었습니다. &&는 왼쪽부터 평가되므로 포인터 타입은 requires 블록까지 가지 않습니다.

#include <concepts>
#include <memory>
template <typename T>
concept SmartPointer = !std::is_pointer_v<T> && requires(T p) {
    *p;
    p.operator->();
    p.get();
};
template <SmartPointer P>
void usePointer(P p) {
    auto& ref = *p;
    auto ptr = p.get();
}

예제 5: RangeWithSize — 크기를 알 수 있는 범위

std::ranges::range이면서 size() 멤버나 std::ranges::size()로 크기를 구할 수 있는 타입입니다.

#include <ranges>
#include <concepts>
template <typename T>
concept RangeWithSize = std::ranges::range<T> && requires(T& r) {
    { std::ranges::size(r) } -> std::convertible_to<std::size_t>;
};
template <RangeWithSize R>
void reserveAndProcess(R&& r) {
    auto sz = std::ranges::size(r);
    // 사전 할당 등에 활용
}

예제 6: 복합 Concept — ThreadSafeSerializable

직렬화 가능하면서 lock/unlock 메서드를 가진 타입을 요구합니다.

template <typename T>
concept ThreadSafeSerializable = Serializable<T> && requires(T& t) {
    t.lock();
    t.unlock();
};
template <ThreadSafeSerializable T>
void saveThreadSafe(T& obj, const std::string& path) {
    obj.lock();
    std::ofstream f(path);
    obj.serialize(f);
    obj.unlock();
}

예제 7: compound requirements — 반환 타입 + noexcept 검사

{ expr } noexcept 형태로 “이 표현식이 예외를 던지지 않아야 한다”를 요구합니다. noexcept와 반환 타입을 동시에 검사할 수 있습니다.

#include <concepts>
#include <utility>
// 이동 생성자가 noexcept인 타입 (벡터 재할당 등에 중요)
// 표준 트레이트로는 std::is_nothrow_move_constructible_v<T>와 같다
template <typename T>
concept NoThrowMoveConstructible = std::move_constructible<T> && requires(T t) {
    { T(std::move(t)) } noexcept -> std::same_as<T>;
};
// 스왑이 noexcept인 타입
template <typename T>
concept NoThrowSwappable = requires(T& a, T& b) {
    { std::swap(a, b) } noexcept;
};
// 사용: 예외 안전성이 중요한 컨테이너
template <NoThrowMoveConstructible T>
class SafeVector {
    // 재할당 시 std::move_if_noexcept 사용 가능
};

예제 8: 타입 요구 + 표현식 요구 — AllocatorAware

표준 할당자 요구 사항에서 max_size()는 선택 사항이고 std::allocator_traits가 기본 구현을 제공합니다. 아래처럼 max_size()까지 요구하면 최소한의 사용자 정의 할당자는 걸러지므로, 실제로 쓰는 연산만 요구하는 것이 좋습니다.

template <typename A>
concept StdAllocator = requires(A a, size_t n, typename A::value_type* p) {
    typename A::value_type;
    typename A::size_type;
    { a.allocate(n) } -> std::same_as<typename A::value_type*>;
    a.deallocate(p, n);
    { a.max_size() } -> std::convertible_to<typename A::size_type>;
} && std::copyable<A>;

예제 9: 논리 조합(||) — InputOrOutput

여러 인터페이스 중 하나라도 만족하면 되는 Concept입니다.

template <typename S>
concept InputStream = requires(S& s) { s.get(); { s.good() } -> std::convertible_to<bool>; };
template <typename S>
concept OutputStream = requires(S& s, char c) { s.put(c); { s.good() } -> std::convertible_to<bool>; };
template <typename S>
concept InputOrOutputStream = InputStream<S> || OutputStream<S>;

예제 10: InvocableWithResult — 반환 타입 제약

람다가 특정 인자로 호출 가능하며, 반환 타입이 특정 Concept을 만족해야 할 때 사용합니다.

template <typename F, typename R, typename... Args>
concept InvocableWithResult = requires(F f, Args&&... args) {
    { std::invoke(f, std::forward<Args>(args)...) } -> std::same_as<R>;
};
template <InvocableWithResult<std::string, int> F>
std::vector<std::string> mapToStrings(const std::vector<int>& vec, F f) {
    std::vector<std::string> result;
    for (int x : vec) result.push_back(f(x));
    return result;
}

Concept 조합과 재사용

개념 합치기 (&&)

여러 Concept을 &&로 연결하면 “모두 만족해야 한다”는 의미입니다.

template <typename T>
concept Input = std::copyable<T> && Serializable<T>;
template <Input T>
void save(const T& obj, const std::string& path) {
    std::ofstream f(path);
    obj.serialize(f);
}

개념 선택 (||)

||로 연결하면 “둘 중 하나만 만족해도 된다”는 의미입니다.

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template <typename T>
concept OutputStream = requires(T& s, const char* p, size_t n) {
    s.write(p, n);
} || requires(T& s, char c) {
    s.put(c);
};

계층적 조합

기본 개념을 조합해 더 구체적인 개념을 만듭니다.

template <typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
    { a > b } -> std::convertible_to<bool>;
};
template <typename T>
concept Key = std::totally_ordered<T> && std::copyable<T>;
template <typename M>
concept MapLike = requires(M m, typename M::key_type k, typename M::mapped_type v) {
    { m[k] } -> std::same_as<typename M::mapped_type&>;
    m.insert({k, v});
} && Key<typename M::key_type>;

조합 다이어그램

flowchart TB
  subgraph base[기본 개념]
    A["std copyable"]
    B[Serializable]
    C["std ranges range"]
  end
  subgraph composed[조합된 개념]
    D[Input = copyable && Serializable]
    E[SortableContainer = range && Comparable]
  end
  A --> D
  B --> D
  C --> E

expression is not satisfied, 순환 정의, ADL 같은 오류

오류 1: “expression is not satisfied” — 표현식 검사 실패

증상: Concept 제약이 만족되지 않아 컴파일 에러가 발생합니다. 원인: requires 블록 안의 표현식이 해당 타입에서 유효하지 않습니다. 흔한 예가 std::swap(a, b)를 직접 요구하는 경우입니다. std::swap은 이동 생성·이동 대입이 가능해야 호출할 수 있으므로, 이동은 막혀 있지만 자기 네임스페이스에 전용 swap을 제공하는 타입은 이 concept을 만족하지 못합니다.

// ❌ std::swap만 검사: 전용 swap만 있는 이동 불가 타입을 거부
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    std::swap(a, b);
};

해결법:

// ✅ 올바른 예: swap은 ADL 고려
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    requires std::swappable_with<T&, T&>;  // 표준 개념 사용
};
// 또는
template <typename T>
concept Sortable = std::totally_ordered<T> && std::swappable<T>;

오류 2: “no matching function” — 반환 타입 요구 사항 불일치

증상: { expr } -> std::same_as<X>에서 반환 타입이 정확히 X가 아닐 때 실패합니다. 원인: same_as는 타입이 완전히 동일해야 합니다. const X&나 X*는 X와 다릅니다.

// ❌ 문제: toString()이 const std::string&를 반환하면
template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::same_as<std::string>;  // const string& ≠ string
};

해결법:

// ✅ convertible_to 사용: 암시적 변환 가능하면 OK
template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::convertible_to<std::string>;
};

오류 3: 중첩 타입이 없는 타입을 넣었을 때

증상: typename T::nested_type에서 T에 해당 중첩 타입이 없으면 제약 불만족입니다. 원인: requires 표현식 안의 치환 실패는 하드 에러가 아니라 false로 처리됩니다. SFINAE와 마찬가지로, 제약을 만족하지 못한 함수 템플릿은 오버로드 후보에서 조용히 빠지고, 남은 후보가 없을 때에만 컴파일 에러가 납니다.

// ❌ T가 value_type을 갖지 않으면
template <typename T>
concept HasValueType = requires {
    typename T::value_type;
};
// HasValueType<int> → false (에러 아님, 단순히 불만족)

해결법: 의도한 동작입니다. if constexpr (HasValueType<T>)로 분기하거나, 제약이 맞는 오버로드만 선택되도록 설계합니다.

오류 4: requires 블록 안에서 noexcept 검사

증상: noexcept(expr)를 Concept에서 쓰고 싶을 때 문법이 다릅니다. 해결법:

template <typename T>
concept NoThrowMove = std::move_constructible<T> &&
    requires(T t) {
        { T(std::move(t)) } noexcept;
    };

오류 5: 자기 참조 Concept (순환 정의)

증상: Concept A가 B를 요구하며, B가 A를 요구하면 순환 의존이 생깁니다.

// ❌ 위험: A → B → A
template <typename T>
concept A = B<T> && requires(T t) { t.foo(); };
template <typename T>
concept B = A<T> && requires(T t) { t.bar(); };

해결법: 개념을 계층적으로 분리하며, 순환 없이 기본 개념 → 파생 개념 순으로 정의합니다.

// ✅ 기본 개념 먼저
template <typename T>
concept HasFoo = requires(T t) { t.foo(); };
template <typename T>
concept HasBar = requires(T t) { t.bar(); };
template <typename T>
concept A = HasFoo<T> && HasBar<T>;

오류 6: const/참조 한정자 불일치

requires(const T& t)인데 serialize가 non-const면 제약 불만족. serialize를 const 메서드로 정의하세요.

오류 7: ADL(Argument-Dependent Lookup)과 swap

증상: std::swap(a, b)를 요구하면 사용자가 네임스페이스에 정의한 swap이 검사에 반영되지 않습니다.

namespace N {
struct MyType { int x; };
void swap(MyType& a, MyType& b) noexcept { std::swap(a.x, b.x); }
}
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    std::swap(a, b);  // N::swap은 보지 않고 std::swap이 호출 가능한지만 검사
};

N::MyType은 이동 가능하므로 이 concept 검사는 통과합니다. 문제는 검사한 것과 실제로 쓰일 것이 다르다는 점입니다. 알고리즘은 보통 using std::swap; swap(a, b); 형태로 ADL을 통해 N::swap을 부르는데, concept은 std::swap만 검사했으므로 이동이 불가능하고 전용 swap만 있는 타입은 실제로는 동작할 수 있는데도 거부됩니다. 해결법: ADL과 std::swap을 모두 고려하는 std::ranges::swap을 기준으로 정의된 표준 concept std::swappable<T>(또는 std::swappable_with<T&, T&>)를 씁니다.

// ✅ 표준 개념 사용
template <typename T>
concept Sortable = std::totally_ordered<T> && std::swappable_with<T&, T&>;

오류 8: 다른 타입을 위한 오버로드가 없을 때

증상: template <std::integral T> void g(T);만 있는 상태에서 g(3.14)를 호출하면 “constraints not satisfied” 에러가 납니다. 원인: 제약을 만족하지 못한 후보는 enable_if와 마찬가지로 오버로드 집합에서 빠지고, 남은 후보가 하나도 없으니 에러가 나는 것입니다. enable_if 버전도 오버로드가 하나뿐이면 똑같이 에러가 납니다. 차이는 에러 메시지가 어떤 제약이 실패했는지를 직접 알려 준다는 점입니다. 해결법: 다른 타입을 받아야 한다면, 제약을 만족하는 오버로드와 그 밖의 타입을 위한 오버로드를 모두 정의합니다. 두 제약이 함께 만족될 수 있으면 더 많이 제약된(subsume하는) 쪽이 선택됩니다.

template <std::integral T>
void process(T x) { /* 정수 처리 */ }
template <typename T>
void process(T x) requires (!std::integral<T>) { /* 그 외 처리 */ }

오류 9: requires 블록 내 변수 선언

증상: requires 블록 안에서 변수 선언은 불가합니다. 표현식만 넣을 수 있습니다.

// ❌ 선언문 불가
template <typename T>
concept C = requires(T t) { auto x = t.get(); };
// ✅ 표현식만
template <typename T>
concept C = requires(T t) { t.get(); { t.get() } -> std::convertible_to<int>; };

도메인 이름과 최소 요구 사항으로 Concept 설계하기

표준 개념을 최대한 활용

이미 있는 std::integral, std::ranges::range, std::copyable 등을 재사용하면, 코드가 짧고 유지보수가 쉬워집니다.

// ❌ 불필요한 재정의
template <typename T>
concept MyIntegral = std::is_integral_v<T>;
// ✅ 표준 개념 사용
template <std::integral T>
void f(T) {}

Concept 이름은 도메인 용어로

“이 타입이 무엇을 할 수 있는지”를 나타내는 이름을 사용합니다. HasSerialize보다 Serializable이 더 직관적입니다.

// ❌ 구현 디테일 노출
template <typename T>
concept HasSerializeMethod = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
// ✅ 도메인 의도 표현
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};

최소 요구 사항만 정의

과도한 제약은 불필요하게 타입을 제한합니다. 실제로 사용하는 연산·멤버만 요구합니다.

// ❌ 과도한 제약: size()를 안 쓰는데 요구
template <typename T>
concept Container = requires(T& c) {
    c.begin();
    c.end();
    c.size();  // 사용하지 않으면 제거
};
// ✅ 최소 요구
template <typename T>
concept Range = requires(T& c) {
    c.begin();
    c.end();
};

requires 절 vs template 파라미터 제약

간단한 제약은 template <Concept T> 형식이, 복잡한 제약은 requires 절이 더 읽기 쉽습니다.

// ✅ 단순: 한 줄로
template <Serializable T>
void save(const T& obj);
// ✅ 복합: requires 절이 명확
// T는 전달 참조로 추론되면 Player&처럼 참조 타입이 되므로, 제약에는 remove_cvref_t를 씌운다
template <typename T>
void process(T&& obj) requires Serializable<std::remove_cvref_t<T>> && std::copyable<std::remove_cvref_t<T>>;

static_assert로 검증

static_assert(Serializable<Player>);
static_assert(!Serializable<int>);

enable_if와 Concepts의 컴파일 시간과 에러 메시지 비교

컴파일 타임 오버헤드

Concept은 컴파일 타임에만 검사되므로 런타임 비용은 없습니다. 컴파일 시간에 미치는 영향은 제약의 복잡도와 컴파일러에 따라 달라서 일반화하기 어렵습니다. 같은 조건을 enable_if로 표현해도 결국 비슷한 치환 작업이 필요하므로, 컴파일 시간이 문제라면 실제 프로젝트에서 -ftime-trace(Clang) 같은 도구로 측정해 보는 것이 정확합니다.

방식컴파일 시간에러 메시지가독성
제약 없음빠름템플릿 내부에서 난해낮음
std::enable_if보통SFINAE로 인해 난해낮음
Concepts제약 복잡도에 따라 다름호출 지점에서 명확높음

런타임 성능

동일합니다. Concept은 타입 검사만 하며, 생성되는 기계어에는 영향을 주지 않습니다.

// 두 함수는 동일한 기계어 생성
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add_if(T a, T b) { return a + b; }
template <std::integral T>
T add_concept(T a, T b) { return a + b; }

에러 메시지 비교

// enable_if 사용 시 (GCC)
error: no matching function for call to 'saveToFile(int, const char [6])'
note: candidate: 'void saveToFile(const T&, const std::string&) [with T = int]'
note:   template argument deduction/substitution failed:
note:     no type named 'type' in 'struct std::enable_if<false, void>'
// Concepts 사용 시 (GCC, 요약)
error: no matching function for call to 'saveToFile(int, const char [6])'
note: constraints not satisfied
note: required for the satisfaction of 'Serializable<T>' [with T = int]
note: the required expression 't.serialize(os)' is invalid

Concepts를 쓰면 어떤 제약이 불만족인지 바로 확인할 수 있습니다.


Concept 헤더 분리, 점진적 도입, CRTP 하이브리드

패턴 1: Concept 헤더 분리

프로젝트 전역에서 쓸 Concept은 별도 헤더에 모아 두며, 필요한 곳에서 include합니다.

// concepts.hpp
#pragma once
#include <concepts>
#include <ranges>
namespace my_project {
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
template <typename T>
concept SortableRange = std::ranges::range<T> &&
    requires(std::ranges::range_value_t<T> a, std::ranges::range_value_t<T> b) {
        a < b;
        std::swap(a, b);
    };
}  // namespace my_project

패턴 2: 점진적 도입 (레거시와 공존)

기존 enable_if 코드를 한 번에 바꾸지 않으며, 새 코드부터 Concepts를 적용합니다.

// 기존: enable_if (유지)
template <typename T>
std::enable_if_t<std::is_integral_v<T>, void> legacyProcess(T x);
// 신규: Concepts
template <std::integral T>
void newProcess(T x);

패턴 3: 테스트용 Concept

단위 테스트에서 “이 타입이 특정 인터페이스를 만족하는지” 검증할 때 Concept을 활용합니다.

// 테스트에서
static_assert(Serializable<Player>);
static_assert(Serializable<Enemy>);
static_assert(!Serializable<int>);

패턴 4: 문서화용 Concept

실제 제약보다는 “이 템플릿이 기대하는 타입”을 문서화하는 용도로 쓸 수 있습니다.

/// @brief 이 함수는 T가 다음을 지원할 때 사용 가능합니다:
///        - serialize(std::ostream&) const
///        - copy 생성 가능
template <Serializable T>
void exportToStream(const T& obj, std::ostream& os);

패턴 5: 조건부 컴파일

if constexpr와 함께 사용해, Concept 만족 여부에 따라 다른 구현을 선택합니다.

template <typename T>
void process(T&& obj) {
    if constexpr (Serializable<std::remove_cvref_t<T>>) {
        std::ostringstream oss;
        obj.serialize(oss);
        send(oss.str());
    } else {
        send(toString(obj));  // fallback
    }
}

패턴 6: Concept 기반 오버로드 분기

타입에 따라 다른 구현을 선택합니다.

template <std::ranges::random_access_range R>
void sortRange(R& r) requires std::sortable<std::ranges::iterator_t<R>> {
    std::ranges::sort(r);
}
template <std::ranges::forward_range R>
void sortRange(R& r) requires (!std::ranges::random_access_range<R>) {
    // 임의 접근이 안 되는 범위(list 등)는 vector로 복사해 정렬한 뒤 다시 써 넣는다
    std::vector<std::ranges::range_value_t<R>> vec(std::ranges::begin(r), std::ranges::end(r));
    std::ranges::sort(vec);
    std::ranges::copy(vec, std::ranges::begin(r));
}

패턴 7: CRTP + Concept 하이브리드

CRTP 기본 클래스의 템플릿 매개변수에 바로 requires Drawable<Derived>를 걸면 동작하지 않습니다. struct Circle : DrawableBase<Circle>을 쓰는 시점에는 Circle이 아직 불완전한 타입이라 draw()를 찾을 수 없어 제약이 거짓이 되기 때문입니다. 검사는 파생 클래스가 완성된 뒤에 인스턴스화되는 멤버 함수 안으로 옮깁니다.

template <typename T>
concept Drawable = requires(const T& t) {
    t.draw();
    { t.getBounds() } -> std::convertible_to<std::pair<int, int>>;
};
template <typename Derived>
class DrawableBase {
public:
    void render() const {
        static_assert(Drawable<Derived>, "Derived must provide draw() and getBounds()");
        static_cast<const Derived*>(this)->draw();
    }
};
struct Circle : DrawableBase<Circle> {
    void draw() const {}
    std::pair<int, int> getBounds() const { return {0, 0}; }
};

패턴 8: 에러 메시지 개선용 Concept

복잡한 enable_if 조건을 Concept으로 추출하면, 제약 불만족 시 “어떤 Concept이 실패했는지” 명확히 표시됩니다.

// ❌ enable_if: "enable_if<false>"만 보임
template <typename T>
std::enable_if_t<std::is_integral_v<T> && (sizeof(T) >= 4), T> process(T x) { return x * 2; }
// ✅ Concept: "IntegralAtLeast32Bit" 실패 등 구체적 메시지
template <typename T>
concept IntegralAtLeast32Bit = std::integral<T> && sizeof(T) >= 4;
template <IntegralAtLeast32Bit T> T process(T x) { return x * 2; }

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Concepts와 enable_if 중 뭘 써야 하나요?

A. 새 코드에는 Concepts를 권장합니다. 에러 메시지가 명확하고 가독성이 좋습니다. 레거시 코드는 점진적으로 마이그레이션할 수 있습니다.

Q. Concept 검사에 런타임 비용이 있나요?

A. 없습니다. 모든 검사는 컴파일 타임에 이루어지며, 생성된 기계어에는 영향을 주지 않습니다.