3. Concepts¶
What & why¶
A template's requirements used to be implicit: whatever the body happened to use. Misuse produced pages of errors deep inside the implementation. Concepts (C++20) make requirements explicit, checked at the call site, and part of overload resolution. They're the biggest usability improvement to templates since templates.
Using standard concepts¶
#include <concepts>
template <std::integral T>
T gcd(T a, T b) {
while (b != 0) {
a = std::exchange(b, a % b);
}
return a;
}
gcd(12, 18); // OK
gcd(1.5, 2.0);
// error: no matching function for call to 'gcd(double, double)'
// note: constraints not satisfied ... the expression 'is_integral_v<T>' evaluated to 'false'
Four ways to write a constraint¶
// 1. Constrained template parameter
template <std::integral T>
T twice(T x);
// 2. requires clause after the template parameter list
template <typename T>
requires std::integral<T>
T twice(T x);
// 3. Trailing requires clause
template <typename T>
T twice(T x) requires std::integral<T>;
// 4. Abbreviated function template
std::integral auto twice(std::integral auto x);
All four are equivalent here. Use (1) or (4) for simple cases; (2)/(3) when combining constraints with && / ||.
The standard concepts you'll use most¶
| Header | Concepts |
|---|---|
<concepts> |
same_as, derived_from, convertible_to, integral, signed_integral, floating_point, equality_comparable, totally_ordered, copyable, movable, regular, invocable, predicate, constructible_from |
<iterator> |
input_iterator, forward_iterator, bidirectional_iterator, random_access_iterator, contiguous_iterator, sortable, indirectly_readable |
<ranges> |
ranges::range, ranges::sized_range, ranges::input_range, ranges::random_access_range, ranges::contiguous_range, ranges::view |
#include <ranges>
double average(const std::ranges::input_range auto& r)
requires std::floating_point<std::ranges::range_value_t<decltype(r)>> ||
std::integral<std::ranges::range_value_t<decltype(r)>>
{
double sum = 0;
std::size_t n = 0;
for (auto x : r) { sum += x; ++n; }
return n ? sum / n : 0.0;
}
Defining your own concepts¶
A concept is a named compile-time predicate on types:
requires expressions¶
Check that expressions are valid, and what they return:
template <typename T>
concept Shape = requires(const T& s) {
{ s.area() } -> std::convertible_to<double>; // expression + return constraint
{ s.name() } -> std::convertible_to<std::string>;
};
template <typename T>
concept Container = requires(T c) {
typename T::value_type; // type requirement
{ c.size() } -> std::convertible_to<std::size_t>;
c.begin(); // simple requirement: must compile
c.end();
requires std::default_initializable<T>; // nested requirement
};
template <typename T>
concept Hashable = requires(const T& x) {
{ std::hash<T>{}(x) } -> std::convertible_to<std::size_t>;
};
Now use them:
void print_area(const Shape auto& s) {
std::cout << s.name() << ": " << s.area() << '\n';
}
struct Circle {
double r;
double area() const { return 3.14159 * r * r; }
std::string name() const { return "circle"; }
};
print_area(Circle{1.0}); // OK — no inheritance required
print_area(42); // error: 'int' does not satisfy 'Shape'
This is static duck typing with a checked contract — compare with virtual interfaces (runtime) from the intermediate track.
Ad-hoc requires requires¶
For one-off checks you can inline a requires expression in a requires clause:
It's legal but ugly — name a concept instead if you'll use it twice.
Overloading on concepts¶
The compiler picks the most constrained viable overload:
#include <iterator>
template <std::input_iterator It>
void advance_by(It& it, std::iter_difference_t<It> n) {
while (n-- > 0) ++it;
}
template <std::random_access_iterator It> // subsumes input_iterator
void advance_by(It& it, std::iter_difference_t<It> n) {
it += n;
}
A std::vector iterator satisfies both; random_access_iterator is defined in terms of (and therefore subsumes) input_iterator, so the second is more specialized and wins. Subsumption only works when constraints are built from the same named concepts combined with && — two different concepts with identical bodies don't subsume each other.
Constraining class templates and members¶
template <std::movable T>
class Queue {
public:
void push(T value);
// Only available when T is copyable
Queue clone() const requires std::copyable<T>;
// Only available when T can be printed
void dump(std::ostream& os) const
requires requires(std::ostream& o, const T& t) { o << t; };
};
Member functions with unsatisfied constraints simply don't exist for that instantiation — no error unless you call them.
Concepts with if constexpr and static_assert¶
template <typename T>
std::string describe(const T& x) {
if constexpr (std::integral<T>) {
return "integer " + std::to_string(x);
} else if constexpr (Shape<T>) {
return "shape " + x.name();
} else {
return "something";
}
}
static_assert(Shape<Circle>); // document and test that your type models a concept
static_assert(!Shape<int>);
Put static_assert(Concept<YourType>) next to types intended to satisfy a concept — it's a compile-time unit test.
Semantic requirements¶
Concepts check syntax, not meaning. std::totally_ordered requires < to compile, but can't verify that it's actually a total order. The standard concepts come with documented semantic requirements; violating them is undefined behavior in the algorithms that rely on them. Document yours too.
Designing good concepts¶
- Model meaningful abstractions (
Shape,Serializable,Allocator), not single operations (HasPlus). - Build on the standard concepts rather than reinventing them.
- Keep the requirements minimal: exactly what your algorithms use.
- Constrain at the interface; don't add requirements the implementation doesn't need.
Common mistakes¶
- Constraining too little — the body uses operations the concept doesn't require; users still get deep errors.
- Constraining too much — requiring
std::regularwhen you only copy. - Expecting subsumption between different concepts with the same body.
requiresin the wrong place —requires std::integral<T>after the parameter list vs.requires requiresfor an expression.- Forgetting
const&in requires-expression parameters, then rejecting const types. - Using concepts for runtime polymorphism — they're compile-time only; a
std::vectorof "anything satisfyingShape" still needs type erasure orstd::variant.
Exercises¶
- Rewrite the SFINAE examples from lesson 2 (
half,has_size) with concepts. - Define
concept Printable(works withstd::ostream& << x) and writeprint_all(const Printable auto&... xs). - Define a
Shapeconcept and three unrelated structs that model it. Addstatic_asserts. Writetotal_area(const std::vector<S>&)constrained onShape<S>. - Write two overloads of
distance(first, last): one forstd::input_iterator, one forstd::random_access_iterator. Verify which is called for astd::listand astd::vector. - Write
class Cache<K, V>that requiresKto beHashableandstd::equality_comparable, with adump()member available only when both arePrintable. - Pass a type that almost satisfies your concept (missing
conston a method) and read the diagnostic. Compare it with the error from an unconstrained template.
Recap¶
- Concepts are named compile-time predicates; they constrain templates and improve errors.
- Four equivalent syntaxes;
template <Concept T>andConcept autofor simple cases. requires (...) { expr; { expr } -> concept; typename T::x; requires C<T>; }defines requirements.- Overload resolution prefers the most constrained overload via subsumption.
- Constrained members exist only when their constraints hold.
- Concepts check syntax; semantics are your documented responsibility.