C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]
이 글의 핵심
C++ 커스텀 Concepts 작성에 대한 실전 가이드입니다. 도메인에 맞는 제약 조건 정의하기 [#22-2] 등을 예제와 함께 상세히 설명합니다.
들어가며: “우리 타입만 받고 싶어요”
표준 개념만으로는 부족할 때
정렬 가능한 타입, 직렬화 가능한 타입(데이터를 저장·전송용 형태로 바꿀 수 있는 타입)처럼 도메인별 조건을 템플릿에 걸고 싶었습니다. 표준에는 없는 제약이라 직접 Concept을 정의해야 했습니다.
커스텀 concept은 “이 타입이 이 연산/멤버를 지원한다”를 requires로 적어 두면, 라이브러리 내부에서만 쓰는 타입이나 도메인 타입에 맞는 제약을 만들 수 있습니다. 표준 개념과 &&, ||로 조합해 쓰면 재사용하기 좋은 제약을 많이 만들 수 있습니다.
목표:
Sortable,Serializable같은 커스텀 Concept 정의- requires 표현식(
requires { ... }—타입이 특정 연산·멤버를 가졌는지 검사하는 식)으로 “이 연산/타입이 있다” 표현 - 여러 개념을 조합해 사용
컴파일: C++20 기준이므로 g++ -std=c++20(또는 clang++ -std=c++20)으로 빌드하면 됩니다.
이 글을 읽으면:
concept선언과requires표현식 문법을 쓸 수 있습니다.- 연산·타입·반환 타입 요구 사항을 정의할 수 있습니다.
- 기존 표준 개념과 조합해 실전에 적용할 수 있습니다.
목차
- concept 선언
- requires 표현식 상세
- 복합 요구 사항
- 완전한 커스텀 Concept 예제
- Concept 조합과 재사용
- 자주 발생하는 오류와 해결법 6-1. 모범 사례 (Best Practices)
- 성능 비교: enable_if vs Concepts
- 프로덕션 패턴
- 실전 예제
1. 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>;
};
2. 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)
Allocator는 “allocate(n)이 T::value_type*를 반환하고, deallocate를 호출할 수 있으며, T는 기본 생성 가능해야 한다”는 제약입니다. requires 블록 안에 연산·호출을 나열하고, 밖에 && std::default_constructible<T>처럼 다른 Concept을 붙여 여러 조건을 동시에 요구할 수 있습니다. STL 스타일 할당자나 커스텀 메모리 풀을 템플릿 인자로 받을 때 이런 제약을 두면, 잘못된 타입이 넘어오는 것을 컴파일 단계에서 막을 수 있습니다.
template <typename T>
concept Allocator = requires(T a, size_t n) {
{ a.allocate(n) } -> std::same_as<typename T::value_type*>;
a.deallocate(/* ... */);
} && std::default_constructible<T>;
3. 복합 요구 사항
논리 조합
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>>;
4. 완전한 커스텀 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 — 특정 인자로 호출 가능
콜백이나 함수 객체가 주어진 인자 타입으로 호출 가능한지 검사합니다.
#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가 아닌 타입을 요구합니다.
#include <concepts>
#include <memory>
template <typename T>
concept SmartPointer = requires(T p) {
*p;
p.operator->();
p.get();
} && !std::same_as<T, std::remove_cvref_t<T>*>;
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인 타입 (벡터 재할당 등에 중요)
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
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;
}
5. 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
6. 자주 발생하는 오류와 해결법
오류 1: “expression is not satisfied” — 표현식 검사 실패
증상: Concept 제약이 만족되지 않아 컴파일 에러가 발생합니다.
원인: requires 블록 안의 표현식이 해당 타입에서 유효하지 않습니다.
// ❌ 잘못된 예: std::swap은 ADL을 위해 using이 필요할 수 있음
template <typename T>
concept Sortable = requires(T a, T b) {
a < b;
std::swap(a, b); // 사용자 타입 T에 swap이 namespace에 있으면 실패할 수 있음
};
해결법:
// ✅ 올바른 예: 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: requires 절에서 타입 추론 실패
증상: typename T::nested_type에서 T에 해당 중첩 타입이 없으면 제약 불만족입니다.
원인: SFINAE와 달리 Concept 검사 실패는 “제거”가 아니라 “에러”로 처리됩니다.
// ❌ 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::MyType에 swap이 namespace N에 있음
namespace N {
struct MyType { int x; };
void swap(MyType& a, MyType& b) { 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만 찾음 → 실패!
};
해결법: std::swappable_with<T&, T&> 표준 개념을 사용하거나, using std::swap; swap(a, b); 패턴을 requires에 표현합니다.
// ✅ 표준 개념 사용
template <typename T>
concept Sortable = std::totally_ordered<T> && std::swappable_with<T&, T&>;
오류 8: SFINAE와의 차이 — 제거 vs 에러
증상: enable_if에서는 제약 불만족 시 오버로드 후보에서 제거되지만, Concept에서는 명시적 에러가 발생합니다.
// enable_if: 다른 오버로드가 선택됨
template <typename T>
std::enable_if_t<std::is_integral_v<T>, void> f(T) {}
template <typename T>
std::enable_if_t<!std::is_integral_v<T>, void> f(T) {}
// Concepts: 제약 불만족 시 에러
template <std::integral T>
void g(T) {}
// g(3.14); // 에러: constraints not satisfied
해결법: “다른 타입은 다른 오버로드로” 가려면, Concept을 만족하는 오버로드와 만족하지 않는 오버로드를 모두 정의합니다.
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>; };
6-1. 모범 사례 (Best Practices)
1. 표준 개념을 최대한 활용
이미 있는 std::integral, std::ranges::range, std::copyable 등을 재사용하면, 코드가 짧고 유지보수가 쉬워집니다.
// ❌ 불필요한 재정의
template <typename T>
concept MyIntegral = std::is_integral_v<T>;
// ✅ 표준 개념 사용
template <std::integral T>
void f(T) {}
2. 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>;
};
3. 최소 요구 사항만 정의
과도한 제약은 불필요하게 타입을 제한합니다. 실제로 사용하는 연산·멤버만 요구합니다.
// ❌ 과도한 제약: 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();
};
4. requires 절 vs template 파라미터 제약
간단한 제약은 template <Concept T> 형식이, 복잡한 제약은 requires 절이 더 읽기 쉽습니다.
// ✅ 단순: 한 줄로
template <Serializable T>
void save(const T& obj);
// ✅ 복합: requires 절이 명확
template <typename T>
void process(T&& obj) requires Serializable<std::remove_cvref_t<T>> && std::copyable<T>;
5. static_assert로 검증
static_assert(Serializable<Player>);
static_assert(!Serializable<int>);
7. 성능 비교: enable_if vs Concepts
컴파일 타임 오버헤드
Concept은 컴파일 타임에만 검사됩니다. 런타임 비용은 0입니다.
| 방식 | 컴파일 시간 | 에러 메시지 | 가독성 |
|---|---|---|---|
| 제약 없음 | 빠름 | 템플릿 내부에서 난해 | 낮음 |
std::enable_if | 보통 | SFINAE로 인해 난해 | 낮음 |
| Concepts | 비슷~약간 증가 | 호출 지점에서 명확 | 높음 |
벤치마크 (컴파일 시간)
대규모 템플릿 프로젝트에서 Concepts 사용 시:
- GCC 13: enable_if 대비 약 5–10% 컴파일 시간 증가 (제약 검사 비용)
- Clang 17: 비슷한 수준
- MSVC 2022: Concepts가 더 최적화되어 enable_if보다 빠른 경우도 있음
런타임 성능
동일합니다. 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: the concept 'Serializable<int>' evaluated to false
Concepts를 쓰면 어떤 제약이 불만족인지 바로 확인할 수 있습니다.
8. 프로덕션 패턴
패턴 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::range R>
void sortRange(R& r) requires (!std::ranges::random_access_range<R>) {
std::vector<std::ranges::range_value_t<R>> vec(r.begin(), r.end());
std::ranges::sort(vec);
}
패턴 7: CRTP + Concept 하이브리드
template <typename T>
concept Drawable = requires(const T& t) {
t.draw();
{ t.getBounds() } -> std::convertible_to<std::pair<int, int>>;
};
template <typename Derived>
requires Drawable<Derived>
class DrawableBase {
public:
void render() const { 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; }
프로덕션 체크리스트
- 프로젝트 공통 Concept을
concepts.hpp에 모아 두기 - 새 템플릿 API에는 Concepts 적용
-
static_assert로 주요 타입 검증 - 에러 메시지가 호출 지점에서 명확한지 확인
- 순환 Concept 정의 없도록 설계
- ADL 고려 (swap 등) — 표준 개념 우선 사용
9. 실전 예제
직렬화 가능
Serializable은 “const T&와 std::ostream&에 대해 t.serialize(os)가 호출 가능하고, 반환 타입이 void”인 타입만 허용합니다. 파일·네트워크로 객체를 저장할 때 “serialize 메서드가 있는 타입만 받겠다”를 Concept으로 두면, 템플릿 함수 안에서 t.serialize(os)를 안전하게 호출할 수 있고, 만족하지 않는 타입이 넘어오면 컴파일 에러로 바로 잡을 수 있습니다.
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
{ t.serialize(os) } -> std::same_as<void>;
};
콜백(함수 객체) 제약
template <typename F, typename... Args>
concept InvocableWith = requires(F f, Args&&... args) {
{ f(std::forward<Args>(args)...) };
};
스마트 포인터류
template <typename T>
concept SmartPointer = requires(T p) {
*p;
p.operator->();
p.get();
} && !std::same_as<T, std::remove_cvref_t<T>*>;
실제로는 std::pointer_like 같은 표준 제안과 조합해 쓰는 경우가 많습니다. 레거시 코드에서는 std::enable_if나 pointer_traits로 “포인터처럼 쓸 수 있는 타입”만 받는 식의 제약을 걸었으나, Concepts로 쓰면 의도와 에러 메시지가 더 명확해집니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++ Concepts와 Constraints | “타입 제약” 가이드
- C++20 Concepts 완벽 가이드 | 템플릿 제약의 새 시대
이 글에서 다루는 키워드 (관련 검색어)
C++ 커스텀 concept, concept 정의, requires 절, 타입 제약 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 선언 | template <...> concept Name = requires (...) { ... }; |
| 요구 | 표현식 유효성, { expr } -> Concept, typename T::type |
| 조합 | &&, || 로 여러 개념 결합 |
| 재사용 | 다른 concept 안에서 사용 가능 |
| 성능 | 런타임 오버헤드 없음, 컴파일 타임에만 검사 |
| 에러 | 호출 지점에서 “constraints not satisfied”로 명확히 표시 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++20에서 자신만의 Concept을 정의하는 방법, requires 표현식, 복합 요구 사항, 그리고 실전에서 재사용 가능한 개념을 만드는 법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
Q. Concepts와 enable_if 중 뭘 써야 하나요?
A. 새 코드에는 Concepts를 권장합니다. 에러 메시지가 명확하고 가독성이 좋습니다. 레거시 코드는 점진적으로 마이그레이션할 수 있습니다.
Q. Concept 검사에 런타임 비용이 있나요?
A. 없습니다. 모든 검사는 컴파일 타임에 이루어지며, 생성된 기계어에는 영향을 주지 않습니다.
관련 글
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++20 Coroutine | co_await·co_yield로
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ Concepts와 Constraints |