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]).

  1. 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 {
    };
  2. 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 name std::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: types reference and const_reference in this excerpt.

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

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's value_type is associated with the iterator through std::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:

  1. conjunctions
  2. disjunctions
  3. atomic constraints

The constraint associated with a declaration are determined by normalizing a logical AND expression whose operands are in the following order:

  1. the constraint expression introduced for each constrained template parameter, in order of appearance;
  2. the constraint expression in the requires clause after the template parameter list; and
  3. 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
}