Defining Concepts and Requires Clauses
Defining Concepts and Requires Clauses
Section titled “Defining Concepts and Requires Clauses”C++20 introduced concepts --- named requirements for template parameters that allow the compiler To check, at the point of instantiation, whether a type satisfies a set of constraints. Concepts Make template requirements explicit, named, and composable, transforming template Metaprogramming from an implicit contract into a readable interface.
The concept Keyword
Section titled “The concept Keyword”A concept is a named compile-time predicate that evaluates to true or false for a given set Of template arguments [N4950 §18.4]. The syntax is defined in [N4950 §13.9.3]:
template <template-parameter-list>concept concept-name = constraint-expression;A concept is declared with the concept keyword and must be defined at namespace scope. The Constraint-expression on the right-hand side is a constant expression of type bool [N4950 §13.5.3]. The concept evaluates to true if the constraint-expression is satisfied for the given Template arguments.
#include <concepts>#include <iostream>
template<typename T>concept Addable = requires(T a, T b) { { a + b } -> std::convertible_to<T>;};
template<typename T>concept HasSize = requires(T t) { { t.size() } -> std::convertible_to<std::size_t>;};
template<Addable T>T add(T a, T b) { return a + b;}
int main() { std::cout << add(3, 4) << "\n"; // OK: int is Addable // std::cout << add("a", "b") << "\n"; // Error: const char* is not Addable}The concept keyword restricts what can appear on the right-hand side. Specifically [N4950 §13.5.3]:
- The constraint-expression must be a constexpr boolean expression.
- It cannot introduce new template parameters (those go in the template-parameter-list).
- A concept itself is never instantiated --- it is only evaluated during constraint checking.
Formal Semantics of the concept Keyword
Section titled “Formal Semantics of the concept Keyword”The Standard defines a concept as a “template that defines a set of requirements” [N4950 §18.4]. The Grammar is:
concept-definition: template < template-parameter-list > concept attribute-specifier-seqopt identifier = constraint-expression ;Several constraints on this grammar are important:
- The template-parameter-list must contain at least one type or type-constraint parameter. A concept with no parameters is ill-formed.
- The constraint-expression must be a constraint-expression as defined in [N4950 §13.5.3], which means it must be one of:
- A logical AND expression of constraints (
&&) - A logical OR expression of constraints (
||) - A
requires-expression - A concept name with arguments
A concept is implicitly
constexpr--- the compiler evaluates it at compile time. You cannot declare a concept that depends on runtime values.A concept cannot be
virtual``explicit``friendOr have storage class specifiers. It is a purely compile-time entity.A concept is not a type. You cannot use it as a type argument, return type, or variable type. It can only appear in constrained contexts: template parameter lists,
requiresclauses, andstatic_assertdeclarations.
Proof: Concepts Are Compile-Time Boolean Predicates
Section titled “Proof: Concepts Are Compile-Time Boolean Predicates”Claim: A concept C<T> is a compile-time predicate --- for any type T``C<T> evaluates to Either true or false during compilation, and the evaluation has no runtime side effects.
Proof:
By [N4950 §13.5.3/1], a constraint-expression is defined as a logical AND/OR of atomic constraints. An atomic constraint is either a concept-id or a
requires-expression.By [N4950 §13.5.3/3], an atomic constraint is formed from an expression and a mapping from template parameters to template arguments. The atomic constraint is satisfied if and only if substitution of the mapped arguments into the expression is valid and the resulting expression is
true.The substitution in step 2 is performed in an unevaluated operand context [N4950 §7.5.8]. No runtime code is generated for the substitution, and no runtime side effects occur.
If the substitution fails (i.e., the expression is ill-formed after substitution), the atomic constraint is not satisfied --- this is not a hard error. This is the SFINAE principle applied to constraints [N4950 §13.5.3/2].
The logical combination of satisfied/unsatisfied atomic constraints via
&&and||produces a single boolean result. By the laws of boolean algebra, this result is well-defined and unique.Since all evaluation occurs during template argument deduction and constraint checking (which are compilation phases), the result is available at compile time with zero runtime cost.
Therefore, C<T> is a compile-time boolean predicate.
Corollary: A concept can be used in static_assert declarations to verify properties of types At compile time:
template<typename T>concept Numeric = std::integral<T> || std::floating_point<T>;
static_assert(Numeric<int>, "int must be numeric");static_assert(Numeric<double>, "double must be numeric");static_assert(!Numeric<std::string>, "string must not be numeric");Corollary: Since a concept is a compile-time predicate with no runtime representation, it has Zero overhead on the generated binary. The concept is “compiled away” after constraint checking Succeeds or fails.
## Requires-ExpressionsA requires-expression is the primary building block for expressing constraints on types [N4950 §7.5.8]. Its grammar is:
requires { requirement-seq }requires parameter-list { requirement-seq }There are four kinds of requirements that can appear in the requirement-seq [N4950 §7.5.8]:
- Simple requirements: an expression that must be well-formed.
- Type requirements: a type that must be valid (using
typenamekeyword). - Compound requirements: an expression with an expected return type.
- Nested requirements: a constraint that must be satisfied (using
requireskeyword).
#include <concepts>#include <string>#include <vector>
template<typename T>concept SortableContainer = requires(T container) { // Simple requirement: the expression must be valid container.begin(); container.end(); container.size();
// Type requirement: the nested type must exist typename T::value_type;
// Compound requirement: the expression must be valid AND return the specified type { container[0] } -> std::same_as<typename T::value_type&>;
// Nested requirement: an additional constraint must hold requires std::random_access_iterator<decltype(container.begin())>;};
template<SortableContainer C>void process(C& container) { // Guaranteed: C has begin(), end(), size(), operator[], value_type}
int main() { std::vector<int> v{1, 2, 3}; process(v); // OK: std::vector<int> satisfies SortableContainer}Each requirement in the sequence is checked independently. If any single requirement fails, the Entire requires-expression evaluates to false. Importantly, the check is performed in an unevaluated context --- no runtime code is generated for the requirement checks [N4950 §7.5.8].
Expression Validity in Requires-Expressions
Section titled “Expression Validity in Requires-Expressions”A requires-expression tests whether an expression is well-formed, not whether it produces a Particular value. The expression a + b is valid if operator+ is accessible and unambiguous for Types a and bRegardless of the result value.
#include <iostream>
template<typename T>concept HasStreamInsertion = requires(std::ostream& os, T value) { { os << value } -> std::same_as<std::ostream&>;};
int main() { static_assert(HasStreamInsertion<int>); static_assert(HasStreamInsertion<const char*>); // static_assert(HasStreamInsertion<void>); // Error: void does not satisfy}Substitution Failure Semantics in Requires-Expressions
Section titled “Substitution Failure Semantics in Requires-Expressions”When the compiler evaluates a requires-expression, it performs template argument substitution for Each requirement. If substitution causes a failure (e.g., the type does not have a member function, Or an expression is ill-formed), that specific requirement is treated as unsatisfied rather than Causing a compilation error [N4950 §7.5.8/7]:
template<typename T>concept HasFooAndBar = requires(T t) { t.foo(); // If T doesn"t have foo(), this fails silently t.bar(); // If T doesn't have bar(), this fails silently // The concept evaluates to false, but NO compilation error is emitted};This is the SFINAE principle applied to requires-expressions. The compiler does not emit an error For a failed substitution inside a requires-expression; it records that the constraint is not Satisfied.
However, if substitution succeeds but the expression is ill-formed for a reason unrelated to Substitution (e.g., a static_assert fires inside a constexpr function called by the expression), The program is ill-formed [N4950 §7.5.8/7]:
constexpr int bad(int x) { static_assert(x > 0, "x must be positive"); // Hard error if x <= 0 return x;}
template<typename T>concept Broken = requires(T t) { bad(t); // If substitution succeeds but bad(t) fails the static_assert, // this IS a hard error};Requires-Clauses
Section titled “Requires-Clauses”A requires-clause is a mechanism for attaching a constraint to a template declaration or Function declaration [N4950 §13.9.2]. There are two syntactic forms:
Trailing requires-clause (after the function parameter list):
template<typename T> requires std::integral<T>T absolute(T x) { return x < 0 ? -x : x;}Constrained template parameter (directly on the parameter):
template<std::integral T>T absolute(T x) { return x < 0 ? -x : x;}Both forms are semantically equivalent. The trailing requires-clause is more flexible because it can Reference multiple template parameters or the function’s own parameters [N4950 §13.9.2.1]:
#include <concepts>#include <type_traits>
template<typename T, typename U> requires std::integral<T> && std::integral<U>auto safe_divide(T numerator, U denominator) -> std::common_type_t<T, U> { return static_cast<std::common_type_t<T, U>>(numerator) / denominator;}
// This cannot be expressed with constrained template parameters alone// because the constraint references both T and UA requires-clause can also appear on non-template functions (C++20) [N4950 §7.6.7]:
#include <concepts>
void process(int x) requires (x > 0){ // This overload is only viable when x > 0}Compound Concepts: Conjunction and Disjunction
Section titled “Compound Concepts: Conjunction and Disjunction”Constraints form a lattice ordered by subsumption [N4950 §13.5.4]. The logical operators && (conjunction) and || (disjunction) are used to combine constraints:
- Conjunction (
&&): All atomic constraints must be satisfied. This produces a constraint that is at least as strict as any individual component. - Disjunction (
||): At least one atomic constraint must be satisfied. This produces a constraint that is at most as strict as any individual component.
#include <concepts>#include <iostream>#include <complex>
template<typename T>concept Arithmetic = std::integral<T> || std::floating_point<T>;
template<typename T>concept Number = Arithmetic<T> || requires(T v) { // Additional support for complex numbers, custom numeric types, etc. { v.real() } -> std::convertible_to<double>; { v.imag() } -> std::convertible_to<double>;};
template<Number T>T compute(T a, T b) { return a + b;}
int main() { std::cout << compute(1, 2) << "\n"; // OK: int is Arithmetic std::cout << compute(1.5, 2.5) << "\n"; // OK: double is Arithmetic std::cout << compute(std::complex<double>(1, 0), std::complex<double>(0, 1)) << "\n";}The C++20 standard library provides a comprehensive set of concepts in the <concepts> header [N4950 §18.4]. These concepts form a hierarchy that mirrors the type categories and iterator Hierarchy of the standard library.
Core Language Concepts
Section titled “Core Language Concepts”| Concept | Description | Key relationships |
|---|---|---|
std::same_as<T, U> | T and U are the same type | Reflexive, symmetric, transitive |
std::derived_from<D, B> | D is derived from B | Implies is_base_of_v<B, D> |
std::convertible_to<From, To> | From is implicitly convertible to To | Includes numeric promotions |
std::integral<T> | T is an integral type (excluding bool by design) | Includes char``short``int``longEtc. |
std::signed_integral<T> | T is a signed integral type | Subsumes std::integral<T> |
std::unsigned_integral<T> | T is an unsigned integral type | Subsumes std::integral<T> |
std::floating_point<T> | T is a floating-point type | float``double``long double |
Comparison Concepts
Section titled “Comparison Concepts”| Concept | Description |
|---|---|
std::equality_comparable<T> | == is an equivalence relation on T |
std::totally_ordered<T> | \<``\>``\<=``\>= define a total order on T |
std::three_way_comparable<T> | \<=\> is defined for T |
Object Concepts
Section titled “Object Concepts”| Concept | Description |
|---|---|
std::copyable<T> | T is copy-constructible, copy-assignable, and destructible |
std::movable<T> | T is move-constructible, move-assignable, and swappable |
std::semiregular<T> | T is copyable and default-constructible |
std::regular<T> | T is semiregular and equality_comparable |
std::destructible<T> | T can be destroyed |
Callable Concepts
Section titled “Callable Concepts”| Concept | Description |
|---|---|
std::invocable<F, Args...> | F can be called with Args... |
std::predicate<F, Args...> | F(Args...) returns something convertible to bool |
std::relation<F, T, U> | F(T, U) defines an equivalence relation or strict weak ordering |
std::strict_weak_order<F, T, U> | F(T, U) defines a strict weak ordering |
Iterator and Range Concepts
Section titled “Iterator and Range Concepts”The iterator concepts form a refinement hierarchy [N4950 §18.4]:
std::input_or_output_iterator<It> └── std::input_iterator<It> (requires readable, incrementable) └── std::forward_iterator<It> (requires multi-pass, equality comparable) └── std::bidirectional_iterator<It> (requires decrementable) └── std::random_access_iterator<It> (requires +=, -=, indexing) └── std::contiguous_iterator<It> (requires contiguous memory)Range concepts build on iterator concepts:
std::ranges::range<R> (has begin() and end()) └── std::ranges::borrowed_range<R> (safe to return by value) └── std::ranges::sized_range<R> (has size()) └── std::ranges::view<R> (semiregular, movable, cheap to copy) └── std::ranges::input_range<R> (input_iterator begin()) └── std::ranges::forward_range<R> (forward_iterator begin()) └── std::ranges::bidirectional_range<R> └── std::ranges::random_access_range<R> └── std::ranges::contiguous_range<R>This hierarchy is designed so that subsumption works correctly: a forward_iterator subsumes an input_iteratorEnabling clean overload sets based on iterator category.
SFINAE vs Concepts: A Detailed Comparison
Section titled “SFINAE vs Concepts: A Detailed Comparison”Before C++20, template constraints were expressed using SFINAE (Substitution Failure Is Not An Error) with techniques like std::enable_if``std::void_tAnd decltype. Concepts provide a Cleaner, more expressive, and more composable alternative.
| Aspect | SFINAE (enable_if) | Concepts |
|---|---|---|
| Syntax | Verbose, often nested in return types or default arguments | Concise, declarative |
| Error messages | Cryptic (deep in the instantiation stack) | Clear (constraint not satisfied) |
| Readability | Low (logic buried in type traits) | High (named predicates) |
| Composability | Manual conjunction via std::enable_if<... && ...> | Natural && and || operators |
| Subsumption | Not supported (compiler cannot compare enable_if conditions) | Supported (partial ordering on constraints) |
| Overload resolution | Ambiguity-prone with multiple enable_if | Ambiguity-free with proper concept design |
| Performance | Zero runtime overhead | Zero runtime overhead |
| Standard reference | [N4950 §13.10.3] (SFINAE context) | [N4950 §18.4] (concept definitions) |
Example: SFINAE approach (pre-C++20)
#include <type_traits>#include <iostream>
// Only integral types: enable_if in template parametertemplate<typename T, typename std::enable_if<std::is_integral_v<T>, int>::type = 0>T absolute(T x) { return x < 0 ? -x : x;}
// Only floating-point types: enable_if in return typetemplate<typename T>std::enable_if_t<std::is_floating_point_v<T>, T>absolute(T x) { return x < 0 ? -x : x;}
int main() { std::cout << absolute(-3) << "\n"; // OK std::cout << absolute(-3.5) << "\n"; // OK // absolute("hello"); // Error: no matching function}Equivalent: Concepts approach (C++20)
#include <concepts>#include <iostream>
template<std::integral T>T absolute(T x) { return x < 0 ? -x : x;}
template<std::floating_point T>T absolute(T x) { return x < 0 ? -x : x;}
int main() { std::cout << absolute(-3) << "\n"; // OK std::cout << absolute(-3.5) << "\n"; // OK // absolute("hello"); // Error: constraints not satisfied (clear message)}The concepts version is shorter, more readable, and produces better error messages. The compiler can Also determine that std::integral<T> and std::floating_point<T> are mutually exclusive (via Subsumption), eliminating the ambiguity that would arise with two enable_if overloads that the Compiler cannot structurally compare.
Defining and Using a Numeric Concept
Section titled “Defining and Using a Numeric Concept”The following complete example demonstrates defining a Numeric concept, using it to constrain a Template function, and providing specialized behavior for different numeric categories:
#include <concepts>#include <iostream>#include <iomanip>#include <limits>#include <type_traits>
template<typename T>concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>concept SignedNumeric = Numeric<T> && std::is_signed_v<T>;
template<typename T>concept UnsignedNumeric = Numeric<T> && !SignedNumeric<T>;
template<Numeric T>T safe_add(T a, T b) { if constexpr (std::floating_point<T>) { return a + b; } else if constexpr (SignedNumeric<T>) { if ((b > 0 && a > std::numeric_limits<T>::max() - b) || (b < 0 && a < std::numeric_limits<T>::min() - b)) { throw std::overflow_error("signed overflow detected"); } return a + b; } else { if (a > std::numeric_limits<T>::max() - b) { throw std::overflow_error("unsigned overflow detected"); } return a + b; }}
template<Numeric T>auto mean(T a, T b) -> double { return static_cast<double>(a) + static_cast<double>(b);}
int main() { std::cout << std::fixed << std::setprecision(1); std::cout << "3 + 4 = " << safe_add(3, 4) << "\n"; std::cout << "1.5 + 2.5 = " << safe_add(1.5, 2.5) << "\n";
try { safe_add(static_cast<int>(2147483647), 1); } catch (const std::overflow_error& e) { std::cout << "Caught: " << e.what() << "\n"; }
std::cout << "mean(3, 7) = " << mean(3, 7) << "\n"; std::cout << "mean(3.0, 7.0) = " << mean(3.0, 7.0) << "\n";}Output:
3 + 4 = 71.5 + 2.5 = 4.0Caught: signed overflow detectedmean(3, 7) = 10.0mean(3.0, 7.0) = 10.0