C++20 concepts

C++
Author

Quasar

Published

December 7, 2024

Introduction

A class template, function template (including lambdas) may be associated with a constraint, which specifies requirements on the template arguments. This can be used to select the most appropriate function overload or template specialization.

A concept is a named set of such constraints. A concept is ultimately a logical predicate \(P(x)\), evaluated at compile-time, where \(x\) represents template parameters. A function or class template constrained by the concept \(P\), will work only for template arguments that satisfy \(P\).

Consider the templated function:

#include <iostream>
#include <complex>

template<typename T>
T sum(T const a, T const b){
    return (a + b);
}

int main()
{
    using namespace std::literals::complex_literals;

    int x{2}, y{3};

    sum(x, y);
    sum(2.71828, 3.14159);
    sum(std::complex{1.0 + 1.0i}, std::complex{1.0 - 1.0i});
    //sum("42", "1");       //Error cannot add two strings

    return 0;
}

Compiler Explorer

The sum function returns the result of applying the binary operator+(T,T) on its arguments. The sum function only makes sense when we discuss mathematical types such as integers, floating-point numbers, std::complex<double>, vectors and matrices. For most types, overloading the operator + makes no sense at all.

Therefore, just by looking at the declaration of this function, without inspecting its body, we cannot really say what this function may accept as input and what it does.

The intention for our sum function template is to allow passing only types that support arithmetic operations. One way is to use std::enable_if:

#include <iostream>
#include <complex>
#include <type_traits>

template<typename T, 
        typename = typename std::enable_if<std::is_arithmetic_v<T>,T>>
T sum(T const a, T const b){
    return (a + b);
}

int main()
{
    using namespace std::literals::complex_literals;

    int x{2}, y{3};

    sum(x, y);
    sum(2.71828, 3.14159);
    sum(std::complex{1.0 + 1.0i}, std::complex{1.0 - 1.0i});
    sum("42", "1");    

    return 0;
}

Compiler Explorer

We added an anonymous template parameter which calls the type metafunction std::enable_if<C,T> from the type_traits library. If the condition C evaluates to std::true_type, then std::enable_if<C,T> returns T. Since std::is_arithmetic_v<const char*> returns false_type, enable_if meta-function doesn’t return anything and the code will not build.

With this implementation, the code readability has decreased. The second type template parameter is difficult to read and certainly requires good TMP knowledge. The compiler error message is also cryptic.

We can improve these two aspects (code readability and compiler error messages) in C++ 20 by using constraints. These are introduced with the requires keyword as follows:

#include <iostream>
#include <complex>
#include <type_traits>
#include <concepts>

template<typename T>
requires std::is_arithmetic_v<T>
T sum(T const a, T const b){
    return (a + b);
}

int main()
{
    int x{2}, y{3};

    sum(x, y);
    sum(2.71828, 3.14159);
    sum("42", "1");    

    return 0;
}

Compiler Explorer

The compiler error message is more meaningful and states that the constraint is_arithmetic_v<const char*> evaluates to false.

The requires keyword introduces a clause, called the requires clasuse, that defines constraints on the template parameters. A constraint is a predicate that evaluates to true or false at compile-time. The expression used in the previous example, std::is_arithmetic_v<T> is simply using a standard type-trait.

Defining Concepts

Many constraints are generic and can be used in multiple places. For example, the functions below require that the type T be arithmetic.

/* Add 2 scalars */
template<typename T>
requires std::is_arithmetic_v<T>
T operator+(T const v1, T const v2){
  return (v1 + v2);
}

/* Add 2 vectors component-wise */
template<typename T>
requires std::is_arithmetic_v<T>
std::vector<T> operator+(std::vector<T> const v1, std::vector<T> const v2){
  std::vector<T> result{v1};

  for(int i{0};i<v1.size();++i){
    result[i] = v1[i] + v2[i];
  }

  return result;
}

/* Multiply 2 scalars */
template<typename T>
requires std::is_arithmetic_v<T>
T operator*(T const a, T const b){
  return (a * b);
}

/* Scalar multiplication of a vector v*/
template<typename T>
requires std::is_arithmetic_v<T>
std::vector<T> operator*(T k, std::vector<T> v){
  std::vector<T> result{v};
  std::transform(std::begin(v), std::end(v), std::begin(result), [&](auto element){
    return (k * element);
  });

  return result;
}

To avoid this repetitive code, we can defined a named constraint that can be reused in multiple places. A named constraint is called a concept. A concept is defined with the concept keyword. Here is an example:

template<typename T>
concept arithmetic = std::is_arithmetic_v<T>;

