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 asint
.
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.
Latest Posts
- A C++ MPI code for 2D unstructured halo exchange
- Essential bash customizations: prompt, ls, aliases, and history date
- Script to copy a directory path in memory in Bash terminal
- What is the difference between .bashrc, .bash_profile, and .profile?
- A C++ MPI code for 2D arbitrary-thickness halo exchange with sparse blocks