C++20 Concepts constraint checking tutorial
|

C++20 Concepts: Constrain Templates with Readable Requirements

If you have ever stared at a 200-line compiler error because you passed the wrong type to a function template, you already understand why C++20 Concepts exist. Templates are powerful, but their error messages have been a running joke in the C++ community for decades. Concepts change that story entirely. They let you constrain template parameters with human-readable requirements, and when a type fails to meet those requirements, the compiler tells you exactly what went wrong — in plain English.

This lesson covers everything you need to start using Concepts effectively: defining them, applying them, leveraging the standard library’s built-in concepts, and understanding the subtleties like subsumption and concept-based overloading.

What Are Concepts?

A concept is a named set of constraints on a template parameter. Think of it as an interface contract — similar to abstract classes and interfaces, but enforced entirely at compile time with zero runtime cost. A concept answers a simple question: “Does type T support the operations I need?”

Before C++20, programmers used std::enable_if and SFINAE (Substitution Failure Is Not An Error) to constrain templates. These techniques worked, but they produced code that looked like it was written by a compiler for a compiler. Concepts replace all of that with clean, declarative syntax.

The Problem Concepts Solve

Consider a simple function template that sorts a vector. Without concepts, nothing stops someone from passing a type that cannot be compared:

// Pre-C++20: No constraints. Compiles until instantiation,
// then produces a wall of errors if T lacks operator<
template<typename T>
void sort_vector(std::vector<T>& v) {
    std::sort(v.begin(), v.end());
}

struct Widget {};  // No operator<

int main() {
    std::vector<Widget> widgets;
    sort_vector(widgets);  // Compiles... then explodes with cryptic errors
}

The error message from GCC or Clang in this scenario can easily span 50+ lines, pointing deep into the internals of std::sort. The actual problem — Widget has no comparison operator — is buried under layers of template instantiation noise. Concepts fix this by catching the problem at the call site.

Defining Your First Concept

A concept is defined with the concept keyword. It evaluates to a compile-time boolean:

#include <concepts>
#include <vector>
#include <algorithm>

// Define a concept: T must be comparable with operator<
template<typename T>
concept Sortable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
};

// Use the concept as a constraint
template<Sortable T>
void sort_vector(std::vector<T>& v) {
    std::sort(v.begin(), v.end());
}

struct Widget {};  // Still no operator<

int main() {
    std::vector<int> numbers = {3, 1, 4, 1, 5};
    sort_vector(numbers);  // OK: int satisfies Sortable

    std::vector<Widget> widgets;
    sort_vector(widgets);  // Error: "Widget does not satisfy Sortable"
}

The error message now points directly at the problem: Widget does not satisfy Sortable. No template internals, no SFINAE noise — just a clear, actionable message. This is a fundamental improvement in how C++ communicates with developers.

The Requires Clause

There are four syntactic ways to apply a concept to a template parameter. All four are equivalent — choose the one that reads best in context:

#include <concepts>

// Syntax 1: Concept in place of typename
template<std::integral T>
T double_it(T value) { return value * 2; }

// Syntax 2: Trailing requires clause
template<typename T>
    requires std::integral<T>
T triple_it(T value) { return value * 3; }

// Syntax 3: Trailing requires clause after parameter list
template<typename T>
T quadruple_it(T value) requires std::integral<T>
{ return value * 4; }

// Syntax 4: Abbreviated function template (auto + concept)
std::integral auto halve_it(std::integral auto value) {
    return value / 2;
}

int main() {
    double_it(42);      // OK
    triple_it(10);      // OK
    quadruple_it(7);    // OK
    halve_it(100);      // OK

    // double_it(3.14);  // Error: double does not satisfy std::integral
}

Syntax 1 is the cleanest for simple, single-concept constraints. Syntax 2 shines when you have complex compound constraints. Syntax 3 is useful for member functions in class templates. Syntax 4 is the most concise but trades some explicitness for brevity.

