C++20 Concepts are a language feature that allows you to specify named, reusable constraints on template parameters, defining precisely what properties a type must have to be used with a given template. Unlike SFINAE, which silently removes overloads using cryptic template machinery, Concepts produce clear, human-readable error messages, allow constraints to be checked throughout the entire function — not just the signature — and make generic code self-documenting by expressing intent directly in the code.
Introduction
Writing generic C++ code has always been a trade-off. Templates give you the power to write one algorithm that works with any type — but until C++20, there was no clean way to say “this template only works with types that support addition” or “this function requires a type with a begin() and end() method.” You could enforce these requirements informally through documentation, or technically through convoluted SFINAE expressions that produced error messages spanning dozens of lines and requiring template expertise to decode.
C++20 Concepts change this completely. A Concept is a compile-time predicate — a named condition on template arguments — that can be used to constrain templates in a way that is readable, composable, and that produces clear, actionable error messages when violated.
Consider the difference. With SFINAE, passing the wrong type to a constrained template might produce: “no matching function for call to ‘sort(Widget)’, candidate template ignored: substitution failure, enable_if condition is not satisfied…” followed by thirty lines of template backtrace. With Concepts, the same error reads: “constraint not satisfied: ‘Sortable’ requires ‘operator<‘”. The intent is communicated clearly, the error points directly to the violated constraint, and no template archaeology is required.
This article builds a complete understanding of C++20 Concepts. You will learn to use the standard library’s built-in concepts, write your own named concepts, apply constraints in all four syntactic forms, use requires expressions to check arbitrary type properties, and see how Concepts improve real-world generic library design. Every example is fully explained step by step.
Before Concepts: The Problem in Full
To appreciate what Concepts solve, see the problem concretely. Here is a sort function written without concepts, followed by what happens when you pass an unsortable type:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// No constraints — accepts any type
template<typename Container>
void sortContainer(Container& c) {
sort(c.begin(), c.end());
}
struct Widget {
string name;
// No operator< defined
};
int main() {
vector<int> nums = {5, 3, 1, 4, 2};
sortContainer(nums); // Works fine
vector<Widget> widgets = {{"B"}, {"A"}, {"C"}};
// sortContainer(widgets); // Compiler error — but the message is terrible
// Error: dozens of lines pointing into <algorithm> internals
// Hard to tell WHY it failed or WHAT is missing
for (int n : nums) cout << n << " ";
cout << endl;
return 0;
}Without Concepts, the error message from the compiler does not say “Widget needs operator<.” Instead, it digs into std::sort‘s implementation and reports an error at the point where two Widget objects are compared — deep inside the standard library’s internals. As a user of sortContainer, you receive a wall of template instantiation backtrace that points to code you did not write and do not control.
Concepts solve this by expressing the requirement at the call site — the place where you use the template — before the compiler ever tries to instantiate it.
Your First Concept: The requires Clause
The simplest way to constrain a template in C++20 is with a requires clause — a condition placed after the template parameter list that must be satisfied for the template to be selected.
#include <iostream>
#include <concepts>
#include <vector>
#include <algorithm>
using namespace std;
// Constrained with requires: T must be totally ordered (has operator<, >, <=, >=, ==)
template<typename Container>
requires requires(Container c) {
c.begin();
c.end();
{ *c.begin() } -> totally_ordered;
}
void sortContainer(Container& c) {
sort(c.begin(), c.end());
}
struct Widget {
string name;
// No operator< — does NOT satisfy totally_ordered
};
struct NamedItem {
string name;
// Has operator< — satisfies totally_ordered
bool operator<(const NamedItem& other) const {
return name < other.name;
}
bool operator>(const NamedItem& other) const { return other < *this; }
bool operator<=(const NamedItem& other) const { return !(other < *this); }
bool operator>=(const NamedItem& other) const { return !(*this < other); }
bool operator==(const NamedItem& other) const { return name == other.name; }
};
int main() {
vector<int> nums = {5, 3, 1, 4, 2};
sortContainer(nums); // Works: int is totally_ordered
for (int n : nums) cout << n << " ";
cout << "\n";
vector<NamedItem> items = {{"Charlie"}, {"Alice"}, {"Bob"}};
sortContainer(items); // Works: NamedItem satisfies the constraint
for (auto& it : items) cout << it.name << " ";
cout << "\n";
// sortContainer(vector<Widget>{});
// Clear error: "constraint not satisfied" — Widget's element
// type does not satisfy totally_ordered
return 0;
}Output:
1 2 3 4 5
Alice Bob Charlie Step-by-step explanation:
requires requires(Container c) { ... }is a requires clause containing a requires expression. The outerrequiresis the clause that gates the template; the innerrequiresis an expression that checks the validity of the listed operations.c.begin()andc.end()check that those methods exist onContainer. If they do not, the constraint is not satisfied and the template is excluded.{ *c.begin() } -> totally_orderedis a compound requirement. It checks that dereferencing the iterator (*c.begin()) is valid and that the result type satisfies thetotally_orderedconcept — meaning it supports all comparison operators.- For
vector<Widget>,*c.begin()yields aWidget, which does not satisfytotally_ordered(nooperator<). The constraint fails. With Concepts, the error message directly says which concept was not satisfied and on what type, pointing to your code — not to standard library internals. - For
vector<NamedItem>, all operators are defined,totally_ordered<NamedItem>is satisfied, and the template is selected.
Named Concepts: Writing Your Own
The real power of Concepts comes from defining named, reusable concepts that can be used like types throughout your codebase. A named concept is defined with the concept keyword.
#include <iostream>
#include <concepts>
#include <string>
using namespace std;
// Concept: T must be printable via operator<<
template<typename T>
concept Printable = requires(T value, ostream& os) {
{ os << value } -> same_as<ostream&>;
};
// Concept: T must have a .size() method returning an integral type
template<typename T>
concept Sized = requires(T container) {
{ container.size() } -> integral;
};
// Concept: T must have both begin() and end() (iterable)
template<typename T>
concept Iterable = requires(T container) {
{ container.begin() } -> input_or_output_iterator;
{ container.end() };
requires same_as<decltype(container.begin()),
decltype(container.end())>;
};
// Concept combining multiple requirements (composing concepts)
template<typename T>
concept PrintableContainer = Iterable<T> && Sized<T> && requires(T c) {
{ *c.begin() } -> Printable;
};
// Function constrained by a named concept
template<PrintableContainer C>
void printContainer(const C& c) {
cout << "Container with " << c.size() << " elements: [";
bool first = true;
for (const auto& elem : c) {
if (!first) cout << ", ";
cout << elem;
first = false;
}
cout << "]" << endl;
}
// Concept: T must be numeric (integral or floating point)
template<typename T>
concept Numeric = integral<T> || floating_point<T>;
template<Numeric T>
T clamp(T value, T low, T high) {
if (value < low) return low;
if (value > high) return high;
return value;
}
int main() {
vector<int> ints = {1, 2, 3, 4, 5};
vector<string> words = {"hello", "world", "cpp"};
vector<double> floats = {1.1, 2.2, 3.3};
printContainer(ints);
printContainer(words);
printContainer(floats);
cout << "\nclamp(15, 0, 10) = " << clamp(15, 0, 10) << endl;
cout << "clamp(-5, 0, 10) = " << clamp(-5, 0, 10) << endl;
cout << "clamp(7.5,0.0,5.0)=" << clamp(7.5, 0.0, 5.0) << endl;
// clamp("hello", "a", "z"); // Error: string does not satisfy Numeric
return 0;
}Output:
Container with 5 elements: [1, 2, 3, 4, 5]
Container with 3 elements: [hello, world, cpp]
Container with 3 elements: [1.1, 2.2, 3.3]
clamp(15, 0, 10) = 10
clamp(-5, 0, 10) = 0
clamp(7.5,0.0,5.0)=5Step-by-step explanation:
template<typename T> concept Printable = requires(T value, ostream& os) { { os << value } -> same_as<ostream&>; };defines a named concept. Therequiresexpression introduces variablesvalueandosthat can be used inside the expression body without being constructed — they exist only for the purpose of type checking.{ os << value } -> same_as<ostream&>is a compound requirement. It checks that the expressionos << valueis valid and that its return type satisfiessame_as<ostream&>. This ensures the type can be streamed and that chaining (cout << a << b) works correctly.concept PrintableContainer = Iterable<T> && Sized<T> && requires(...)composes concepts using&&. A type must satisfy all listed concepts forPrintableContainerto be satisfied. This composability is a key advantage over SFINAE — concepts are first-class boolean predicates that compose naturally.template<PrintableContainer C> void printContainer(const C& c)uses the concept as a constraint directly in the template parameter list — notypename, noenable_if, no angle brackets with conditions. The concept name replacestypenameand expresses the constraint clearly.template<Numeric T> T clamp(T value, T low, T high)uses theNumericconcept to constrainT. Callingclamp("hello", "a", "z")would fail with a clear message: “constraint not satisfied: ‘Numeric<const char*>’ evaluated to false.”
The Four Syntactic Forms of Constraints
C++20 provides four different syntactic locations where you can apply concept constraints. Each has its use case.
#include <iostream>
#include <concepts>
using namespace std;
template<typename T>
concept Arithmetic = integral<T> || floating_point<T>;
// Form 1: requires clause after template parameter list
template<typename T>
requires Arithmetic<T>
T add1(T a, T b) { return a + b; }
// Form 2: concept name directly as template parameter constraint
template<Arithmetic T>
T add2(T a, T b) { return a + b; }
// Form 3: requires clause at the end of the function signature
template<typename T>
T add3(T a, T b) requires Arithmetic<T> { return a + b; }
// Form 4: abbreviated function template (most concise, C++20)
Arithmetic auto add4(Arithmetic auto a, Arithmetic auto b) {
return a + b;
}
// Constrained auto in variable declarations
Arithmetic auto getValue() {
return 42; // Must return an Arithmetic type
}
int main() {
cout << add1(3, 4) << endl; // 7
cout << add2(3.14, 2.0) << endl; // 5.14
cout << add3(10, 20) << endl; // 30
cout << add4(1.5f, 2.5f) << endl; // 4
Arithmetic auto x = getValue(); // x must be arithmetic
cout << "x = " << x << endl;
// All four forms produce the same constraint behavior
// add1("hello", "world"); // Error with any form: string is not Arithmetic
return 0;
}Output:
7
5.14
30
4
x = 42Step-by-step explanation:
- Form 1 (
requiresclause after parameter list): Most flexible — allows complex compound conditions and is the only form that can express constraints involving relationships between multiple parameters. Also works for non-template contexts. - Form 2 (concept as template parameter type): Cleanest for simple single-concept constraints. The concept name replaces
typenamedirectly, making the constraint part of the parameter declaration. Most readable for simple cases. - Form 3 (
requiresclause after function signature): Useful when the constraint depends on the complete function signature (including return type or other parameters) rather than a single type. - Form 4 (abbreviated templates): The most concise form —
Arithmetic autodeclares a parameter whose type is deduced and must satisfyArithmetic. Eachautois a separate deduced type, soadd4(1, 2.0)would work if bothintanddoublesatisfyArithmeticindependently. Arithmetic auto x = getValue()constrains the auto variable — the compiler verifies that the assigned value’s type satisfiesArithmetic.
Requires Expressions: Checking Type Properties
A requires expression (the inner requires { ... } block) is how you express what operations must be valid for a type. It supports four kinds of requirements:
#include <iostream>
#include <concepts>
#include <string>
using namespace std;
template<typename T>
concept FullContainer = requires(T c, const T cc, size_t n) {
// 1. Simple requirement: expression must be valid (no return type check)
c.begin();
c.end();
c.clear();
// 2. Type requirement: nested type must exist
typename T::value_type;
typename T::iterator;
// 3. Compound requirement: expression must be valid AND return type must satisfy a concept
{ cc.size() } -> convertible_to<size_t>;
{ cc.empty() } -> same_as<bool>;
{ c[n] } -> same_as<typename T::value_type&>;
// 4. Nested requirement: another constraint must be satisfied
requires DefaultConstructible<typename T::value_type>;
};
// A concept for types that act like a map (key-value container)
template<typename T>
concept MapLike = requires(T m, typename T::key_type k) {
typename T::key_type;
typename T::mapped_type;
{ m[k] } -> same_as<typename T::mapped_type&>;
{ m.count(k) } -> convertible_to<size_t>;
{ m.find(k) } -> same_as<typename T::iterator>;
{ m.contains(k) } -> same_as<bool>;
};
// Adaptive function: different behavior for map-like vs. regular containers
template<typename T>
void inspectContainer(const T& container) {
if constexpr (MapLike<T>) {
cout << "Map-like container with "
<< container.size() << " entries" << endl;
} else if constexpr (FullContainer<T>) {
cout << "Sequence container with "
<< container.size() << " elements" << endl;
} else {
cout << "Unknown container" << endl;
}
}
int main() {
vector<int> vec = {1, 2, 3};
map<string, int> m = {{"a", 1}, {"b", 2}};
unordered_map<string,int> um = {{"x", 10}};
inspectContainer(vec);
inspectContainer(m);
inspectContainer(um);
cout << "\nFullContainer<vector<int>>: " << FullContainer<vector<int>> << endl;
cout << "MapLike<map<string,int>>: " << MapLike<map<string,int>> << endl;
cout << "MapLike<vector<int>>: " << MapLike<vector<int>> << endl;
return 0;
}Output:
Sequence container with 3 elements
Map-like container with 2 entries
Map-like container with 1 entries
FullContainer<vector<int>>: 1
MapLike<map<string,int>>: 1
MapLike<vector<int>>: 0Step-by-step explanation:
- Simple requirements (
c.begin(),c.end()) just check that the expression is valid — that it compiles. No return type constraints. This verifies method existence. - Type requirements (
typename T::value_type) verify that a nested type exists. Without this, trying to useT::value_typein the function body would be a hard error for types lacking it. - Compound requirements (
{ expr } -> concept) check validity and return type.{ cc.size() } -> convertible_to<size_t>means: the expressioncc.size()must compile, and its result must be convertible tosize_t. The->part requires a concept, not a raw type — you usesame_as<X>to require an exact match. - Nested requirements (
requires DefaultConstructible<...>) embed another concept check inside the expression. This allows hierarchical constraint composition. - Concepts used as
boolvalues (likeFullContainer<vector<int>>) evaluate totrueorfalseat compile time. This is the “concept predicate” form, usable anywhere aconstexpr boolis needed.
Standard Library Concepts
C++20 ships with a rich set of standard concepts in <concepts> and <iterator> headers. Knowing these lets you constrain templates using well-tested, standard-defined predicates rather than writing everything from scratch.
#include <iostream>
#include <concepts>
#include <iterator>
#include <ranges>
#include <vector>
#include <list>
#include <string>
using namespace std;
// Demonstrate key standard concepts
template<typename T>
void showConceptMembership() {
cout << "Type: " << typeid(T).name() << "\n";
cout << " integral: " << integral<T> << "\n";
cout << " floating_point: " << floating_point<T> << "\n";
cout << " signed_integral: " << signed_integral<T> << "\n";
cout << " unsigned_integral: " << unsigned_integral<T> << "\n";
cout << " constructible_from<>: "
<< constructible_from<T, int> << "\n";
cout << " copy_constructible: " << copy_constructible<T> << "\n";
cout << " move_constructible: " << move_constructible<T> << "\n";
cout << " default_initializable: " << default_initializable<T> << "\n";
cout << " equality_comparable: " << equality_comparable<T> << "\n";
cout << " totally_ordered: " << totally_ordered<T> << "\n";
cout << "\n";
}
// Standard concepts for iterators and ranges
template<ranges::input_range R>
void printRange(const R& r) {
for (const auto& elem : r) cout << elem << " ";
cout << "\n";
}
template<ranges::random_access_range R>
void sortRange(R& r) {
sort(r.begin(), r.end());
cout << "Sorted (random access): ";
printRange(r);
}
// Only for bidirectional (not random access) ranges — use different sort
template<ranges::bidirectional_range R>
requires (!ranges::random_access_range<R>)
void sortRange(R& r) {
r.sort(); // std::list has its own sort
cout << "Sorted (bidirectional): ";
printRange(r);
}
int main() {
cout << "=== int ===" << endl;
showConceptMembership<int>();
cout << "=== double ===" << endl;
showConceptMembership<double>();
vector<int> v = {5, 3, 1, 4, 2};
list<int> l = {5, 3, 1, 4, 2};
sortRange(v); // random_access_range — uses std::sort
sortRange(l); // bidirectional only — uses list::sort
}Output (abbreviated):
=== int ===
Type: int
integral: 1
floating_point: 0
signed_integral: 1
unsigned_integral: 0
constructible_from<>: 1
copy_constructible: 1
move_constructible: 1
default_initializable: 1
equality_comparable: 1
totally_ordered: 1
=== double ===
Type: d
integral: 0
floating_point: 1
...
Sorted (random access): 1 2 3 4 5
Sorted (bidirectional): 1 2 3 4 5 Standard concepts overview by category:
The core language concepts in <concepts> cover the basics: same_as<T,U>, derived_from<D,B>, convertible_to<F,T>, common_with<T,U>, integral<T>, floating_point<T>, signed_integral<T>, unsigned_integral<T>.
The object concepts describe construction, copying, and assignment: default_initializable<T>, copy_constructible<T>, move_constructible<T>, copyable<T>, movable<T>, semiregular<T> (movable + default constructible), regular<T> (semiregular + equality comparable).
The comparison concepts describe ordering: equality_comparable<T>, equality_comparable_with<T,U>, totally_ordered<T>, three_way_comparable<T>.
The callable concepts describe function-like types: invocable<F,Args...>, regular_invocable<F,Args...>, predicate<F,Args...>, relation<R,T,U>.
The range and iterator concepts in <ranges> and <iterator> describe sequence traversal capabilities: input_iterator, forward_iterator, bidirectional_iterator, random_access_iterator, contiguous_iterator, and the corresponding input_range, forward_range, bidirectional_range, random_access_range, contiguous_range.
Real-World Example: A Generic Algorithm Library
Let’s build a small, practical generic algorithm library that uses Concepts throughout, demonstrating how they make generic code readable, safe, and adaptable.
#include <iostream>
#include <concepts>
#include <vector>
#include <list>
#include <map>
#include <string>
#include <numeric>
#include <ranges>
#include <algorithm>
using namespace std;
// --- Custom Concepts ---
// A type that can be summed (has + and a zero value)
template<typename T>
concept Summable = requires(T a, T b) {
{ a + b } -> same_as<T>;
{ T{} } -> same_as<T>; // Default constructible (for zero value)
};
// A container that allows random access and mutation
template<typename C>
concept MutableRandomAccess =
ranges::random_access_range<C> &&
!is_const_v<remove_reference_t<C>>;
// A callable that takes two arguments of the same type and returns bool
template<typename F, typename T>
concept BinaryPredicate = predicate<F, T, T>;
// --- Generic algorithms using concepts ---
// Sum all elements of any range whose element type is Summable
template<ranges::input_range R>
requires Summable<ranges::range_value_t<R>>
auto sumAll(const R& range) {
using T = ranges::range_value_t<R>;
return accumulate(begin(range), end(range), T{});
}
// Find the maximum element using a custom comparator
template<ranges::forward_range R, typename Comp = less<>>
requires BinaryPredicate<Comp, ranges::range_value_t<R>>
auto findMax(const R& range, Comp comp = {}) {
auto it = max_element(begin(range), end(range), comp);
return (it != end(range)) ? make_optional(*it) : nullopt;
}
// In-place reverse (only for bidirectional ranges)
template<ranges::bidirectional_range R>
void reverseInPlace(R& range) {
reverse(begin(range), end(range));
}
// Count elements satisfying a predicate
template<ranges::input_range R, typename Pred>
requires predicate<Pred, ranges::range_value_t<R>>
size_t countIf(const R& range, Pred pred) {
return count_if(begin(range), end(range), pred);
}
// Transform and collect into a new vector
template<ranges::input_range R, typename F>
requires invocable<F, ranges::range_value_t<R>>
auto transformToVector(const R& range, F func) {
using OutT = invoke_result_t<F, ranges::range_value_t<R>>;
vector<OutT> result;
result.reserve(ranges::distance(range));
transform(begin(range), end(range), back_inserter(result), func);
return result;
}
int main() {
vector<int> ints = {5, 3, 8, 1, 9, 2, 7};
vector<double> doubles = {1.5, 2.5, 3.5, 4.5};
list<string> words = {"banana", "apple", "cherry", "date"};
// sumAll — works for any Summable range
cout << "Sum of ints: " << sumAll(ints) << endl;
cout << "Sum of doubles: " << sumAll(doubles) << endl;
// sumAll(words); // Error: string satisfies +, but T{} might fail concept check
// findMax — works for any forward range
auto maxInt = findMax(ints);
auto maxWord = findMax(words);
if (maxInt) cout << "Max int: " << *maxInt << endl;
if (maxWord) cout << "Max word: " << *maxWord << endl;
// findMax with custom comparator: find shortest word
auto shortest = findMax(words, [](const string& a, const string& b) {
return a.size() > b.size(); // Reversed: "max" by "has fewer chars"
});
if (shortest) cout << "Shortest word: " << *shortest << endl;
// reverseInPlace — bidirectional range required
reverseInPlace(ints);
cout << "Reversed ints: ";
for (int n : ints) cout << n << " ";
cout << "\n";
reverseInPlace(words); // list is bidirectional — works
cout << "Reversed words: ";
for (const auto& w : words) cout << w << " ";
cout << "\n";
// countIf
size_t evenCount = countIf(ints, [](int n) { return n % 2 == 0; });
cout << "Even numbers: " << evenCount << endl;
size_t longWords = countIf(words, [](const string& s) { return s.size() > 4; });
cout << "Long words (>4 chars): " << longWords << endl;
// transformToVector
auto squared = transformToVector(ints, [](int n) { return n * n; });
cout << "Squared: ";
for (int s : squared) cout << s << " ";
cout << "\n";
auto lengths = transformToVector(words, [](const string& s) { return s.size(); });
cout << "Word lengths: ";
for (size_t l : lengths) cout << l << " ";
cout << "\n";
return 0;
}Output:
Sum of ints: 35
Sum of doubles: 12
Max int: 9
Max word: date
Shortest word: date
Reversed ints: 7 2 9 1 8 3 5
Reversed words: date cherry apple banana
Even numbers: 3
Long words (>4 chars): 2
Squared: 49 4 81 1 64 9 25
Word lengths: 4 6 5 6 Step-by-step explanation:
ranges::range_value_t<R>is a standard type alias that gives the element type of a range. Used withSummable<ranges::range_value_t<R>>, it applies the constraint to the element type, not the container type.findMaxusesrequires BinaryPredicate<Comp, ranges::range_value_t<R>>to check that the comparator is callable with two elements of the range. The defaultComp = less<>works for any totally-ordered type.reverseInPlacerequiresranges::bidirectional_range. Bothvectorandlistsatisfy this. Aforward_list(which only supports forward traversal) would fail this constraint — correctly, since you cannot reverse it in place without a backward pass.transformToVectorusesinvoke_result_t<F, ValueType>to deduce the output type from the function’s return type. This is a standard trait that works even for lambdas and function objects.- All constraint violations produce clear messages naming the failed concept, the type that was tested, and the requirement that was not met.
Concept Subsumption: Choosing the Best Overload
C++20 Concepts support subsumption: if Concept A implies Concept B (A is “stronger” than B), the compiler automatically prefers the overload constrained by A over the one constrained by B. This enables writing a family of overloads with increasing specificity.
#include <iostream>
#include <concepts>
using namespace std;
// A hierarchy of overloads using concept subsumption
// Most general: any input range
template<ranges::input_range R>
void process(const R& range) {
cout << "input_range: basic processing" << endl;
}
// More specific: forward range (subsumes input_range)
template<ranges::forward_range R>
void process(const R& range) {
cout << "forward_range: can iterate twice" << endl;
}
// Even more specific: bidirectional range (subsumes forward_range)
template<ranges::bidirectional_range R>
void process(const R& range) {
cout << "bidirectional_range: can go backwards" << endl;
}
// Most specific: random access range (subsumes bidirectional_range)
template<ranges::random_access_range R>
void process(const R& range) {
cout << "random_access_range: index access available" << endl;
}
int main() {
vector<int> vec; // random_access_range
list<int> lst; // bidirectional_range
forward_list<int> flst; // forward_range
process(vec); // Selects random_access_range — most specific
process(lst); // Selects bidirectional_range — most specific for list
process(flst); // Selects forward_range — most specific for forward_list
return 0;
}Output:
random_access_range: index access available
bidirectional_range: can go backwards
forward_range: can iterate twiceStep-by-step explanation:
ranges::random_access_range<R>impliesranges::bidirectional_range<R>impliesranges::forward_range<R>impliesranges::input_range<R>— this is the standard iterator category hierarchy.- The concept subsumption rules state: if concept A’s definition syntactically includes all the constraints of concept B, then A subsumes B. The compiler uses this to prefer the most-constrained overload.
- For
vector<int>(which is arandom_access_range), all four overloads match. But becauserandom_access_rangesubsumes all others, it is chosen without ambiguity. - Without Concepts, achieving this overload hierarchy required complex tag dispatching with iterator category tags — a pattern that was effective but verbose and not composable. Concept subsumption makes it declarative.
Practical Guidelines: Writing Good Concepts
Keep concepts focused. A concept should check one meaningful semantic property. Monolithic concepts that check dozens of operations are harder to understand and harder to satisfy. Compose small concepts into larger ones using && and ||.
// Too broad — hard to satisfy and understand
template<typename T>
concept GoodConcept = Printable<T> && Sortable<T> && Serializable<T>
&& Copyable<T> && Sized<T> && /* ... */;
// Better: compose at the point of use
template<Printable T>
requires Sortable<T>
void sortAndPrint(vector<T>& v) { /* ... */ }Use standard concepts where possible. The standard library provides integral, floating_point, totally_ordered, copyable, movable, regular, invocable, and dozens more. Prefer these over rolling your own — they are well-tested, well-documented, and composable.
Check semantic intent, not just syntax. A concept that checks { a + b } -> same_as<T> verifies that + exists and returns T. But it does not verify that + is associative or commutative. When designing concepts, document the semantic expectations in comments even if the compiler cannot verify them.
Prefer compound requirements over simple ones. { container.size() } -> convertible_to<size_t> is more precise than just container.size(). Always include a return-type constraint in compound requirements when the return type matters.
Name concepts after semantics, not syntax. Summable is better than HasPlusOperator. Printable is better than HasStreamOperator. Concept names should express what a type is or can do, not what operations it has.
SFINAE vs. Concepts: The Definitive Comparison
// The same constraint expressed four ways — from worst to best:
// Way 1: No constraint (worst — no error until deep inside)
template<typename T>
void process1(T value) { cout << value + value; }
// Way 2: SFINAE with enable_if (better — but verbose and cryptic errors)
template<typename T>
enable_if_t<is_arithmetic_v<T>, void>
process2(T value) { cout << value + value; }
// Way 3: SFINAE with requires expression (C++20 but without named concept)
template<typename T>
requires (is_arithmetic_v<T>)
void process3(T value) { cout << value + value; }
// Way 4: Named concept (best — readable, reusable, clear errors)
template<typename T>
concept Arithmetic = integral<T> || floating_point<T>;
template<Arithmetic T>
void process4(T value) { cout << value + value; }| Aspect | No Constraint | SFINAE | requires clause | Named Concept |
|---|---|---|---|---|
| Error location | Deep in instantiation | At call site | At call site | At call site |
| Error clarity | Very poor | Poor-moderate | Good | Excellent |
| Code readability | Simple | Verbose | Moderate | Excellent |
| Reusability | N/A | Low (inline) | Low (inline) | High (named) |
| Composition | N/A | Difficult | Easy (&&, ||) | Easy (&&, ||) |
| Overload control | None | Full | Full | Full + subsumption |
| C++ version | C++98 | C++11 | C++20 | C++20 |
| Body errors caught? | No | No | Partial | Partial |
Conclusion
C++20 Concepts represent the most significant improvement to C++ generic programming since templates were introduced. They transform template constraints from an arcane art — requiring deep knowledge of SFINAE, enable_if, and void_t — into a straightforward, readable, and composable feature that any C++ developer can understand.
The benefits are concrete and immediate. Clear error messages that say exactly which constraint failed and for what type, instead of pages of template instantiation backtraces. Named, reusable concepts that document requirements at the point of definition rather than burying them in function signatures. Natural composition with && and ||. Automatic overload selection through concept subsumption. And the expressiveness of requires expressions that can check any compile-time property of a type — method existence, return types, nested types, operators, and arbitrary compound conditions.
The standard library itself ships with dozens of well-designed concepts in <concepts>, <iterator>, and <ranges> that cover the most common requirements for generic algorithms: numeric types, copy and move semantics, ordering, iterability, and callable types.
For new C++ code targeting C++20 or later, Concepts should be your primary tool for constraining templates. They replace SFINAE for most use cases with dramatically better developer experience, and they make your generic interfaces self-documenting — the constraints are the documentation. Understanding Concepts deeply is essential for writing modern, professional C++ that is both maximally generic and maximally safe.








