Home C / C++ C++20 Concepts Explained: Constraints, Requires Clauses and Real-World Usage

C++20 Concepts Explained: Constraints, Requires Clauses and Real-World Usage

In Plain English 🔥
Imagine you're running a bakery and you hire helpers. You don't just want 'anyone' — you want someone who can frost cakes AND use an oven. Instead of hiring them and discovering mid-shift they can't bake, you check those skills upfront at the interview. C++20 Concepts work exactly like that job interview checklist for your template functions: you state exactly what abilities a type must have before the compiler even attempts to compile your code. No more cryptic 30-line error messages — just a clean 'this type doesn't meet the requirements' message at the right moment.
⚡ Quick Answer
Imagine you're running a bakery and you hire helpers. You don't just want 'anyone' — you want someone who can frost cakes AND use an oven. Instead of hiring them and discovering mid-shift they can't bake, you check those skills upfront at the interview. C++20 Concepts work exactly like that job interview checklist for your template functions: you state exactly what abilities a type must have before the compiler even attempts to compile your code. No more cryptic 30-line error messages — just a clean 'this type doesn't meet the requirements' message at the right moment.

Before C++20, writing generic code in C++ was like signing a contract written in invisible ink. You'd author a template, ship it, and only discover at compile time — buried under an avalanche of substitution-failure errors — that a user passed a type that simply wasn't compatible. The template machinery would choke deep inside instantiation, producing error messages that looked like the compiler had a breakdown. Senior engineers learned to read those stack traces like tea leaves. Everyone else just suffered.

Concepts are C++20's answer to that chaos. They let you attach formal, human-readable constraints to templates. The compiler checks those constraints before even attempting instantiation. If a type doesn't satisfy a concept, you get a crisp, targeted diagnostic pointing directly at the mismatch. Beyond error messages, concepts enable overload resolution that was previously impossible without arcane SFINAE tricks — letting you write genuinely different code paths based on what a type can do, not just what it is.

By the end of this article you'll be able to define your own named concepts, apply them using all four syntax forms, understand how concept subsumption drives overload selection, spot the subtle gotchas that bite even experienced engineers, and know exactly when reaching for a concept adds value versus when it's overkill. We'll look at real output, real diagnostics, and the performance implications that matter in production.

Defining Concepts: What a Constraint Actually Is Under the Hood

A concept is a named predicate — a compile-time boolean expression evaluated against one or more template parameters. Syntactically it looks like a variable template that yields true or false, but semantically it's much richer because the compiler uses concepts for constraint checking, overload ranking, and diagnostics, none of which an ordinary bool variable template can do.

The body of a concept is a constraint expression. The most powerful form uses a requires expression — a block that lists operations the type must support, return types those operations must yield, and nested requirements that must hold. The requires expression doesn't execute any code; it checks whether the expression would be well-formed. This is the critical distinction: it's purely a syntactic and semantic check at the point of constraint evaluation.

Under the hood the compiler normalises every constraint into a conjunction or disjunction of atomic constraints. An atomic constraint is an expression that can't be decomposed further — typically a single requires expression or a concept specialisation. This normalisation is what powers subsumption: the compiler can determine that one concept is strictly more refined than another, enabling it to pick the 'most constrained' overload without ambiguity.

A concept cannot be recursive, cannot refer to itself, and cannot be specialised. These restrictions aren't arbitrary — they keep constraint normalisation decidable and prevent infinite loops during compilation.

ConceptDefinition.cpp · CPP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
#include <concepts>
#include <iostream>
#include <string>
#include <vector>

// --- Defining a simple concept using a requires expression ---
// The requires expression checks that:
//   1. T has a .size() member returning something convertible to std::size_t
//   2. T supports the subscript operator [] with an integral index
//   3. T is default-constructible
template <typename T>
concept Sequence = requires(T container, std::size_t index) {
    { container.size() } -> std::convertible_to<std::size_t>; // return-type constraint
    { container[index] };                                       // simple validity check
    T{};                                                        // nested requirement: must default-construct
};