Requires Expressions in Depth

The requires expression is the building block of concept definitions. It checks whether certain operations are valid for a type — without actually executing them. There are four kinds of requirements you can express:

#include <concepts>
#include <string>
#include <iostream>

template<typename T>
concept Printable = requires(T val, std::ostream& os) {
    // Simple requirement: the expression must be valid
    os << val;

    // Compound requirement: expression must be valid AND return a specific type
    { os << val } -> std::same_as<std::ostream&>;

    // Type requirement: an associated type must exist
    typename T::value_type;  // T must have a nested value_type

    // Nested requirement: a compile-time boolean condition
    requires sizeof(T) > 1;
};

// A type that satisfies Printable
struct LogEntry {
    using value_type = std::string;
    std::string message;
    friend std::ostream& operator<<(std::ostream& os, const LogEntry& e) {
        return os << e.message;
    }
};

template<Printable T>
void log(const T& entry) {
    std::cout << entry << "\n";
}

int main() {
    LogEntry entry{"Server started"};
    log(entry);  // OK: LogEntry satisfies all four requirements
    // log(42);  // Error: int has no value_type, and sizeof(int) may be 4 but int has no value_type
}

The compound requirement with the -> arrow is particularly useful. It says, "This expression must compile, and its return type must satisfy this concept." This is how you enforce not just that an operation exists, but that it returns something meaningful. The cppreference documentation on requires expressions covers every edge case in detail.

Standard Library Concepts

C++20 ships with a rich library of predefined concepts in the <concepts> header. You should prefer these over writing your own whenever possible — they are well-tested, well-documented, and universally understood:

#include <concepts>
#include <iostream>
#include <string>
#include <vector>

// Core language concepts
template<std::integral T>
void process_integer(T val) { std::cout << "Integer: " << val << "\n"; }

template<std::floating_point T>
void process_float(T val) { std::cout << "Float: " << val << "\n"; }

// Comparison concepts
template<std::totally_ordered T>
T find_max(const std::vector<T>& v) {
    T result = v[0];
    for (const auto& elem : v)
        if (elem > result) result = elem;
    return result;
}

// Object concepts
template<std::copyable T>
T duplicate(const T& val) { return val; }

template<std::movable T>
void transfer(T& from, T& to) { to = std::move(from); }

// Callable concepts
template<std::invocable<int, int> F>
int apply(F func, int a, int b) { return func(a, b); }

int main() {
    process_integer(42);         // OK
    process_float(3.14);         // OK
    // process_integer(3.14);    // Error: double is not integral

    std::vector<int> nums = {5, 2, 8, 1};
    std::cout << find_max(nums) << "\n";  // 8

    auto result = apply([](int a, int b) { return a + b; }, 3, 4);
    std::cout << result << "\n";  // 7
}

The standard concepts fall into several categories: core language concepts (std::same_as, std::derived_from, std::convertible_to, std::integral, std::floating_point), comparison concepts (std::equality_comparable, std::totally_ordered), object concepts (std::movable, std::copyable, std::regular), and callable concepts (std::invocable, std::predicate). The full list is available at the cppreference concepts library page.

Concept-Based Overloading

One of the most powerful features of concepts is concept-based overloading: providing different implementations for different sets of constraints. The compiler picks the most constrained overload that matches:

#include <concepts>
#include <iostream>
#include <string>
#include <cmath>

// Overload 1: for integral types
template<std::integral T>
std::string stringify(T value) {
    return "int:" + std::to_string(value);
}

// Overload 2: for floating-point types
template<std::floating_point T>
std::string stringify(T value) {
    return "float:" + std::to_string(value);
}

// Overload 3: for anything with a .to_string() method
template<typename T>
    requires requires(T val) { { val.to_string() } -> std::convertible_to<std::string>; }
std::string stringify(T value) {
    return "custom:" + value.to_string();
}

