C++20 Concepts & Constraints: Writing Readable, Safe Generic Code

C++20 Concepts & Constraints: Writing Readable, Safe Generic Code
Table of Contents
- The Problem: Template Error Wall of Text
- Four Constraint Syntax Forms
- Using Built-In Standard Library Concepts
- requires Expressions: Compound Requirements
- Defining Custom Concepts
- Combining Concepts with && and ||
- Concept-Based Overloading: Subsumption Rules
- Concepts as Template Parameter Constraints
- std::ranges Concepts for Collections
- Frequently Asked Questions
- Key Takeaway
The Problem: Template Error Wall of Text
// Without Concepts — template that expects printable types:
template<typename T>
void log(T val) {
std::cout << val << '\n'; // Requires operator<<
}
struct Widget {}; // Widget has no operator<<
log(Widget{}); // Compiles fine... but:
// ERROR: 71 lines of error messages about std::ostream, operator<<,
// function overload candidates, template instantiation history...
// NONE of which tell you "Widget doesn't support operator<<"
// WITH Concepts — single clear error:
template<typename T>
concept Printable = requires(T v, std::ostream& os) { os << v; };
template<Printable T>
void log_safe(T val) { std::cout << val << '\n'; }
log_safe(Widget{});
// ERROR: candidate template ignored: constraints not satisfied [with T = Widget]
// note: 'Widget' does not satisfy 'Printable'
// → One line. Exact violation. Fixed in 30 seconds.Four Constraint Syntax Forms
All four forms are equivalent — choose the most readable for each context:
#include <concepts>
// Form 1: requires clause after template parameter list
template<typename T>
requires std::integral<T>
T double_it(T x) { return x * 2; }
// Form 2: requires clause after function signature
template<typename T>
T triple_it(T x) requires std::integral<T> { return x * 3; }
// Form 3: Concept shorthand (most concise for single constrained param)
template<std::integral T>
T quadruple_it(T x) { return x * 4; }
// Form 4: Abbreviated function template (C++20 — auto + concept)
auto quintuple_it(std::integral auto x) { return x * 5; }
// Exactly equivalent to Form 3
// All four are 100% equivalent — same overload resolution, same error messages
// Preference: Form 3/4 for simple cases, Form 1 for complex multi-requirement constraintsUsing Built-In Standard Library Concepts
The <concepts> header provides a comprehensive set of ready-made constraints:
#include <concepts>
// === Type category concepts ===
void process_int(std::integral auto x) { /* int/long/char/bool/... */ }
void process_fp(std::floating_point auto x) { /* float/double/long double */ }
void process_num(std::arithmetic auto x) { /* integral OR floating_point */ }
void process_signed(std::signed_integral auto x) { /* int/long/short/... */ }
// === Relationship concepts ===
template<typename From, typename To>
requires std::convertible_to<From, To>
To safe_cast(From val) { return static_cast<To>(val); }
template<typename T, typename U>
requires std::same_as<T, U> // Exactly the same type
void identical_types_only(T a, U b) {}
// === Object concepts ===
template<typename T>
requires std::copy_constructible<T> // Can be copied
void duplicate(T val) { T copy = val; }
template<typename T>
requires std::movable<T> // Can be moved
void transfer(T& src, T& dst) { dst = std::move(src); }
template<typename T>
requires std::default_initializable<T> // T{} is valid
T make_default() { return T{}; }
// === Callable concepts ===
template<typename F, typename... Args>
requires std::invocable<F, Args...> // F(Args...) is valid
auto call(F&& fn, Args&&... args) {
return std::invoke(std::forward<F>(fn), std::forward<Args>(args)...);
}
template<typename F, typename Arg>
requires std::predicate<F, Arg> // F(Arg) returns bool
void filter(std::vector<Arg>& v, F pred) {
std::erase_if(v, [&](const Arg& a){ return !pred(a); });
}requires Expressions: Compound Requirements
requires expressions check validity of expressions — they test syntax, not runtime behavior:
// Simple requirement: expression compiles
template<typename T>
concept HasSize = requires(T v) {
v.size(); // size() must be a valid call on T
};
// Type requirement: expression has a specific type
template<typename T>
concept HasValueType = requires {
typename T::value_type; // T::value_type must exist as a type
};
// Nested requirement: additional constraint on expression's result
template<typename T>
concept SizedContainer = requires(T v) {
{ v.size() } -> std::convertible_to<size_t>; // size() must return something convertible to size_t
{ v.empty() } -> std::same_as<bool>; // empty() must return exactly bool
typename T::value_type; // must have value_type
};
// Compound requirement — multiple checks:
template<typename T>
concept Serializable = requires(T v, std::ostream& out, std::istream& in) {
{ v.serialize(out) } -> std::same_as<void>; // has void serialize(ostream&)
{ T::deserialize(in) } -> std::same_as<T>; // has static T deserialize(istream&)
{ v.byte_size() } -> std::integral; // byte_size() returns integral
};Defining Custom Concepts
#include <concepts>
#include <ranges>
#include <ostream>
// Printable: can be streamed to ostream
template<typename T>
concept Printable = requires(T v, std::ostream& os) {
{ os << v } -> std::same_as<std::ostream&>;
};
// Hashable: works as key in unordered containers
template<typename T>
concept Hashable = requires(T v) {
{ std::hash<T>{}(v) } -> std::convertible_to<size_t>;
{ v == v } -> std::same_as<bool>; // Can compare for equality
};
// Numeric: arithmetic and comparable
template<typename T>
concept Numeric = std::arithmetic<T> && requires(T a, T b) {
{ a + b } -> std::same_as<T>;
{ a - b } -> std::same_as<T>;
{ a * b } -> std::same_as<T>;
{ a / b } -> std::same_as<T>;
{ a < b } -> std::convertible_to<bool>;
};
// Container: has begin/end/size
template<typename C>
concept Container = requires(C c) {
typename C::value_type;
typename C::iterator;
{ c.begin() } -> std::input_or_output_iterator;
{ c.end() } -> std::input_or_output_iterator;
{ c.size() } -> std::integral;
{ c.empty() } -> std::convertible_to<bool>;
};
// Usage:
template<Numeric T>
T clamp(T val, T lo, T hi) {
return std::max(lo, std::min(val, hi));
}
template<Container C>
void print_container(const C& c) {
for (const auto& v : c) std::cout << v << ' ';
std::cout << '\n';
}
template<Hashable K, typename V>
class SafeMap {
std::unordered_map<K, V> data_;
public:
V& operator[](const K& key) { return data_[key]; }
};Combining Concepts with && and ||
// Both constraints must be satisfied:
template<typename T>
requires std::integral<T> && std::signed_integral<T>
T safe_negate(T x) { return -x; }
// Either constraint is sufficient:
template<typename T>
requires std::integral<T> || std::floating_point<T>
T square(T x) { return x * x; }
// Negation:
template<typename T>
requires (!std::is_pointer_v<T>)
T safe_copy(T val) { return val; }
// Complex compound:
template<typename T>
requires (std::semiregular<T> &&
std::totally_ordered<T> &&
(sizeof(T) <= 16))
class StackSet {
// T can be default-constructed, copied, moved, compared, and fits in 2 cache words
};Concept-Based Overloading: Subsumption Rules
When multiple constrained overloads are valid, the compiler picks the most constrained one:
// General: handles any range
template<std::ranges::range R>
void sort_it(R& range) {
// Fallback: general sort using std::begin/std::end
std::vector<typename R::value_type> tmp(range.begin(), range.end());
std::sort(tmp.begin(), tmp.end());
std::ranges::copy(tmp, range.begin());
}
// More constrained: handles random-access ranges (faster algorithm)
template<std::ranges::random_access_range R>
void sort_it(R& range) {
// Better: direct in-place sort (random_access_range ⊃ range)
std::sort(range.begin(), range.end());
}
// Even more constrained: handles contiguous ranges + trivial types (SIMD-eligible)
template<std::ranges::contiguous_range R>
requires std::is_trivially_copyable_v<std::ranges::range_value_t<R>>
void sort_it(R& range) {
// Best: SIMD-optimized or radix sort
std::sort(range.begin(), range.end()); // Could be replaced with SIMD sort
}
std::vector<int> v = {5,2,8,1};
std::list<int> l = {5,2,8,1};
sort_it(v); // Uses contiguous_range overload (most constrained, vector qualifies)
sort_it(l); // Uses range overload (list is not random access)Frequently Asked Questions
Do concepts impact runtime performance? No — concepts are purely compile-time. They add zero runtime overhead. The generated machine code for a constrained template is identical to an unconstrained one; constraints only affect which instantiation is chosen and what errors are produced.
Can I use concepts to check for non-type properties, like a specific value?
No — concepts check syntactic and semantic properties of types (do they have this function? does it return this type?). They cannot check runtime values or non-type properties directly. For value-based compile-time checks, use if constexpr with static_assert, or template non-type parameters with value constraints.
Should I use concepts everywhere, or only at interfaces?
Use concepts on every public template function/class interface — they serve as documentation and provide better error messages. Inside implementation details, if constexpr + type traits are still appropriate for compile-time branching. Don't add concepts to private helper functions that aren't part of any public API.
Key Takeaway
C++20 Concepts complete the template programming story. Before: templates were "duck typing" — if it compiles, it works; if not, good luck reading the error. After: templates are precisely specified contracts. The requires clause says exactly what properties T must have, the compiler verifies this at the call site, and error messages name the violated constraint. Combined with standard library concepts (<concepts>, <ranges>), you can constrain almost any generic API without writing a single custom concept.
Read next: Meta-programming: constexpr and Compile-Time Logic →
Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.