// --- A more refined concept that builds ON Sequence ---
// Because SortableSequence requires Sequence AND ordering, it SUBSUMES Sequence.
// The compiler will prefer SortableSequence overloads over plain Sequence overloads.
template <typename T>
concept SortableSequence = Sequence<T> && requires(T container, std::size_t i) {
    { container[i] < container[i] } -> std::same_as<bool>; // elements must be comparable
};

// --- Function constrained with the plain Sequence concept ---
// This overload is chosen for types satisfying Sequence but NOT SortableSequence.
template <Sequence S>
void describe(const S& container) {
    std::cout << "[Sequence] size = " << container.size() << '\n';
}

// --- More-constrained overload: chosen when SortableSequence is satisfied ---
// Subsumption guarantees no ambiguity — the compiler picks this one for std::vector<int>.
template <SortableSequence S>
void describe(const S& container) {
    std::cout << "[SortableSequence] size = " << container.size()
              << ", first element = " << container[0] << '\n';
}

int main() {
    std::vector<int> numbers = {42, 7, 19};
    describe(numbers); // SortableSequence overload wins — int supports operator<

    // std::string satisfies Sequence (has size(), operator[]) AND SortableSequence
    // because char supports operator<
    std::string greeting = "hello";
    describe(greeting);

    // Static assertion: document assumptions directly in code
    static_assert(Sequence<std::vector<int>>,
        "vector<int> must satisfy Sequence");
    static_assert(SortableSequence<std::vector<int>>,
        "vector<int> must satisfy SortableSequence");

    return 0;
}
▶ Output
[SortableSequence] size = 3, first element = 42
[SortableSequence] size = 5, first element = h
🔥
Compiler Insight:The requires expression body is never instantiated — the compiler only checks well-formedness. Writing { container.size() } -> std::convertible_to does NOT call size(). It asks: 'would this expression compile and would its type satisfy convertible_to?' That's why concepts have zero runtime cost.

Four Ways to Apply Concepts — and When to Use Each One

C++20 gives you four distinct syntactic positions to attach a concept. They're all equivalent in terms of what constraint they impose, but they differ dramatically in readability, and choosing the right form is a genuine engineering decision.

The terse template syntax — writing the concept name directly where typename would go — is the cleanest for single-parameter constraints. It communicates intent at a glance. Use it when a function takes one constrained type and the constraint name is self-documenting.

The requires clause after the template parameter list is the right tool when you need compound constraints (combining multiple concepts with && or ||), or when you need to express constraints that span multiple parameters. It's more explicit and slightly more verbose.

The trailing requires clause — placed after the function signature but before the body — is useful when the constraint logically reads as a postcondition on the full signature, especially for member functions where you want the constraint visible near the return type.

Finally, auto parameters in abbreviated function templates are the most compact form, but they create unconstrained templates by default. Pairing a concept name before auto gives you a clean, lambda-like syntax for short utility functions. Know all four: interviewers test exactly this, and real codebases use all of them depending on context.

FourSyntaxForms.cpp · CPP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
#include <concepts>
#include <iostream>
#include <numeric>
#include <vector>

// FORM 1: Terse syntax — concept name replaces 'typename' in template parameter
// Best for: single-type constraints, maximum readability
template <std::integral IntegerType>
IntegerType square(IntegerType value) {
    return value * value; // only compiles for int, long, char, etc.
}

// FORM 2: requires clause after template parameter list
// Best for: multi-parameter constraints or compound conditions
template <typename ElementType, typename ContainerType>
    requires std::same_as<typename ContainerType::value_type, ElementType>
          && std::default_initializable<ElementType>
ElementType sum_container(const ContainerType& container) {
    // Guaranteed: ElementType IS the container's element type AND is default-constructible
    ElementType total{}; // default-initialise to zero (works for int, double, etc.)
    for (const auto& element : container) {
        total += element;
    }
    return total;
}

