C++ Concepts: Constraints on Template Arguments
A concept in C++ (version C++20) is a constraint on a template argument for a function, a class, a structure etc. Being a constraint means that it limits the set of arguments that are accepted as template parameters.
An Example: Defining our own Concept
Say we have defined concept IndexAddressable
, which demands for a type C (a container, actually) to support indeces (like C c; c[idx]
).
-
This is how we would use it to constrain a container template argument into a class. Instead of
template <typename F = double, typename CONTAINER=std::vector<F> > class MyContainer { };
which allows for CONTAINER to be a non-indexable container (and thus cause long and cryptic error messages), we might write
template <typename F = double, IndexAddressable CONTAINER=std::vector<F> > class MyContainer { };
-
And this is how we might define template
IndexAddressable
:#include <concepts> template<class CONT> concept IndexAddressable = requires(CONT cont, const CONT ccont, std::size_t i) { { cont[i]} -> std::convertible_to<typename CONT:: reference>; {ccont[i]} -> std::convertible_to<typename CONT::const_reference>; };
Please note the following syntactical elements in defining our template:
- inclusion of library
<concepts>
to bring in namestd::convertible_to<TYPE>
, - curly braces are placed around the expression,
- arrow operator (
->
), - a
requires
clause with syntax:requires(PARAMS) {CONSTRAINTS}
Further, the concept declaration assumes that the container conforms to the STL convention of declaring several types with
typdef
: typesreference
andconst_reference
in this excerpt. - inclusion of library
Compile-Time Evaluation and Error Messages
Concepts are evaluated at compile time and, importantly, failure to fulfill a concept's constraint happens early in the template instantiation process, which leads to fairly readable error messages.
Trying to compile the following with templates
std::list<int> l = {3, -1, 10}; std::sort(l.begin(), l.end());
results in error message:
error: cannot call std::sort with std::_List_iterator<int> note: concept RandomAccessIterator<std::_List_iterator<int>> was not satisfied
Writing a Concept Anew or Borrowing it from the STL
When using concepts, we may either
- rely on a concept already defined in the standard library, or
- define our own concept(s) and use them.
T
Models the Concept of MyConcept
In concept terminology, a type or group of types that satisfies a concept's requirements is said to model the concept or to be a model of the concept. A concept is said to refine another concept when its requirements are a superset of those of the other concept.
The intent of concepts is to model semantic categories (Number, Range, RegularFunction) rather than syntactic restrictions (HasPlus, Array). According to ISO C++ core guideline T.20, The ability to specify a meaningful semantics is a defining characteristic of a true concept, as opposed to a syntactic constraint.
Note that in generic programming (implemented as templates in C++), we take the notion of an ADT a step further. Instead of writing down the specification for a single type, we describe a family of types that all have a common interface and semantic behavior. The set of requirements that describe the interface and semantic behavior is referred to as a concept. Algorithms constructed in the generic style are then applicable to any type that satisfies the requirements of the algorithm. This ability to use many different types with the same variable (or parameter of a function) is referred to as polymorphism.
Concept Requirements
Concept requirements usually come from the following categories:
- Valid expressions
- C++ expressions that must compile successfully for the objects involved in the expression to be considered models of the concept. For example, an
Iterator x
is expected to support the expressions++x
and*x
. - Associated types
- Types that participate in one or more of the valid expressions and that can be computed from the type(s) modeling the concept. Typically, associated types can be accessed either through
typedef
's nested within a class definition for the modeling type or through a traits class. For example, an iterator'svalue_type
is associated with the iterator throughstd::iterator_traits
. - Invariants
- Runtime characteristics of a model's instances that must always be true; that is, all operations on an instance must preserve these characteristics. The invariants often take the form of pre-conditions and post-conditions. For instance, after a
Forward Iterator
is copied, the copy and the original must compare equal. - Complexity guarantees
- Maximum limits on how long the execution of one of the valid expressions will take, or how much of various resources its computation will use. Incrementing an
Iterator
, for example, is required to have constant complexity.
Syntax for Concepts
template <template-var-list> concept concept-name = constraint-expression;
as in
// concept template <class T, class U> concept Derived = std::is_base_of<U, T>::value;
Concepts cannot recursively refer to themselves and cannot be constrained:
template<typename T> concept V = V<T*>; // error: recursive concept template<class T> concept C1 = true; template<C1 T> concept Error1 = true; // Error: C1 T attempts to constrain a concept definition template<class T> requires C1<T> concept Error2 = true; // Error: the requires-clause attempts to constrain a concept
Explicit instantiations, explicit specializations, or partial specializations of concepts are not allowed (the meaning of the original definition of a constraint cannot be changed).
Constraints
A constraint is a sequence of logical operations and operands that specifies requirements on template arguments. They can appear within requires-expressions (see below) and directly as bodies of concepts.
There are three types of constraints:
- conjunctions
- disjunctions
- atomic constraints
The constraint associated with a declaration are determined by normalizing a logical AND expression whose operands are in the following order:
- the constraint expression introduced for each constrained template parameter, in order of appearance;
- the constraint expression in the
requires
clause after the template parameter list; and - the constraint expression in the trailing requires clause.
This order determines the order in which constraints are instantiated when checking for satisfaction.
A constrained declaration may only be redeclared using the same syntactic form. No diagnostic is required.
template<Incrementable T> void f(T) requires Decrementable<T>; template<Incrementable T> void f(T) requires Decrementable<T>; // OK, redeclaration template<typename T> requires Incrementable<T> && Decrementable<T> void f(T); // Ill-formed, no diagnostic required // the following two declarations have different constraints: // the first declaration has Incrementable<T> && Decrementable<T> // the second declaration has Decrementable<T> && Incrementable<T> // Even though they are logically equivalent. template<Incrementable T> void g(T) requires Decrementable<T>; template<Decrementable T> void g(T) requires Incrementable<T>; // ill-formed, no diagnostic required
Conjunctions
The conjunction of two constraints is formed by using the && operator in the constraint expression:
template <class T> concept Integral = std::is_integral<T>::value; template <class T> concept SignedIntegral = Integral<T> && std::is_signed<T>::value; template <class T> concept UnsignedIntegral = Integral<T> && !SignedIntegral<T>;
A conjunction of two constraints is satisfied only if both constraints are satisfied. Conjunctions are evaluated left to right and short-circuited (if the left constraint is not satisfied, template argument substitution into the right constraint is not attempted: this prevents failures due to substitution outside of immediate context).
template<typename T> constexpr bool get_value() { return T::value; } template<typename T> requires (sizeof(T) > 1 && get_value<T>()) void f(T); // #1 void f(int); // #2 void g() { f('A'); // OK, calls #2. When checking the constraints of #1, // 'sizeof(char) > 1' is not satisfied, so get_value<T>() is not checked }
Disjunctions
The disjunction of two constraints is formed by using the || operator in the constraint expression.
A disjunction of two constraints is satisfied if either constraint is satisfied. Disjunctions are evaluated left to right and short-circuited (if the left constraint is satisfied, template argument substitution into the right constraint is not attempted).
template <class T = void> requires EqualityComparable<T> || Same<T, void> struct equal_to;
Atomic Constraints
An atomic constraint consists of an expression E and a mapping from the template parameters that appear within E to template arguments involving the template parameters of the constrained entity, called its parameter mapping.
Atomic constraints are formed during constraint normalization. E is never a logical AND or logical OR expression (those form conjunctions and disjunctions, respectively).
Satisfaction of an atomic constraint is checked by substituting the parameter mapping and template arguments into the expression E. If the substitution results in an invalid type or expression, the constraint is not satisfied. Otherwise, E, after any lvalue-to-rvalue conversion, shall be a prvalue constant expression of type bool , and the constraint is satisfied if and only if it evaluates to true.
The type of E after substitution must be exactly bool. No conversion is permitted:
template<typename T> struct S { constexpr operator bool() const { return true; } }; template<typename T> requires (S<T>{}) void f(T); // #1 void f(int); // #2 void g() { f(0); // error: S<int>{} does not have type bool when checking #1, // even though #2 is a better match }
Two atomic constraints are considered identical if they are formed from the same expression at the source level and their parameter mappings are equivalent.
template<class T> constexpr bool is_meowable = true; template<class T> constexpr bool is_cat = true; template<class T> concept Meowable = is_meowable<T>; template<class T> concept BadMeowableCat = is_meowable<T> && is_cat<T>; template<class T> concept GoodMeowableCat = Meowable<T> && is_cat<T>; template<Meowable T> void f1(T); // #1 template<BadMeowableCat T> void f1(T); // #2 template<Meowable T> void f2(T); // #3 template<GoodMeowableCat T> void f2(T); // #4 void g(){ f1(0); // error, ambiguous: // the is_meowable<T> in Meowable and BadMeowableCat forms distinct // atomic constraints that are not identical (and so do not subsume each other) f2(0); // OK, calls #4, more constrained than #3 // GoodMeowableCat got its is_meowable<T> from Meowable }