C++Generic Programming

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

TT
TopicTrick Team
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

cpp
// 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:

cpp
#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 constraints

Using Built-In Standard Library Concepts

The <concepts> header provides a comprehensive set of ready-made constraints:

cpp
#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:

cpp
// 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

cpp
#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 ||

cpp
// 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:

cpp
// 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.