What are C++20 concepts and constraints? How to use them?

Introduction

A C++ 20 concept is a named predicate (true/false expression) that constrains templates. It improves the readability of code and facilitates finding bugs. Concepts can also be used for function overloading, template specialization, and creating meta-functions. Concepts along with if-constexpr are modish alternatives for unpleasant SFINAE pattern.

Prerequisites

This post assumes you are familiar with templates and auto keyword. The codes here are compiled with GCC v10.2.

Why Concepts?

Concepts make a template code easy to read and help to find bugs.

In the example below, it is not clear what T is unless you read the whole function. The function works well with arrays. But let’s, instead of an array, pass a wrong object to the function:

#include<iostream>

template<class T>
double sum(T& items){
    double sum=0;
    for (auto& item:items)
        sum+=item;
    return sum;
}
 
class A{};

int main(){
    A a;
    auto c = sum(a);
}

Look at the errors, it takes a bit of time to decipher:

<source>: In instantiation of 'double sum(T) [with T = A]':
<source>:15:19:   required from here
<source>:6:5: error: 'begin' was not declared in this scope; did you mean 'std::begin'?
    6 |     for (auto& item:items)
      |     ^~~
      |     std::begin
In file included from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/string:54,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/locale_classes.h:40,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/ios_base.h:41,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/ios:42,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/ostream:38,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/iostream:39,
                 from <source>:1:
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/range_access.h:108:37: note: 'std::begin' declared here
  108 |   template<typename _Tp> const _Tp* begin(const valarray<_Tp>&);
      |                                     ^~~~~
<source>:6:5: error: 'end' was not declared in this scope; did you mean 'std::end'?
    6 |     for (auto& item:items)
      |     ^~~
      |     std::end
In file included from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/string:54,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/locale_classes.h:40,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/ios_base.h:41,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/ios:42,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/ostream:38,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/iostream:39,
                 from <source>:1:
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/range_access.h:110:37: note: 'std::end' declared here
  110 |   template<typename _Tp> const _Tp* end(const valarray<_Tp>&);
      |                                     ^~~
Compiler returned: 1

The errors become even more entangled when something goes wrong in nested class templates.

Definition

Here, Array concept is defined to tell the user of this code that T should be an array:

#include<iostream>

template<class U>
concept Array = std::is_array<U>::value;

template<Array T> // Apply Array concept
double sum(T& items){
    double sum=0;
    for (auto& item:items)
        sum+=item;
    return sum;
}

There are many useful structs like is_array in type_traits header which can be used for defining a concept.

Again we injected the wrong parameter into the sum function:

class A{};

int main(){
    A a;
    int b[2]={1,2}; // b is the right input for sum()
    std::cout<<sum(a);
}

Now let’s check the errors, they directly point out that a is not an array:

: In function 'int main()':
:19:21: error: use of function 'double sum(T&) [with T = A]' with unsatisfied constraints
   19 |     std::cout<<sum(a);
      |                     ^