struct Point {
    double x, y;
    std::string to_string() const {
        return "(" + std::to_string(x) + ", " + std::to_string(y) + ")";
    }
};

int main() {
    std::cout << stringify(42) << "\n";       // "int:42"
    std::cout << stringify(3.14) << "\n";     // "float:3.140000"
    std::cout << stringify(Point{1, 2}) << "\n"; // "custom:(1.000000, 2.000000)"
}

This replaces the old std::enable_if pattern entirely. Where before you needed verbose SFINAE tricks, now each overload clearly states what it requires. The compiler resolves ambiguity through subsumption rules, which we cover next.

Abbreviated Function Templates

C++20 lets you use auto with a concept prefix in function parameters. This implicitly creates a function template — each auto parameter introduces its own template parameter:

#include <concepts>
#include <iostream>
#include <numeric>
#include <vector>

// This is a function template, not a regular function.
// Each 'auto' parameter is independently deduced.
void print_sum(std::integral auto a, std::floating_point auto b) {
    std::cout << "Sum: " << (a + b) << "\n";
}

// Constrained return type
std::integral auto compute_length(const std::string& s) {
    return static_cast<int>(s.size());  // Must return an integral type
}

// Unconstrained auto still works (plain template)
auto add(auto a, auto b) {
    return a + b;  // Works for any types that support operator+
}

int main() {
    print_sum(10, 3.5);         // OK: int + double
    // print_sum(3.5, 10);      // Error: 3.5 is not integral

    auto len = compute_length("hello");
    std::cout << len << "\n";  // 5

    std::cout << add(1, 2) << "\n";           // 3
    std::cout << add(1.5, 2.5) << "\n";       // 4.0
}

Abbreviated templates are ideal for short utility functions. For complex templates with multiple parameters that share the same type, the traditional template<Concept T> syntax is better because it ensures both parameters have the same type T. With abbreviated syntax, each auto can deduce a different type.

Combining Concepts with && and ||

Concepts compose naturally using logical operators. You can build complex constraints from simpler ones:

#include <concepts>
#include <iostream>
#include <type_traits>

// Combine with && (conjunction): must satisfy BOTH
template<typename T>
concept SignedInteger = std::integral<T> && std::is_signed_v<T>;

// Combine with || (disjunction): must satisfy EITHER
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

// Build layered concepts
template<typename T>
concept Hashable = requires(T val) {
    { std::hash<T>{}(val) } -> std::convertible_to<std::size_t>;
};

template<typename T>
concept MapKey = std::totally_ordered<T> && Hashable<T> && std::copyable<T>;

template<MapKey K>
void use_as_key(const K& key) {
    std::cout << "Valid map key: " << key << "\n";
}

template<Numeric T>
T absolute(T val) {
    return val < 0 ? -val : val;
}

int main() {
    use_as_key(std::string("hello"));  // OK: string is ordered, hashable, copyable
    use_as_key(42);                    // OK: int qualifies too

    std::cout << absolute(-5) << "\n";    // 5
    std::cout << absolute(-3.7) << "\n";  // 3.7
}

Composing concepts this way is far more readable than the equivalent std::enable_if chains. It also works cleanly with type casting and type traits, since concepts can incorporate type_traits predicates directly.

Concept Subsumption

When two constrained overloads both match, the compiler uses subsumption to choose the more specific one. Concept A subsumes concept B if A's constraints logically imply B's. The more constrained overload wins:

#include <concepts>
#include <iostream>

template<typename T>
concept Animal = requires(T a) { a.speak(); };

template<typename T>
concept Pet = Animal<T> && requires(T a) { a.name(); };
// Pet subsumes Animal because Pet implies Animal

template<Animal T>
void greet(const T& a) {
    std::cout << "Some animal speaks: ";
    a.speak();
}

template<Pet T>
void greet(const T& a) {
    std::cout << "Pet named " ;
    a.name();
    std::cout << " says: ";
    a.speak();
}