Even though they are assigned a boolean value, concept names should not contain verbs. They represent requirements and are used as attributes or qualifiers on template parameters. So, prefer names like arithmetic, copyable, serializable, container and not is_arithmetic, is_copyable, is_serializable and is_container. The arithmetic concept can be used as follows:

#include<iostream>
#include<type_traits>
#include<concepts>
#include<vector>

template<typename T>
concept arithmetic = std::is_arithmetic_v<T>;

/* Add 2 scalars */
template<arithmetic T>
T operator+(T const v1, T const v2){
  return (v1 + v2);
}

/* Add 2 vectors component-wise */
template<arithmetic T>
std::vector<T> operator+(std::vector<T> const v1, std::vector<T> const v2){
  std::vector<T> result{v1};

  for(int i{0};i<v1.size();++i){
    result[i] = v1[i] + v2[i];
  }

  return result;
}

/* Multiply 2 scalars */
template<arithmetic T>
T operator*(T const a, T const b){
  return (a * b);
}

/* Scalar multiplication of a vector v*/
template<arithmetic T>
std::vector<T> operator*(T k, std::vector<T> v){
  std::vector<T> result{v};
  std::transform(std::begin(v), std::end(v), std::begin(result), [&](auto element){
    return (k * element);
  });

  return result;
}

int main()
{
    2 + 3;
    std::vector{1.0,2.0} + std::vector{3.0,4.0};
    2 * 3;
    2.0 * std::vector{1.0,-1.0};
    return 0;
}

Compiler Explorer

The arithmetic concept can also be defined using a requires expression. A requires expression uses curly braces {} and it contains a sequence of requirements.

Consider the case where we want to define a template that only takes container types as an argument. A container type is not easy to define formally. We can do this based on some properties of standard containers.

  • They have the member types value_type, size_type, allocator_type, iterator and const_iterator.

  • They have the member function size() that returns the elements of the container.

  • They have the member functions begin(), end(), cbegin() and cend() that return iterators and constant iterators to the first and one-past-the-last element in the container.

We can define as is_container type trait as follows:

#include <type_traits>

template<typename T, typename U = void>
struct is_container : std::false_type {};

template<typename >
struct is_container<T, 
  std::void_t<typename T::value_type,
              typename T::size_type,
              typename T::allocator_type,
              typename T::iterator,
              typename T::const_iterator,
              decltype(std::declvalue<T>().size()),
              decltype(std::declvalue<T>().begin()),
              decltype(std::declvalue<T>().end()),
              decltype(std::declvalue<T>().cbegin()),
              decltype(std::declvalue<T>().cend())
            >> : std::true_type {};

template<typename T, typename U=void>
constexpr bool is_container_v<T,U> = is_container<T>::value;

Note, that std::void_t<Args...> is a utility type metafunction that maps a sequence of any types to void. It is a convenient way to leverage SFINAE prior to C++20’s concepts.

Concepts make writing such a template constraint much easier. We can employ the concept syntax and requires expressions to define the following:

template<typename T>
concept container = requires(T cont)
{
  typename T::value_type;
  typename T::size_type;
  typename T::allocator_type;
  typename T::iterator;
  typename T::const_iterator;
  cont.size();
  cont.begin();
  cont.end();
  cont.cbegin();
  cont.cend();
};

This definition is both shorter and more readable. It uses both simple requirements such as t.size() as well as type requirements such as T::value_type. It can be used to constrain template parameters in the manner seen previously.

requires expressions

The requires expression in the body of a concept has a function-like syntax.

requires(parameter-list){
  requirement-seq;
}

It is a prvalue expression of type bool that describes the constraints on some template arguments. Such an expression is true if the constraints are satisfied and false otherwise.

The substitution of template arguments into a requires expression used in the declaration of a templated entity may result in the formation of invalid types or expressions, or the violation of the semantic constraints of the requirements. In such cases, the requires-expression evaluates to false and does not cause the program to be ill-formed.

Simple requirements

A simple requirement is an expression that is not evaluated but only checked for correctness. The expression must be valid for the requirement to be evaluated to true.

template<typename T>
concept arithmetic requires(T a){
  std::is_arithmetic_v<T>;
};

Type requirements

Type requirements are introduced with the typename keyword followed by the name of a type. We can use it verify if :

  • A nested type exists(such as in typename T::value_type).
  • A class template specialization names a type.
  • An alias template specialization names a type.

Let’s code up a few examples.

template<typename T>
concept KeyValuePair = requires{
  typename T::key_type;
  typename T::value_type;
}

template<typename T, typename U>
struct Pair{

  using key_type = T;
  using value_type = U;

  key_type key;
  value_type value;
};

Pair satisfies the concept KeyValuePair, as it has inner types key_type and value_type. To verify this is indeed the case, we can use KeyValuePair as a compile-time metafunction.

static_assert(KeyValuePair<Pair>);
static_assert(!KeyValuePair<std::pair>);

std::pair<T,U> does have inner types, but they are called first_type and second_type.

#include<iostream>
#include<type_traits>
#include<concepts>

template<typename T>
concept arithmetic = std::is_arithmetic_v<T>;

template<arithmetic T>
struct Point2D{
    T x;
    T y;
};

template<typename T>
using Ref = T&;

template<typename T>
concept C = requires(T t){
    typename T::inner; // required nested member name
    typename Point2D<T>; // required class template specialization
    typename Ref<T>;     // required alias template specialization
};

Compound requirements

A compound requirement has the form:

{expression} noexcept -> return_type_requirement

and asserts the properties of the named expression. Both the noexcept and the return_type_requirement are optional.

Let’s code up a couple of examples.

In the below example, we define a NonThrowing to check if a function is marked with the noexcept specifier.

#include<iostream>
#include<type_traits>
#include<concepts>

/* 
Template Metaprogramming 
Mariusz Bancila  
*/
template<typename T>
void f(T) noexcept {}

template<typename T>
void g(T) {}

template <typename F, typename... T>
concept NonThrowing = requires(F&& func, T... t){
  {func(t...)} noexcept;
};

template<typename F, typename... T>
requires NonThrowing<F,T...>
void invoke(F&& func, T... t)
{
  func(t...);
}

int main()
{
    invoke(f<double>, 100.0);
    // invoke(g<double>, 100.0); //Error
    return 0;
}

Compiler Explorer

The call invoke(g<double>,100.0) is not valid, because g<double> may throw an exception, which results in NonThrowing<F,T...> to evaluating as false.

Nested requirements

A nested requirement has the form:

requires constraint_expression;

It is introduced by the requires keyword. Suppose we want to define a function that performs addition on a variable number of arguments. However, we want to impose some conditions:

  • There is more than one argument.
  • All arguments have the same type.
  • The expression arg1 + arg2 + ... + argn is valid.

We define a concept called HomogenousRange as follows:

/* 
Template Metaprogramming 
Mariusz Bancila  
*/
template<typename T, typename... Ts>
inline constexpr bool are_same_v = 
  std::conjunction_v<std::is_same<T,Ts>...>;

template <typename... T>
concept HomogenousRange = requires(T... t)
{
  (... + t);
  requires are_same_v<T...>;
  requires sizeof...(T) > 1;
}

This concept contains one simple requirement and two nested requirements. std::conjunction_v<B1,...,BN> is a type metafunction that forms the logical conjunction of conditions B1,…,BN, effectively performing a logical AND on the sequence. It works as follows:

  • If sizeof...(B)==0, std::true_type otherwise
  • The first type Bi in B1,...,BN for which Bi is false or BN if there is no such type.

The pattern std::is_same<T,Ts>... is expanded as

std::is_same<T,T1>,std::is_same<T,T2>,...,std::is_same<T,Tn>

Akin to the logical AND operation, if all of them evaluate to std::true_type, the type metafunction std::conjunction_v<B1,...,Bn returns std::true_type.

The simple requirement (... + t) specifies that left fold expression (adding all the arguments) is a valid operation.

Using this concept, we can define the variadic function template:

#include<iostream>
#include<type_traits>
#include<concepts>

/* 
Template Metaprogramming 
Mariusz Bancila  
*/
template<typename T, typename... Ts>
inline constexpr bool are_same_v = 
  std::conjunction_v<std::is_same<T,Ts>...>;

template <typename... T>
concept HomogenousRange = requires(T... t)
{
  (... + t);
  requires are_same_v<T...>;
  requires sizeof...(T) > 1;
};

template<typename... T>
requires HomogenousRange<T...>
std::common_type_t<T...> sum(T&&... args){
    return (... + args);
}

int main()
{
    auto result = sum(1, 2, 3, 4, 5);
    return 0;
}

Compiler Explorer

Composing constraints

Constraints can be composed using && and || operators. A composition of two constraints using the && operator is called a conjunction and the composition of two constraints using the || operator is called a disjunction.

For a conjunction to be true, both constraints must be true. For a disjunction to be true, atleast one of the constraints must be true.

template<typename T, typename U>
concept signed_integral = std::integral<T> && std::is_signed_v<T>;

template<typename T>
requires std::is_integral_v<T> || std::is_floating_point_v<T>
T add(T a, T b){
  return (a + b)
}

The standard concepts library

The standard library provides a set of fundamental concepts that can be used to define requirements on template arguments, class templates, variable templates and aliast templates. The standard concepts in C++20 are spread across several headers and namespaces. The main set of concepts is in the <concepts> header and the std namespace. Most of the concepts are equivalent to one or more existing type-traits.