// FORM 3: Trailing requires clause — after the function parameter list
// Best for: constraints that reference parameter types computed from the signature
template <typename Callable, typename ArgumentType>
auto invoke_and_print(Callable&& function, ArgumentType argument)
    -> decltype(function(argument))
    requires std::invocable<Callable, ArgumentType> // constraint reads naturally here
{
    auto result = function(argument);
    std::cout << "Result: " << result << '\n';
    return result;
}

// FORM 4: Abbreviated function template with concept-constrained auto
// Best for: short utility lambdas and simple free functions
// 'std::floating_point auto' means: deduce the type, but it MUST satisfy floating_point
void print_precision(std::floating_point auto value) {
    std::cout << "Floating value: " << value << '\n';
}

int main() {
    // Form 1 — works with int, long; would fail for float (not integral)
    std::cout << "4 squared = " << square(4) << '\n';
    std::cout << "7L squared = " << square(7L) << '\n';

    // Form 2 — sums a vector<double>
    std::vector<double> prices = {9.99, 4.50, 12.75};
    std::cout << "Total price = " << sum_container<double>(prices) << '\n';

    // Form 3 — invokes a lambda and prints the result
    auto doubler = [](int n) { return n * 2; };
    invoke_and_print(doubler, 21); // prints 42

    // Form 4 — works for float and double; fails for int (not floating_point)
    print_precision(3.14159f);
    print_precision(2.71828);

    // Uncommenting the line below gives a clean diagnostic:
    // square(3.14); // error: '3.14' does not satisfy 'std::integral'

    return 0;
}
▶ Output
4 squared = 16
7L squared = 49
Total price = 27.24
Result: 42
Floating value: 3.14159
Floating value: 2.71828
⚠️
Rule of Thumb:Use the terse syntax (Form 1) for everyday single-constraint cases. Switch to the requires clause (Form 2 or 3) the moment you need && or || logic, or when the constraint involves relationships between multiple type parameters. Mixing forms in one overload set causes readability chaos — pick one style per API surface.

Subsumption, Overload Resolution and Why Ordering Concepts Matters

Subsumption is the mechanism that lets the compiler rank constrained overloads without ambiguity. If concept B is defined in terms of concept A (that is, satisfying B logically implies satisfying A), the compiler knows B is more constrained. When both overloads match, it picks the more constrained one — no ambiguity error, no user-side tricks needed.

The critical rule: subsumption only works through concept names, not through raw type traits. If you write the same constraint inline in two places using raw requires expressions rather than naming a concept, the compiler cannot prove they're identical — it treats them as different atomic constraints and you get an ambiguity error. This is the biggest practical gotcha in real codebases migrating from SFINAE to concepts.

Subsumption is checked syntactically at the level of normalised atomic constraints. Two atomic constraints subsume each other only if they originate from the same concept specialisation. This means copy-pasting a requires body doesn't achieve subsumption — you must factor it into a named concept.

In performance terms, none of this is runtime cost. It's purely a compile-time ranking algorithm that runs during overload resolution. The only cost is potentially longer compile times in constraint-heavy translation units, because the compiler must normalise and compare constraint sets for every candidate overload.

SubsumptionDemo.cpp · CPP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
#include <concepts>
#include <iostream>
#include <iterator>
#include <vector>
#include <list>

// --- Concept hierarchy for iterators ---
// InputIterable: can be iterated forward (covers list, vector, etc.)
template <typename Container>
concept InputIterable = requires(Container c) {
    { std::begin(c) } -> std::input_iterator;
    { std::end(c) };
};

// RandomAccessIterable: subsumes InputIterable because it requires it PLUS random access.
// This is the key: we REFERENCE InputIterable by name so subsumption works.
template <typename Container>
concept RandomAccessIterable = InputIterable<Container> && requires(Container c, std::size_t n) {
    { c[n] };                                           // random access by index
    { std::end(c) - std::begin(c) } -> std::integral; // distance is O(1)
};

// --- Overload for any input-iterable container (less constrained) ---
// Chosen for std::list, where random access doesn't exist
template <InputIterable Container>
void process(const Container& container) {
    std::cout << "[InputIterable path] linear scan, size computed by traversal\n";
    std::size_t count = 0;
    for (const auto& element : container) { ++count; (void)element; }
    std::cout << "  Element count: " << count << '\n';
}

// --- Overload for random-access containers (more constrained) ---
// Compiler picks THIS one for std::vector — subsumption guarantees no ambiguity.
template <RandomAccessIterable Container>
void process(const Container& container) {
    std::cout << "[RandomAccessIterable path] O(1) size, direct index access\n";
    // Safe to use operator[] because the concept guarantees it
    std::cout << "  First element: " << container[0] << '\n';
    std::cout << "  Size (O1): " << (std::end(container) - std::begin(container)) << '\n';
}

// --- WRONG WAY: inline requires instead of concept name breaks subsumption ---
// If you write the same constraint inline in both overloads, you get:
//   error: call to 'process_wrong' is ambiguous
// because the compiler sees two different atomic constraints that happen to say the same thing.
template <typename T>
    requires requires(T c) { std::begin(c); }  // raw inline — NOT a named concept
void process_wrong(const T& container) { std::cout << "overload A\n"; }

// This overload cannot subsume the one above because neither references the other by name
template <typename T>
    requires requires(T c) { std::begin(c); } && requires(T c, int n) { c[n]; }
void process_wrong(const T& container) { std::cout << "overload B\n"; }

int main() {
    std::vector<int> scores = {10, 20, 30};
    std::list<int>   tasks  = {1, 2, 3};

    process(scores); // RandomAccessIterable wins — subsumption at work
    process(tasks);  // Only InputIterable matches — list has no operator[]

    // Uncommenting this causes ambiguity error — inline requires breaks subsumption:
    // process_wrong(scores);

    return 0;
}
▶ Output
[RandomAccessIterable path] O(1) size, direct index access
First element: 10
Size (O1): 3
[InputIterable path] linear scan, size computed by traversal
Element count: 3
⚠️
Watch Out:Never write the same constraint twice inline hoping for subsumption. The compiler checks whether atomic constraints originate from the same concept template — not whether they're textually identical. Factor shared constraints into named concepts. This single rule prevents 90% of 'ambiguous overload' errors in concept-heavy codebases.

Production Patterns: requires in Class Templates, Lambdas and SFINAE Migration

Concepts aren't just for free functions. Applying them in class templates, member functions, and lambdas is where you feel the full productivity gain — and where the subtle edges emerge.

In a class template, you can constrain the entire class, or constrain individual member functions using requires clauses inside the class body. The latter is powerful: it lets you expose methods only when the type parameter supports them, giving you something close to Rust's trait-gated impl blocks without macros.

Lambdas in C++20 can use concept-constrained auto parameters, making generic lambdas finally express intent. A lambda taking std::integral auto is immediately self-documenting and gives a clean error if someone passes a float.

Migrating from SFINAE: the most common pattern to replace is std::enable_if. The mental model is direct — a requires clause replaces the enable_if condition. But watch out for the 'ill-formed, no diagnostic required' case: if a concept's requires expression checks something that is inherently ill-formed rather than substitution-dependent, the compiler might reject it at definition time rather than at point of use. This is typically caused by using volatile or reference-qualified types inside requires bodies without accounting for them.

For library authors, the most important production insight is to constrain your public API surface with concepts and leave internals unconstrained. Over-constraining internals makes future refactoring painful without changing user-visible behaviour.

ProductionPatterns.cpp · CPP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
#include <concepts>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

// --- Concept for types that can represent a monetary amount ---
template <typename T>
concept MonetaryType = std::floating_point<T> || std::integral<T>;

// --- Class template constrained at the class level ---
// The entire class only exists for MonetaryType parameters
template <MonetaryType CurrencyType>
class Wallet {
    CurrencyType balance_;

public:
    explicit Wallet(CurrencyType initial_balance)
        : balance_{initial_balance} {}

    void deposit(CurrencyType amount) {
        balance_ += amount;
    }

    // Member function with its OWN additional constraint
    // Only available when CurrencyType supports division (i.e., floating point)
    // This gives us Rust-style conditional method exposure
    CurrencyType split_evenly(int ways) const
        requires std::floating_point<CurrencyType> // narrower than the class constraint
    {
        return balance_ / static_cast<CurrencyType>(ways);
    }

    CurrencyType balance() const { return balance_; }
};

// --- Concept-constrained generic lambda (C++20) ---
// This is the replacement for the old unconstrained [](auto x) lambdas
auto format_currency = [](MonetaryType auto amount, const std::string& symbol) {
    std::cout << symbol << amount << '\n';
};

// --- SFINAE to Concepts migration example ---
// OLD (SFINAE style — ugly, opaque):
template <typename T, std::enable_if_t<std::is_arithmetic_v<T>, int> = 0>
T sfinae_double(T value) { return value * 2; }

// NEW (Concepts style — readable, better diagnostics, same semantics):
template <typename T>
    requires std::is_arithmetic_v<T> // can use type traits directly in requires
T concept_double(T value) { return value * 2; }

// EVEN BETTER — name the concept for reuse and subsumption:
template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>;

template <Arithmetic T>
T named_concept_double(T value) { return value * 2; }

// --- Concept-constrained lambda for use with standard algorithms ---
// Ensures the comparator actually returns bool — catches custom Compare objects that don't
auto make_descending_comparator = []<std::totally_ordered ElementType>() {
    return [](const ElementType& lhs, const ElementType& rhs) {
        return lhs > rhs; // descending order
    };
};

int main() {
    // Class template usage
    Wallet<double> savings{1000.0};
    savings.deposit(250.50);
    std::cout << "Balance: " << savings.balance() << '\n';
    std::cout << "Split 3 ways: " << savings.split_evenly(3) << '\n';

    // Wallet<int> integer_wallet{500};
    // integer_wallet.split_evenly(3); // compile error: constraint not satisfied
    //   'split_evenly' requires floating_point<int> which is false

    // Concept-constrained lambda
    format_currency(99.95, "$");
    format_currency(150, "€"); // int satisfies MonetaryType (integral branch)

    // SFINAE vs Concepts — same behaviour, dramatically different readability
    std::cout << sfinae_double(21)     << '\n'; // 42
    std::cout << concept_double(21)    << '\n'; // 42
    std::cout << named_concept_double(21) << '\n'; // 42

    // Constrained lambda producing a type-safe comparator
    auto int_desc = make_descending_comparator.operator()<int>();
    std::vector<int> values = {3, 1, 4, 1, 5};
    std::sort(values.begin(), values.end(), int_desc);
    for (int v : values) std::cout << v << ' ';
    std::cout << '\n';

    return 0;
}
▶ Output
Balance: 1250.5
Split 3 ways: 416.833
$99.95
€150
42
42
42
5 4 3 1 1
⚠️
Migration Strategy:When migrating SFINAE to Concepts, do it type by type, not file by file. Translate each enable_if into a named concept first, then swap the syntax. Named concepts give you the subsumption benefits SFINAE never had. Inline std::is_arithmetic_v inside a raw requires clause still works but you lose subsumption — always name the concept.
AspectSFINAE / enable_ifC++20 Concepts
Error message quality30+ lines of instantiation backtrace inside the librarySingle line: 'T does not satisfy concept X' at call site
Overload rankingRequires hacks like void_t + priority_tag to disambiguateSubsumption handles ranking automatically via concept hierarchy
Readability at declaration siteenable_if<...> buried in template parameter listConcept name reads like English: template
Compile-time costSubstitution attempted then discarded (expensive for many overloads)Constraint checked before substitution — can be faster in large overload sets
ReusabilityType trait structs must be defined separately; no unified syntaxNamed concept is a first-class entity, usable anywhere typename appears
Abbreviated templatesNot supported — always need templateSupported: void f(std::integral auto value) — cleaner generic code
Partial specialisationEnabled via enable_if on specialisationConstrained partial specialisations: template struct S
Standard library integrationstd::enable_if_t, std::void_t, detected_t idiomsstd::concepts, std::ranges concepts, iterator_concept — consistent hierarchy

