C++ / TEMPLATES AND GENERIC PROGRAMMING
Concepts for constraining template parameters
Define C++20 concepts with requires-expressions, attach them to templates in every available syntax, and let constraint subsumption choose between overloads.
What you will learn
- Define a concept as a requires-expression that only tests whether expressions compile
- Attach constraints with template <C T>, a requires-clause, or a C auto parameter
- Build stricter concepts from looser ones so subsumption picks the right overload
- Read concept satisfaction as a compile-time check, never a promise about semantics
Understanding Concepts for constraining template parameters
A concept is a named predicate over template arguments, evaluated entirely at compile time: Scalable<Box> is just a constexpr bool, except that the compiler also understands its internal structure. The body of a requires-expression is not code that runs; each requirement asks only whether an expression would be well formed if it were written, so the invented parameters in requires(T obj, double factor) are never constructed and their operations never happen. A compound requirement like { obj.scale(factor) } -> std::same_as<void> asks a second question about the type of that expression, and the right-hand side of the arrow must itself be a concept, because it is applied to the deduced result type.
Because the constraint is checked before the template body is instantiated, the compiler can reject a bad argument while it still knows where the call was written. Calling grow with a type that has no scale member reports that the constraints of grow are not satisfied and names the failed requirement, instead of emitting an error from inside a function the caller never read. Substitution failures inside a requires-expression are deliberately non-fatal, so a missing member or a missing nested type makes a requirement false rather than ending the compilation, and that is exactly what lets the same concept be used as an ordinary boolean in static_assert or if constexpr.
Constraints also order overloads. The compiler normalises each constraint into a conjunction and disjunction of atomic constraints, then asks whether one candidate's atoms imply the other's; if they do, the implying candidate is more constrained and wins outright, with no tag types or dispatch helpers. The catch is the identity rule for atoms: an atomic constraint is tied to the exact expression that was written, so std::is_integral_v<T> spelled out in two separate concepts yields two unrelated atoms and no implication. That is why a stricter concept should be defined in terms of the looser one, as SignedWhole = Whole<T> && std::is_signed_v<T>, which is the only way the implication becomes visible to overload resolution.
Concepts therefore serve two jobs at once: they document and enforce what a template needs, and they give the compiler enough structure to rank templates by how much they demand.
// build: g++ -std=c++20 main.cpp
<concepts>
<cstddef>
<iostream>
<string>
template <typename T>
concept Scalable = requires(T obj, double factor) {
{ obj.scale(factor) } -> std::same_as<void>;
{ obj.size() } -> std::convertible_to<double>;
};
struct Box {
double side = 2.0;
void scale(double f) { side *= f; }
double size() const { return side; }
};
struct Label {
std::string text;
std::size_t size() const { return text.size(); }
};
template <Scalable T>
void grow(T& thing) {
thing.scale(3.0);
std::cout << "size after growing: " << thing.size() << '\n';
}
int main() {
Box b;
grow(b);
std::cout << std::boolalpha
<< "Scalable<Box> = " << Scalable<Box> << '\n'
<< "Scalable<Label> = " << Scalable<Label> << '\n';
// grow(lbl) would be rejected here, at the call, not inside grow.
}
A concept is a named compile-time predicate on template arguments, checked before the body is instantiated, which both moves errors to the call site and lets the compiler rank overloads by how much they require.
Worked examples
The more constrained overload wins
Shows how defining one concept in terms of another lets the compiler prefer the stricter overload instead of reporting ambiguity.
// build: g++ -std=c++20 main.cpp
<iostream>
<type_traits>
template <typename T>
concept Whole = std::is_integral_v<T>;
template <typename T>
concept SignedWhole = Whole<T> && std::is_signed_v<T>;
template <Whole T>
const char* pick(T) { return "Whole"; }
template <SignedWhole T>
const char* pick(T) { return "SignedWhole"; }
int main() {
std::cout << pick(7) << '\n';
std::cout << pick(7u) << '\n';
std::cout << pick(true) << '\n';
}
Example explained
Line 1SignedWhole is written as Whole<T> && ..., so its normalised form literally contains Whole's atomic constraint.
Line 2pick(7) matches both templates, and the compiler prefers SignedWhole because its atoms imply Whole's; that implication is subsumption.
Line 3pick(7u) fails the is_signed atom, so only one candidate survives and there is nothing left to order.
Line 4pick(true) deduces bool, which is integral but not signed, so it takes the same overload as the unsigned call.
Type requirements and nested requirements
Demonstrates a concept that checks for a nested type name and applies a further constraint to it, and shows that a missing nested type is false rather than an error.
// build: g++ -std=c++20 main.cpp
<concepts>
<iostream>
<list>
<string>
<vector>
template <typename R>
concept NumericRange = requires(const R& r) {
r.begin();
r.end();
typename R::value_type;
requires std::integral<typename R::value_type>
|| std::floating_point<typename R::value_type>;
};
template <typename R>
requires NumericRange<R>
auto total(const R& r) {
typename R::value_type sum{};
for (const auto& x : r) sum += x;
return sum;
}
int main() {
std::vector<int> v{1, 2, 3, 4};
std::list<double> d{0.5, 0.25};
std::cout << total(v) << ' ' << total(d) << '\n';
std::cout << std::boolalpha
<< "NumericRange<vector<string>> = "
<< NumericRange<std::vector<std::string>> << '\n'
<< "NumericRange<int> = "
<< NumericRange<int> << '\n';
}
Example explained
Line 1typename R::value_type; is a type requirement: it only asks that the name exists, which is why NumericRange<int> is false instead of a hard error.
Line 2The requires inside the braces is a nested requirement, evaluating another constraint rather than testing an expression for validity.
Line 3requires NumericRange<R> after the parameter list means the same as template <NumericRange R>; the clause form is what you need once a constraint mentions several parameters.
Line 4vector<string> passes begin, end and the type requirement and fails only the nested arithmetic check, so the requirements are tested in the order written.
Important notes
Satisfaction only means the listed expressions are well formed; an operation you forgot to require still fails inside the body at instantiation time, far from the call site.
A concept name doubles as a plain bool for static_assert and if constexpr, but requires-expressions need C++20 enabled (-std=c++20, or /std:c++20 on MSVC).
Common mistakes
Writing { a.size() } -> std::size_t; — the arrow must be followed by a concept, not a type, so this does not compile; write std::same_as<std::size_t> or std::convertible_to<std::size_t>.
Assuming requires(T a) creates a T: nothing is evaluated, so a type with no default constructor can still satisfy the concept, and any side effect you put in a requirement never happens.
Repeating a trait in a stricter concept instead of writing Looser<T> && extra: the two atoms are unrelated, so the call is ambiguous rather than preferring the stricter overload.
Try it yourself
Change, predict, then run
Define a concept Reversible that requires t.rbegin(), t.rend() and a nested value_type, and use it to constrain print_backwards(const T&) so it prints the elements in reverse. Add static_assert(Reversible<std::vector<int>>); and static_assert(!Reversible<int>); to confirm both directions.
Open the C++ workspaceCheck your understanding
Two overloads of f(T) are constrained, one by concept A = std::is_integral_v<T> and the other by concept B = std::is_integral_v<T> && std::is_signed_v<T>, where B spells the first trait out again instead of writing A<T> && .... Why is f(1) rejected as ambiguous?
- Both concepts are satisfied by int, and any call matching two satisfied constrained overloads is ambiguous.
- std::is_integral_v is a variable template rather than a concept, so trait-based constraints take no part in partial ordering.
- The two spellings of std::is_integral_v<T> are distinct atomic constraints, so B does not subsume A and neither overload counts as more constrained.
- Constraint-based partial ordering applies only when the two overloads differ in their function parameter types.
Show answer
Normalisation reduces each constraint to atomic constraints identified by the source expression they came from, so identical text in two separate concept definitions produces two unrelated atoms and no implication is found. The first option is tempting but wrong: rewrite B as A<T> && std::is_signed_v<T> and the very same call resolves to B, which shows the ambiguity comes from how the constraints were written, not from both being satisfied.