The Ultimate Complete Guide to C++20 Concepts | A New Era of Template Constraints
이 글의 핵심
A comprehensive guide to C++20 Concepts for clarifying template constraints. Learn about requires, standard concepts, custom concepts, replacing SFINAE, and more.
originalId: cpp-concept
What Are C++20 Concepts and Why Do We Need Them?
Problem Scenario: The Template Error Message Nightmare
The Problem: Passing an incorrect type to a template function often results in error messages spanning hundreds of lines.
template<typename T>
T add(T a, T b) {
return a + b;
}
int main() {
add("hello", "world");
// Error: no operator+ for const char*
// 50 lines of template instantiation error messages...
}
The Solution: Concepts allow you to specify constraints on template arguments, producing clear and immediate errors when an invalid type is used.
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
template<Addable T>
T add(T a, T b) {
return a + b;
}
int main() {
add("hello", "world");
// Error: const char* does not satisfy Addable
// Clear and concise error message!
}
flowchart TD
subgraph before[Before C++20]
call1["add(string, string)"]
inst1["Template Instantiation"]
err1["50-line Error Message"]
end
subgraph after[With C++20 Concepts]
call2["add(string, string)"]
check["Concept Check"]
err2["Clear Error: Addable Violation"]
end
call1 --> inst1 --> err1
call2 --> check --> err2
Table of Contents
- Basic Syntax: concept, requires
- Standard Concepts
- Writing Custom Concepts
- requires Expressions
- Concept Composition
- Common Errors and Solutions
- Production Patterns
- Complete Example: Generic Container
- SFINAE vs Concepts
- Migration Guide
1. Basic Syntax: concept, requires
Defining a Concept
#include <concepts>
// Basic form
template<typename T>
concept MyConstraint = /* boolean expression */;
// Example: Types that support addition
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
Using a Concept
// Method 1: template<Concept T>
template<Addable T>
T add(T a, T b) {
return a + b;
}
// Method 2: requires clause
template<typename T>
requires Addable<T>
T add(T a, T b) {
return a + b;
}
// Method 3: trailing requires
template<typename T>
T add(T a, T b) requires Addable<T> {
return a + b;
}
// Method 4: auto (abbreviated function template)
auto add(Addable auto a, Addable auto b) {
return a + b;
}
2. Standard Concepts
Type Categories
#include <concepts>
// Integral types
template<std::integral T>
T square(T x) {
return x * x;
}
// Floating-point types
template<std::floating_point T>
T sqrt_approx(T x) {
return x / 2;
}
// Signed integers
template<std::signed_integral T>
T negate(T x) {
return -x;
}
// Unsigned integers
template<std::unsigned_integral T>
T increment(T x) {
return x + 1;
}
int main() {
square(5); // OK: int
sqrt_approx(9.0); // OK: double
negate(-10); // OK: int
increment(10u); // OK: unsigned int
}
Relationship Concepts
// Same type
template<typename T, typename U>
requires std::same_as<T, U>
void func(T a, U b) {
// T and U are the same type
}
// Convertible types
template<typename From, typename To>
requires std::convertible_to<From, To>
To convert(From value) {
return static_cast<To>(value);
}
// Derived relationship
template<typename Derived, typename Base>
requires std::derived_from<Derived, Base>
void process(Derived* ptr) {
Base* base = ptr; // OK
}
Comparison Concepts
// Equality comparable
template<std::equality_comparable T>
bool is_equal(T a, T b) {
return a == b;
}
// Totally ordered
template<std::totally_ordered T>
T max(T a, T b) {
return (a > b) ? a : b;
}
Callable Concepts
// Callable
template<typename F, typename... Args>
requires std::invocable<F, Args...>
auto call(F func, Args... args) {
return func(args...);
}
// Predicate (returns bool)
template<typename F, typename T>
requires std::predicate<F, T>
bool test(F pred, T value) {
return pred(value);
}
Object Concepts
// Default constructible
template<std::default_initializable T>
T create() {
return T{};
}
// Copy constructible
template<std::copy_constructible T>
T duplicate(const T& value) {
return T(value);
}
// Move constructible
template<std::move_constructible T>
T transfer(T&& value) {
return T(std::move(value));
}
3. Writing Custom Concepts
Container Concept
template<typename T>
concept Container = requires(T c) {
// Type members
typename T::value_type;
typename T::iterator;
// Member functions
{ c.size() } -> std::same_as<std::size_t>;
{ c.begin() } -> std::same_as<typename T::iterator>;
{ c.end() } -> std::same_as<typename T::iterator>;
{ c.empty() } -> std::convertible_to<bool>;
};
template<Container C>
void print_size(const C& container) {
std::cout << "Size: " << container.size() << '\n';
}
int main() {
std::vector<int> v = {1, 2, 3};
print_size(v); // OK
int arr[] = {1, 2, 3};
// print_size(arr); // Error: int[] not Container
}
Serializable Concept
template<typename T>
concept Serializable = requires(T obj, std::ostream& os, std::istream& is) {
{ obj.serialize(os) } -> std::same_as<void>;
{ T::deserialize(is) } -> std::same_as<T>;
};
template<Serializable T>
void save(const T& obj, std::ostream& os) {
obj.serialize(os);
}
template<Serializable T>
T load(std::istream& is) {
return T::deserialize(is);
}
Numeric Concept
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<Numeric T>
T abs(T value) {
return value < 0 ? -value : value;
}
template<Numeric T>
T clamp(T value, T min, T max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
4. requires Expressions
Simple Requirements
template<typename T>
concept HasSize = requires(T t) {
t.size(); // size() member function exists
};
Type Requirements
template<typename T>
concept HasValueType = requires {
typename T::value_type; // value_type type member exists
};
Compound Requirements
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>;
};
Nested Requirements
template<typename T>
concept ComplexConstraint = requires(T t) {
// Simple requirement
t.method();
// Type requirement
typename T::value_type;
// Compound requirement
{ t.size() } -> std::same_as<std::size_t>;
// Nested requirements
requires std::default_initializable<T>;
requires sizeof(T) <= 64;
};
5. Concept Composition
Combining Concepts with && and ||
Concepts compose with ordinary logical operators, letting a constraint be built from smaller, individually testable pieces.
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>
concept SortableContainer = requires(T t) {
{ t.begin() } -> std::input_iterator;
{ t.end() } -> std::input_iterator;
} && std::totally_ordered<typename T::value_type>;
Subsumption: More Specific Concepts Win Overload Resolution
When two overloads are both viable, the compiler prefers the one whose concept subsumes (is a stricter superset of) the other — this is what lets you write a general version and a more specific, more optimized version without ambiguity errors.
template<std::input_iterator It>
void process(It begin, It end) { /* generic, slower path */ }
template<std::random_access_iterator It> // subsumes input_iterator
void process(It begin, It end) { /* specialized, faster path */ }
// Called with a std::vector<int>::iterator: the random_access_iterator
// overload wins because random_access_iterator subsumes input_iterator.
Refining a Concept for a Specific Use Case
Layering constraints lets you express “a Container, but specifically one holding numeric elements” without duplicating the container requirements.
template<typename T>
concept NumericContainer = requires(T t) {
{ t.begin() } -> std::input_iterator;
{ t.end() } -> std::input_iterator;
} && Numeric<typename T::value_type>;
6. Common Errors and Solutions
Error 1: Constraint Not Satisfied
template<std::integral T>
T add(T a, T b) { return a + b; }
add(1.5, 2.5); // ❌ error: constraints not satisfied [with T = double]
Fix: either use std::floating_point too (via a composed concept), or call with an integral type.
Error 2: Ambiguous Concept Overloads
template<typename T> concept A = requires(T t) { t.foo(); };
template<typename T> concept B = requires(T t) { t.foo(); }; // same requirement as A!
template<A T> void f(T);
template<B T> void f(T); // ❌ ambiguous: A and B don't subsume each other
Fix: define B as A && <extra requirement> so the compiler can establish subsumption, or merge them into a single concept.
Error 3: Using auto Constraints Incorrectly
void f(std::integral auto x) { } // ✅ constrained function parameter, C++20
std::integral auto g() { return 5; } // ✅ constrained return type
// std::integral auto x = 5.0; // ❌ error: 5.0 doesn't satisfy std::integral
Error 4: Forgetting Concepts Are Not Types
template<typename T>
concept Sized = requires(T t) { t.size(); };
// Sized x; // ❌ error: Sized is a concept, not a type — cannot declare a variable of it
7. Production Patterns
Replacing enable_if Library Code
Migrating an existing enable_if-constrained API to concepts is usually a mechanical, low-risk change since the runtime behavior doesn’t change — only the compile-time constraint expression does.
// Before
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
class NumericWrapper { T value; };
// After
template<std::integral T> // or a custom Numeric concept for both int/float
class NumericWrapper { T value; };
Constraining Class Templates
template<typename T>
requires std::default_initializable<T> && std::copyable<T>
class Cache {
std::vector<T> items;
public:
void add(const T& item) { items.push_back(item); }
};
Concepts as API Documentation
A well-named concept doubles as living documentation of what an API actually requires — a caller reading template<Drawable T> void render(T obj) immediately knows the contract, unlike an unconstrained template<typename T>.
template<typename T>
concept Drawable = requires(T t, Canvas& c) {
{ t.draw(c) } -> std::same_as<void>;
};
template<Drawable T>
void render(const T& shape, Canvas& canvas) {
shape.draw(canvas);
}
8. Complete Example: Generic Container
A self-contained example combining a custom concept, standard-library concepts, and subsumption-based overloading in one generic container.
#include <concepts>
#include <vector>
#include <iostream>
template<typename T>
concept Printable = requires(T t, std::ostream& os) {
{ os << t } -> std::same_as<std::ostream&>;
};
template<typename T>
requires Printable<T> && std::copyable<T>
class LoggedContainer {
std::vector<T> items;
public:
void add(const T& item) {
items.push_back(item);
std::cout << "Added: " << item << "\n";
}
template<std::input_iterator It>
void addRange(It begin, It end) {
for (auto it = begin; it != end; ++it) add(*it);
}
std::size_t size() const { return items.size(); }
};
int main() {
LoggedContainer<int> container;
container.add(42);
std::vector<int> nums = {1, 2, 3};
container.addRange(nums.begin(), nums.end());
std::cout << "Size: " << container.size() << "\n";
}
9. SFINAE vs Concepts
| Aspect | SFINAE (enable_if) | Concepts (C++20) |
|---|---|---|
| Readability | Low — constraint hidden in template parameter list | High — constraint reads like a sentence |
| Error messages | Long substitution-failure dumps | Points directly at the unsatisfied requirement |
| Composability | Manual &&/` | |
| Overload resolution | Works, but ambiguity is harder to reason about | Subsumption gives predictable “most specific wins” |
| Compile time | Can be slower (deep substitution attempts) | Generally faster (dedicated language feature) |
// SFINAE
template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void f(T);
// Concepts — same constraint, clearer intent
template<std::integral T>
void f(T);
10. Migration Guide
Step 1: Identify enable_if/type_traits Usage
grep -rn "enable_if\|is_same_v\|is_integral_v\|is_base_of_v" src/
Step 2: Map Traits to Standard Concepts
| Old trait check | Equivalent concept |
|---|---|
std::is_integral_v<T> | std::integral<T> |
std::is_floating_point_v<T> | std::floating_point<T> |
std::is_default_constructible_v<T> | std::default_initializable<T> |
std::is_copy_constructible_v<T> | std::copy_constructible<T> |
std::is_base_of_v<Base, T> | std::derived_from<T, Base> |
Step 3: Replace Incrementally
Convert one template at a time and re-run the test suite — since concepts and SFINAE can coexist in the same codebase, there’s no need for a big-bang rewrite.
// Old and new can coexist during migration
template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void legacy_func(T value);
template<std::integral T>
void modern_func(T value);
Step 4: Require C++20 in the Build
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Related Articles (Internal Links)
Other articles related to this topic.
- C++20 Modules 완벽 가이드 | 헤더 파일을 넘어서
- C++20 Coroutines 완벽 가이드 | 비동기 프로그래밍의 새 시대
- C++ SFINAE | “Substitution Failure Is Not An Error” 가이드
- C++ enable_if | “조건부 컴파일” 가이드
Keywords Covered in This Article (Related Search Terms)
This article covers C++, concept, cpp20, template, constraint, requires.