C++20 Constraints in Practice: requires Expressions, Subsumption, and Overload Selection
Key takeaways
Concepts make template requirements part of the signature, but the rules behind them are subtle: a requires expression only checks that code compiles, only named concepts take part in subsumption, and the most constrained overload wins only when the compiler can prove it is more constrained. This guide focuses on those mechanics.
The Problem with Unconstrained Templates
Templates in C++ can accept any type — which means bad type errors appear at instantiation, buried deep in the implementation:
template<typename T>
T sum(const std::vector<T>& v) {
T result{};
for (const auto& x : v) result += x; // error here if T has no +=
return result;
}
// Calling with a type that doesn't support +=
struct Point { int x, y; };
std::vector<Point> points = {{1, 2}, {3, 4}};
sum(points);
// Error: no match for 'operator+=' (operand types are 'Point' and 'const Point')
// ... in instantiation of 'T sum(const std::vector<T>&) [with T = Point]'
// ... (many more lines)
With a concept, the error appears at the call site with a clear message:
template<typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>;
{ a += b };
};
template<Addable T>
T sum(const std::vector<T>& v) { /* ... */ }
sum(points);
// error: use of function 'T sum(...) [with T = Point]' with unsatisfied constraints
// note: the required expression '(a + b)' is invalid
Note what the concept does not catch. std::vector<std::string> satisfies Addable, because strings support + and +=, and sum happily concatenates them into "helloworld". Whether that is a bug depends on what sum is meant to do. A concept can only check that the operations exist, not that they mean “numeric addition”. That limitation runs through everything below: concepts check syntax, and the meaning is still your responsibility. If sum is only meant for numbers, say so directly with std::integral<T> || std::floating_point<T>.
Defining Concepts
A concept is a compile-time predicate on template parameters:
#include <concepts>
// Simple type trait concept
template<typename T>
concept Integral = std::is_integral_v<T>;
// Requires expression — checks that specific operations compile
template<typename T>
concept Addable = requires(T a, T b) {
a + b; // expression must be valid
a += b; // this too
};
// With return type constraint
template<typename T>
concept Comparable = requires(T a, T b) {
{ a < b } -> std::convertible_to<bool>; // must return something convertible to bool
{ a == b } -> std::same_as<bool>; // must return exactly bool
};
// Compound concept
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
// Nested requirements
template<typename T>
concept Sortable = requires(T container) {
typename T::value_type; // type must exist
{ container.begin() } -> std::forward_iterator;
{ container.end() } -> std::forward_iterator;
requires std::totally_ordered<typename T::value_type>; // nested requires
};
The four kinds of requirements inside a requires expression are easy to confuse, and confusing them produces concepts that accept everything. a + b; is a simple requirement: it only checks that the expression compiles. { a < b } -> std::convertible_to<bool>; is a compound requirement: the expression must compile and its type must satisfy the concept after the arrow. typename T::value_type; is a type requirement. And requires std::totally_ordered<...>; is a nested requirement: the condition must be true.
The classic mistake is forgetting the requires keyword in a nested requirement:
template<typename T>
concept Small = requires {
sizeof(T) <= 8; // ❌ simple requirement: only checks this COMPILES (always true)
};
template<typename T>
concept SmallFixed = requires {
requires sizeof(T) <= 8; // ✅ nested requirement: checks the VALUE
};
Small accepts every complete type, including a 1 KB struct, because sizeof(T) <= 8 is always a valid expression, whether it evaluates to true or false. No compiler error or warning points this out. This is the concept bug I would look for first in code review, because the concept looks right, compiles, and silently constrains nothing. A quick static_assert(!Small<std::array<char, 100>>); next to each hand-written concept catches it immediately, and I now write a couple of such positive and negative static_asserts for every custom concept.
Standard Library Concepts
<concepts> provides a rich set of ready-made concepts:
#include <concepts>
// Type categories
std::integral<T> // int, long, char, bool, ...
std::floating_point<T> // float, double, long double
std::signed_integral<T> // signed integer types
std::unsigned_integral<T> // unsigned integer types
// (there is no std::arithmetic; write std::integral<T> || std::floating_point<T>)
// Relationships
std::same_as<T, U> // T and U are the same type
std::derived_from<T, Base> // T is derived from Base
std::convertible_to<T, U> // T is implicitly convertible to U
std::common_with<T, U> // T and U share a common type
// Comparison
std::equality_comparable<T> // T has == and !=
std::totally_ordered<T> // T has <, <=, >, >=
std::three_way_comparable<T> // T has <=>
// Callable
std::invocable<F, Args...> // F can be called with Args
std::regular_invocable<F, Args...> // same + equality preserving
std::predicate<F, Args...> // F returns bool-like
// Object concepts
std::copyable<T> // copy constructible and assignable
std::movable<T> // move constructible and assignable
std::regular<T> // copyable, equality comparable
Two details in this list surprise people. std::integral<bool> and std::integral<char> are both true, so a function constrained with std::integral accepts true and 'A'. If that is not what you want, exclude them explicitly. And many standard concepts carry semantic requirements that the compiler cannot check: std::totally_ordered requires that < is a real total order, and std::regular_invocable requires that calling the function does not change the result. A type whose operator< is inconsistent satisfies the concept anyway, and an algorithm that relies on it behaves unpredictably. The concept documents the contract, but only syntax is enforced.
Prefer standard concepts over hand-written ones where they fit. Besides saving code, they are what the standard library’s own constraints are built from, which matters for subsumption (next sections).
Four Ways to Apply a Concept
template<typename T>
concept Printable = requires(std::ostream& os, T t) {
{ os << t } -> std::same_as<std::ostream&>;
};
// 1. Abbreviated function template (simplest)
void print(const Printable auto& value) {
std::cout << value << '\n';
}
// 2. Requires clause after template parameters
template<typename T>
requires Printable<T>
void print(const T& value) {
std::cout << value << '\n';
}
// 3. Constrained template parameter (classic concept syntax)
template<Printable T>
void print(const T& value) {
std::cout << value << '\n';
}
// 4. Inline requires expression (for one-off constraints)
template<typename T>
requires requires(std::ostream& os, T t) { os << t; }
void print(const T& value) {
std::cout << value << '\n';
}
All four accept the same types. Style 1 (abbreviated) is the most concise for simple cases; style 2 (requires clause) handles complex multi-concept constraints most readably. (Do not put all four in one file as written: they declare overloads with equivalent constraints, and calls become ambiguous.)
Style 4 has a hidden cost. The double requires requires is legal, but an ad-hoc requires expression is not a named concept, so it cannot take part in subsumption, the mechanism that lets the compiler rank one overload as more constrained than another. That matters as soon as a second overload is added, as the next sections show. For anything beyond a one-off, give the constraint a name.
Requires Expressions in Detail
A requires expression tests whether operations on template parameters are valid at compile time:
template<typename T>
concept Container = requires(T c, const T cc) {
// Type requirements — nested type must exist
typename T::value_type;
typename T::iterator;
// Simple expression — must compile (return type not checked)
c.clear();
// Compound expression — {expr} -> type constraint
{ c.size() } -> std::convertible_to<std::size_t>;
{ c.begin() } -> std::same_as<typename T::iterator>;
{ cc.begin() } -> std::same_as<typename T::const_iterator>;
{ c.empty() } -> std::convertible_to<bool>;
// Nested requirement — must evaluate to true
requires std::copyable<typename T::value_type>;
};
// Test it
static_assert(Container<std::vector<int>>); // passes
static_assert(Container<std::list<double>>); // passes
// static_assert(Container<int>); // fails — int has no .size() etc.
Custom Concepts: Real Examples
Hashable Type
template<typename T>
concept Hashable = requires(T t) {
{ std::hash<T>{}(t) } -> std::convertible_to<std::size_t>;
};
template<Hashable K, typename V>
class HashMap {
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 std::nullopt;
return it->second;
}
};
HashMap<std::string, int> wordCount; // OK — string is Hashable
// HashMap<std::vector<int>, int> map; // error — vector is not Hashable
Serializable Type
template<typename T>
concept Serializable = requires(T t, std::ostream& os, std::istream& is) {
{ t.serialize(os) } -> std::same_as<void>;
{ T::deserialize(is) } -> std::same_as<T>;
};
template<Serializable T>
void saveToFile(const T& obj, const std::string& path) {
std::ofstream f(path);
obj.serialize(f);
}
There is a subtle mismatch in this example that the concept does not catch. The concept checks t.serialize(os) on a non-const T, but saveToFile calls it on a const T&. A type whose serialize is not marked const satisfies the concept and then fails to compile inside saveToFile, with exactly the kind of deep instantiation error concepts were supposed to prevent. Declare the parameter in the requires expression the way the template will use it: requires(const T t, std::ostream& os). In general, a concept should test the operations with the same constness and value category as the constrained code.
Iterator Concepts
template<typename It>
concept InputIter = requires(It it) {
*it; // dereferenceable
++it; // pre-increment
it != it; // comparable
};
template<InputIter It>
auto accumulate(It first, It last) {
using T = std::remove_cvref_t<decltype(*first)>;
T sum{};
for (; first != last; ++first) sum += *first;
return sum;
}
This hand-written InputIter is a teaching example. In real code, use std::input_iterator<It>, which also requires the associated types (std::iter_value_t) that algorithms depend on, and use std::iter_value_t<It> instead of std::remove_cvref_t<decltype(*first)>. The two differ for proxy iterators such as std::vector<bool>::iterator, where dereferencing returns a proxy object instead of a reference.
Concepts for Overload Resolution
Concepts can select between overloads — more specific constraints win:
#include <concepts>
#include <iostream>
// Generic fallback
template<typename T>
void process(T val) {
std::cout << "Generic: " << val << '\n';
}
// More constrained — selected when T is integral
template<std::integral T>
void process(T val) {
std::cout << "Integer: " << val << " (bits: " << sizeof(T)*8 << ")\n";
}
// Even more constrained — selected when T is exactly int
void process(int val) {
std::cout << "Exact int: " << val << '\n';
}
int main() {
process(3.14); // Generic: 3.14
process(42L); // Integer: 42 (bits: 64)
process(42); // Exact int: 42
process("hello"); // Generic: hello
}
Two different rules are at work here. process(42) picks the plain int function not because it is “more constrained” but because, when a template and a non-template are equally good matches, the non-template wins. That is an old C++ rule that concepts do not change. Between the two templates, process(42L) picks the std::integral one because its constraints subsume the unconstrained generic version.
Subsumption is where concepts get subtle. The compiler decides that one constraint is more specific than another by breaking both into their component concepts and checking logical implication, but it only compares named concepts, never the expressions inside them:
template<typename T> concept Animal = requires(T t) { t.speak(); };
template<typename T> concept Dog = Animal<T> && requires(T t) { t.fetch(); };
template<Animal T> int f(T) { return 1; }
template<Dog T> int f(T) { return 2; } // Dog includes Animal: more constrained
struct Lab { void speak(); void fetch(); };
f(Lab{}); // 2: OK, Dog subsumes Animal
// Same checks written as raw requires expressions:
template<typename T> requires requires(T t) { t.speak(); } int g(T) { return 1; }
template<typename T> requires requires(T t) { t.speak(); } && requires(T t) { t.fetch(); }
int g(T) { return 2; }
g(Lab{}); // error: call of overloaded 'g(Lab)' is ambiguous
Both g overloads visibly check the same thing as f, but the compiler does not compare the expressions inside requires, so it cannot see that the second implies the first, and the call is ambiguous. The same happens with constraints written as type traits: std::is_integral_v<T> && std::is_signed_v<T> does not subsume std::is_integral_v<T>, because they are ordinary boolean expressions, not concepts. Build specialized concepts from more general named concepts (as Dog does with Animal), and overloads rank the way you expect.
Concepts vs SFINAE
The same constraint, old and new style:
// SFINAE (pre-C++20) — cryptic, easy to get wrong
template<typename T,
std::enable_if_t<std::is_integral_v<T> &&
std::is_signed_v<T>, int> = 0>
T negate(T value) { return -value; }
// Concepts (C++20) — readable, compile errors at call site
template<std::signed_integral T>
T negate(T value) { return -value; }
// Abbreviated — even shorter
auto negate(std::signed_integral auto value) { return -value; }
Error message comparison for a bad call like negate(3.14f):
- SFINAE: “no matching function”, followed by candidate notes about substitution failure and
enable_ifinternals - Concepts: “constraints not satisfied”, with a note naming the concept that failed (
signed_integral<float>)
The difference grows with the size of the codebase. With SFINAE, the reason a candidate was rejected is buried in enable_if_t<...> expressions. With concepts, the compiler reports which concept failed, and for a requires expression, which individual requirement. That said, deeply nested concepts (a concept built from five others) can still produce long diagnostic chains. Keeping concepts shallow and well named helps the error messages as much as the code.
Concepts are not just nicer SFINAE, though. Converting an existing overload set from enable_if to concepts can change which overload is chosen: with SFINAE, two viable overloads with overlapping conditions were simply ambiguous, while with concepts, subsumption may now pick one of them. Re-run the tests after such a migration, rather than assuming the behavior is identical.
Compiler Support
# GCC 10+
g++ -std=c++20 main.cpp
# Clang 10+ (some corner cases fixed only in later versions)
clang++ -std=c++20 main.cpp
# MSVC 2019 16.8+ (older versions: /std:c++latest)
cl /std:c++20 main.cpp
Basic concepts work in all three compilers from these versions, but early implementations had bugs and gaps in less common features (constraints on member functions of class templates, some subsumption cases, abbreviated templates in certain positions). If a concept behaves differently on two compilers, check against a recent version before assuming your code is wrong.
Writing and testing concepts: the rules to keep
- Concepts are compile-time predicates on template parameters — they constrain which types a template accepts
- Use
requiresexpressions to specify that certain operations onTmust compile, with optional return type constraints - Standard
<concepts>header providesstd::integral,std::copyable,std::invocable, and many more - The four application styles accept the same types, but only named concepts take part in subsumption
- Concepts improve overload resolution: the most constrained overload wins, if the compiler can prove it through named concepts
- A requires expression checks that code compiles; use
requiresinside it to check a condition’s value - Test each custom concept with a few positive and negative
static_asserts - No runtime cost — all checks are compile-time
- Error messages are dramatically shorter and appear at the call site, not deep in template instantiation
Related Articles
- C++20 Concepts: Writing Readable Template Constraints
- C++20 Concepts basics
- SFINAE in C++: enable_if, Expression SFINAE and When to Switch to Concepts