struct Dog {
    void speak() const { std::cout << "Woof!\n"; }
    void name() const { std::cout << "Rex"; }
};

struct Wolf {
    void speak() const { std::cout << "Howl!\n"; }
};

int main() {
    Dog d;
    Wolf w;
    greet(d);  // Calls Pet overload (more constrained)
    greet(w);  // Calls Animal overload (Wolf has no name())
}

Subsumption only works when one concept is defined in terms of the other using conjunction (&&). If two concepts have overlapping but unrelated constraints, calling with a type that satisfies both is ambiguous — the compiler will reject it. The partial ordering by constraints rules on cppreference explain the full algorithm.

Common Mistakes and Pitfalls

1. Forgetting That Requires Expressions Don't Execute Code

A requires expression only checks whether an expression is syntactically valid — it does not evaluate it. A division by zero inside a requires expression will not cause an error, because the expression is never actually run:

// This concept checks syntax, not runtime behavior
template<typename T>
concept Divisible = requires(T a, T b) { a / b; };
// int satisfies Divisible even though dividing by zero is undefined behavior

2. Abbreviated Templates Create Independent Type Parameters

Each auto in an abbreviated template deduces independently. If you need two parameters of the same type, use the explicit template syntax:

// BUG: a and b can be different types
auto add(std::integral auto a, std::integral auto b) { return a + b; }
add(1, 2L);  // OK: int + long — maybe not what you wanted

// FIX: force same type
template<std::integral T>
T add_same(T a, T b) { return a + b; }
// add_same(1, 2L);  // Error: deduction conflict (int vs long)

3. Subsumption Requires Syntactic Identity

The compiler determines subsumption by analyzing the syntactic structure of constraints. Two logically identical but syntactically different constraints are not recognized as equivalent:

// These are logically the same but syntactically different
template<typename T>
concept A = std::integral<T>;

template<typename T>
concept B = std::integral<T>;

// Using A and B in overloads is AMBIGUOUS — the compiler
// does not know A and B are the same thing

4. Overusing Ad-Hoc Constraints

Inline requires clauses are convenient, but naming your concepts makes code far more maintainable. A named concept is documentation; an inline constraint is implementation detail.

5. Not Using Standard Concepts

Before writing concept Addable = requires(T a, T b) { a + b; };, check if <concepts> or <iterator> already provides what you need. The standard concepts proposal (P0898) documents the design rationale behind each one.

Key Takeaways

  • Concepts are named boolean predicates that constrain template parameters at compile time, producing clear error messages instead of SFINAE noise.
  • Four syntax forms exist for applying concepts: concept-in-place-of-typename, trailing requires clause, post-parameter requires clause, and abbreviated function templates.
  • Requires expressions check syntactic validity of operations without executing them. They support simple, compound, type, and nested requirements.
  • The standard library provides dozens of ready-made concepts in <concepts>, <iterator>, and <ranges>. Prefer these over custom definitions.
  • Concept-based overloading replaces enable_if/SFINAE with readable, maintainable code. The compiler selects the most constrained matching overload.
  • Subsumption resolves ambiguity when one concept logically implies another, but only when the implication is expressed through syntactic conjunction (&&).
  • Abbreviated function templates (auto with concepts) provide concise syntax but create independent type parameters per auto — use explicit template parameters when types must match.
  • Concepts work hand-in-hand with function templates and class templates, making generic programming in C++ dramatically more accessible.

Concepts are arguably the single biggest usability improvement C++ has received since templates themselves were introduced. They do not add runtime cost, they do not change how templates are compiled — they simply make templates understandable. Every new template you write from now on should use concepts. Your future self, reading a compiler error at 2 AM, will thank you.

For further reading, explore the constraints and concepts language reference, the ISO C++ FAQ on concepts, and Bjarne Stroustrup's original design paper on concepts.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *