C++20 Concepts — Why std::list Fails Your Range Constraint
Requires expressions check constraints in order—subscript operators reject std::list before return-type checks run.
- Concepts are compile-time predicates that constrain template parameters — checked before instantiation, not during
- A concept definition uses a requires expression to list required operations and return types
- The four syntax forms (terse, requires clause, trailing requires, constrained auto) are semantically identical but differ in readability
- Subsumption ranks overloads automatically when concepts reference each other by name — inline requires does not subsume
- Performance is zero runtime cost; compile times can increase in constraint-heavy translation units
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.
container.size() } -> std::convertible_to<std::size_t> 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.
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.
requires requires blocks. The compiler couldn't prove they were the same, so it gave up. After factoring into a named concept, the overload set compiled cleanly. The lesson: if you find yourself copy-pasting a requires expression, stop and name it. This also improves documentation.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.
std::floating_point as a constraint but forgetting that float and double are fine, but long double is also floating_point. If your function assumes 64-bit precision, you need an additional constraint.Designing Custom Concepts: Best Practices and Gotchas
Writing your own concepts is straightforward, but writing good ones requires discipline. A concept should be minimal, composable, and named clearly. Over-constraining is more common than under-constraining. Start with the minimum operations your algorithm actually needs, then compose.
One major gotcha: the 'ill-formed, no diagnostic required' trap. If your requires expression uses expressions that are ill-formed for any type (like attempting to create a reference to void), the compiler may reject the concept definition entirely, and the error message may not point to the user's call site. Always ensure each atomic requirement makes sense for the types you intend to support.
Another pitfall: volatile and reference qualification inside requires. If you write requires(T& a) { a = {}; } you're requiring that assigning from {} works on an lvalue reference. But if T is const int, the concept fails. This is correct but often surprises developers who forget to account for const.
Concepts should be defined before they are used. Forward declarations are not allowed. This is usually fine, but can cause ordering issues in large headers. Organise your concept definitions at the top of the translation unit or in a dedicated header.
Finally, avoid concept recursion. A concept cannot depend on itself directly or indirectly. The compiler will reject it, but the diagnostic can be cryptic. Keep your concept hierarchy acyclic.
- Start with the operations your generic code actually calls.
- Use
convertible_tooversame_asfor return types unless exact type matters. - Compose small concepts into larger ones — keep each concept focused on one abstraction.
- Test each concept with a static_assert on types that should and should not satisfy it.
- Avoid volatile, const, and reference qualification surprises by using std::remove_cvref_t when appropriate.
std::same_as<typename T::iterator, typename T::const_iterator> because the team thought making iterators equivalent would simplify the API. That broke every container that had separate iterator and const_iterator types (most of the STL). The fix was to drop that requirement entirely — the algorithm didn't actually need it. The lesson: never add a constraint you don't absolutely need. Every extra atomic constraint is a chance to accidentally exclude valid types.convertible_to for return-type constraints unless exact identity is needed.The Concept That Rejected Every Valid Type — Overly Strict Return-Type Constraint
begin() would be safe because both containers' begin() returns exactly the same type (iterator). The team assumed std::same_as was semantically equivalent to std::convertible_to for this case.-> std::same_as<iterator_trait> but also required a subscript operator via c[n] which list doesn't have — however the error message pointed at the return type mismatch first. The real issue was the combined constraint: the subscript operator check filtered out std::list immediately, but the compiler's diagnostic highlighted the same_as failure because it was checked earlier in the requires expression order.std::same_as to std::convertible_to for the return type of begin(). The concept should check that begin() returns something that can be used as an input iterator, not that it returns the exact same concrete type. After the fix, std::list correctly fails the operator[] check and falls through to the less-constrained overload.- Use
std::convertible_toorstd::constructible_fromfor return-type constraints unless you genuinely need exact type identity. - Constraint order in a requires expression affects which diagnostic the compiler emits first — the most restrictive constraint should come later.
- Always test concepts against multiple types including those that should fail subtly — a concept that only rejects wrong types is good; one that reports the wrong reason wastes developer time.
-> std::convertible_to<T> to each expression.convertible_to not same_as unless exact type neededKey takeaways
Common mistakes to avoid
4 patternsWriting the same constraint inline in two overloads instead of naming a concept
Checking for a method's existence without checking its return type
container.size() } -> std::convertible_to<std::size_t>. An unconstrained existence check is half a check.Applying concepts to non-deduced contexts and expecting constraint checking
Using std::same_as for return-type constraints when std::convertible_to would be appropriate
Interview Questions on This Topic
What 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?
A<T> && ..., then B subsumes A. When both overloads match, the compiler picks B without ambiguity. Without subsumption — if both overloads used inline requires expressions that are textually different but semantically identical — the compiler sees them as different atomic constraints and emits an ambiguity error. This is why you must use named concepts, not inline requires, when you want overload ranking.Frequently Asked Questions
That's C++ Advanced. Mark it forged?
6 min read · try the examples if you haven't