:7:8: note: declared here
    7 | double sum(T& items){
      |        ^~~
:7:8: note: constraints not satisfied
: In instantiation of 'double sum(T&) [with T = A]':
:19:21:   required from here
:4:9:   required for the satisfaction of 'Array<T>' [with T = A]
:4:35: note: the expression 'std::is_array< <template-parameter-1-1> >::value [with <template-parameter-1-1> = A]' evaluated to 'false'
    4 | concept Array = std::is_array<U>::value;
      |                                   ^~~~~

Concept for auto keyword

auto keyword is used for setting the type of a variable based on its initializer. For more details, see my post on auto here. For example

auto i=1; // i is int

With the aid of concepts, we can constrain auto, for instance this compiles well:

#include<concepts>

std::integral auto i=2;

but if you change the last line to

std::integral auto i=2.2;

We get an error because 2.2 is a double, not an integral number. Therefore, the function below accepts any integral type like int, long, long long and so on:

void f(integral auto i){
    /* some code*/
}

But gives an error if non-integral types are passed.

Concepts and constraints

Two constraints can create conjunction with && operator where both must be true

template<class T> 
concept ConstInt = std::is_const<T>::value && std::is_integral<T>::value;

template<ConstInt T>
void f(T a){};

Note that If is_const<T>::value is false, is_integral term is ignored.

Disjunction can be created with || operator where either constraint can be true

template<class T>
concept Arithmetic =  std::is_integral<T>::value || std::is_floating_point<T>::value;

Constraints can be created with other concepts:

template<class T>
concept ConstArithmetic = Arithmetic && is_const<T>::value;

Before defining your concepts, it is worth checking already defined concepts in the <concepts> header.

Requires clause

Instead of imposing a concept inside the template brackets:

template<Arithmetic T> 
void f(T a){/*function definition*/};

we can enforce it just after template declaration using requires:

template<class T> 
requires Arithmetic<T>
void f(T a)
{/*function definition*/};

or after the function declaration

#include<concepts>
template<class T>
void f(T a) requires integral<T> // integral is in header <concepts>	
{/*function definition*/}; 

Requires expressions

If working with type traits is hard, we can explain what we expect from a type with an object of that type:

template<class T>
concept isVector = requires (T a) // a is an object of type T
{ 
    a.begin(); // constraint #1: a.begin() must be meaningful.
    a.reserve(1); // constraint #2: a.reserve(1) must work.
    a.data(); // constraint #3: a.data() is there.
};

template<isVector T>
class Box{}; 

So users get errors if a Box object is created that violates constraints #1, #2 and #3.

Constraints can be imposed on types too:

template<class T>
concept MyArray = requires (T a){ 
    a[0]; // Accessing elements through [] works.
    a.size(); // a must have size() method.
    typename T::Type; // T must contain a datatype, Type.
};

template<MyArray T> 
void DisplayArray(T x){
    
    typename x::Type sample;
    std::cout<<sample;
    for (size_t i=0;i<x.size();i++)
        std::cout<<x[i]<<" ";

}

Compounds

The requires expression can constrain the return type of its statements

template<class T>
concept AddableInt = requires (T a){
    { a + a } -> std::same_as<int>;
}

The above concept imposes two constraints:

  • a+a must be valid.
  • decltype(a+a) must be the same as int.

Concept same_as is defined in the <concepts> header.

We can also have multiple expressions:

template<class T>
concept MyPointer = requires (T a){
    { *a + 1} -> std::convertible_to<MyBaseClass>;
    { a->sum()} -> std::same_as<double>;
};

Concepts and Constepxr

Concepts can be employed in constexpr assessments:

template<typename T>
concept hasSize = requires (T a){ a.size();};

auto f(auto x){
    if constexpr (hasSize<decltype(x)>){      
        return x.size();
    else
        return x;
}

int main(){
    
    std::vector<int> vec{1,2};
    int i=5;
    std::cout<<f(i); // 5
    std::cout<<f(vec);// 2
}

To read more on constexpr, see my post here.

Partial ordering

Imaginge constraint A is looser than constraint B and A contains B. If a template type matches both, the template that constrained by B is selected. This is called partial ordering of constraints. For example:

// strict constraint
template<class T>
concept Integral =  std::is_integral_v<T>;

// loose constraint
template<class T>
concept Arithmetic =  Integral<T> || 
            std::is_floating_point_v<T>;

auto f(Integral auto x){
    std::cout<<"Integral.";
}

auto f(Arithmetic auto x){
    std::cout<<"Arithmetic.";
}

f(2.2); // Arithmetic 
f(1); // Integral

The idea is very similiar to template specialization technique that is used to select a type based on some criteria.

Example

Create a meta-function using partial ordering of concepts that creates different outputs for STL containers, std::array, std::vector and everything else.

Let’s solve it via two steps: firstly, defining or finding some suitable concepts and, secondly, apply them to meta-functions.

Step 1: create constraints

There are already many type traits in <type_traits> header and concepts in <concepts> header, check them before re-inventing the wheel.

In the below example, I start from a general STL container and filter types until I reach std::array and std::vector.

A STL container must have begin():

template<typename T>
concept isContainer = requires (T a){ a.begin();};

Arrays and vectors have data() function:

template<typename T>
concept hasData = requires (T a){ a.data();};

template<typename T>
concept isArrayVector = isContainer<T> && hasData<T>;

In camparison to arrays, vectors have reserve() function:

template<typename T>
concept hasReserve = requires (T a){ a.reserve(1);};

template<typename T>
concept isVector = isArrayVector<T> && hasReserve<T>;

So arrays do not have reserve() function:

template<typename T>
concept isArray = isArrayVector<T> && !hasReserve<T>;

Step 2: Define meta-function

A meta-function is a struct whose arguments are template parameters. It is overloaded similar to the run-time function.

Let’s create a meta-function that stores the name of the types. The first instance must be a generic or default behaviour which happens when none of the special cases is satisfied:

template<typename T>
struct Info{
    static constexpr char TypeName[] = "Unknown";
};

Now we define the special ones using our concepts:

template<isArray T>
struct Info<T>{
   static constexpr char TypeName[] = "Array";
};

template<isContainer T>
struct Info<T>{
   static constexpr char TypeName[] = "Container";
};

template<isVector T>
struct Info<T>{
    static constexpr char TypeName[] = "Vector";
};

The compiler tries to match a type to the concept with most narrowed down conditions, so the priorities for matching are

1- Array or Vector

2- Container

3- Generic case

Note that I purposefully mashed the order that special cases of info are written to show that the order is not important as long as concepts are narrowed down nicely.

We can see the outcome like:

int main(){
    std::vector<double> vec;
    std::array<int,2> arr;
    std::list<int> lis;
    int s;

    
    std::cout<<Info<decltype(lis)>::TypeName; // Container
    std::cout<<Info<decltype(vec)>::TypeName; // Vector
    std::cout<<Info<decltype(arr)>::TypeName; // Array
    std::cout<<Info<decltype(s)>::TypeName; // Unknown
}

This example can also be written using if constexpr and constexpr functions. Can you give it a go?

Read more

If you are interested in how to use constexpr variables, conditions, and functions see this post.

Or if you just want to brush up your C++ template skills, see this post.

Subscribe

I notify you of my new posts

Latest Posts

Comments

0 comment