🎯 Key Takeaways

  • A requires expression checks well-formedness, never executes — zero runtime cost, purely a compile-time gate before instantiation even begins.
  • Subsumption only works when concepts reference each other by name. Inline requires expressions that say the same thing are treated as different atomic constraints — always name your concepts for overload ranking to work.
  • The four syntax forms (terse, requires clause, trailing requires, constrained auto) are interchangeable in constraint power but not in readability — pick the form that makes the constraint most visible to the next engineer.
  • Migrating from SFINAE? Name every constraint as a concept first, then swap syntax. Leaving constraints inline in requires clauses preserves correctness but loses subsumption — the killer feature that makes overload sets unambiguous.

⚠ Common Mistakes to Avoid

  • Mistake 1: Writing the same constraint inline in two overloads instead of naming a concept — Symptom: 'call to overloaded function is ambiguous' even though one overload is clearly more restrictive — Fix: Extract the shared part into a named concept and reference it by name in both overloads. Subsumption only works through concept-name references, not through textually-equivalent requires expressions.
  • Mistake 2: Checking for a method's existence without checking its return type — Symptom: The concept is satisfied but the function body fails to compile because the method returns void instead of the expected type — Fix: Always use the { expression } -> concept syntax to constrain the return type: { container.size() } -> std::convertible_to. An unconstrained existence check is half a check.
  • Mistake 3: Applying concepts to non-deduced contexts and expecting constraint checking — Symptom: You constrain a function taking const ConceptName auto& but call it with an explicit cast, and the constraint is bypassed — Fix: Concepts apply during template argument deduction. When you force an explicit type that satisfies the concept it works; when a type genuinely fails the concept, you still get a diagnostic. The mistake is thinking concepts act as runtime guards — they don't. Always use static_assert with your concept to document invariants that must hold in contexts where deduction doesn't occur.

Interview Questions on This Topic

  • QWhat is concept subsumption in C++20 and why does it matter for overload resolution? Can you show a case where two constrained overloads would be ambiguous without it?
  • QExplain the difference between a requires expression and a requires clause. When would you use each, and what happens inside a requires expression at compile time — does any code actually execute?
  • QIf you have a concept MyRange that wraps std::ranges::range, and you define a more refined concept MySortableRange = MyRange && requires(...){...}, will subsumption work if you write the same body inline rather than referencing MyRange by name? Why or why not?

Frequently Asked Questions

Can C++20 concepts replace all uses of SFINAE and std::enable_if?

In the vast majority of cases, yes. Concepts are strictly more expressive and readable than enable_if for constraining templates. The only edge case where SFINAE-based techniques still appear is in very old library code or when targeting compilers with partial C++20 support. For new code, there's no reason to reach for enable_if.

Do C++20 concepts have any runtime overhead?

None whatsoever. Concepts are evaluated entirely at compile time during constraint checking and overload resolution. The generated machine code is identical to unconstrained templates that happen to be called with the correct types. The only measurable cost is potentially increased compile time in translation units with many constrained overloads.

What's the difference between a concept and a type trait in C++?

A type trait (like std::is_integral) is a struct with a ::value member — just a metaprogramming utility. A concept is a first-class language construct that integrates with overload resolution, produces better diagnostics, supports subsumption, and can be used anywhere 'typename' appears. You can use a type trait inside a concept body (requires std::is_integral_v) but the concept gives the constraint a name, enables subsumption, and makes diagnostics point at the call site rather than deep inside instantiation.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousSFINAE in C++Next →STL Deque in C++
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged