1

C++20 Concepts

 2 years ago
source link: https://vorbrodt.blog/2021/08/25/c20-concepts/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

C++20 Concepts

C++20 introduced the Concepts library and the corresponding language extensions to template metaprogramming. This post will be a brief introduction to the topic for people already well versed in C++ templates.

What is a concept? Besides being a new keyword in C++20 it is a mechanism to describe constraints or requirements of typename T; it is a way of restricting which types a template class or function can work with. Imagine a simple template function that adds two numbers:

template<typename T> auto add(T a, T b) { return a + b; }

The way it is implemented doesn’t stop us from calling it with std::string as the parameters’ type. With concepts we can now restrict this function template to work only with integral types for example.

But first let’s define two most basic concepts, one which will accept, or evaluate to true, for all types, and another which will reject, or evaluate to false, for all types as well:

template<typename T> concept always_true = true;
template<typename T> concept always_false = false;

Using those concepts we can now define two template functions, one which will accept, or compile with any type as its parameter, and one which will reject, or not compile regardless of the parameter’s type:

template<typename T> requires always_true<T> void good(T) {} // ALWAYS compiles
template<typename T> requires always_false<T> void bad(T) {} // NEVER compiles

Let’s now rewrite the function that adds two numbers using a standard concept std::integral found in the <concepts> header file:

template<typename T> requires std::integral<T> auto add(T a, T b) { returna + b; }

Now this template function will only work with integral types. But that’s not all! There are two other ways C++20 allows us to express the same definition. We can replace typename with the name of the concept and drop the requires keyword:

template<std::integral T> auto add(T a, T b) { return a + b; }

Or go with the C++20 abbreviated function template syntax where auto is used as a function’s parameter type together with the (optional) name of the concept we wish to use:

auto add(std::integral auto a, std::integral auto b) { return a + b; }

I don’t know about you but I really like this short new syntax!

Concepts can be easily combined. Imagine we have two concepts we wish to combine into a third one. Here’s a simple example of how to do it:

template<typename T> concept concept_1 = true;
template<typename T> concept concept_2 = false;
template<typename T> concept concept_3 = concept_1<T> and concept_2<T>;

Alternatively a function or class template can be declared to require multiple concepts (which requires additional parenthesis):

template<typename T> requires(concept_1<T> and concept_2<T>) foo(T) {}

What follows the requires keyword must be an expression that evaluates to either true or false at compile time, so we are not limited to just concepts, for example:

template<typename T> requires(std::integral<T> and sizeof(T) >= 4) voidfoo(T) {}

The above function has been restricted to working only with integral types that are at least 32 bit.

Let’s look at a more complex example and analyze it line by line:

Example concept
template<typename T> concept can_add = requires (T a)
requires std::integral<T>;
requires sizeof(T) >= sizeof(int);
{ a + a } noexcept -> std::same_as<T>;

In line #1 we define a concept called can_add and define an optional variable a of type T. You may be wondering why the requires keyword appears multiple times. It’s because what follows after requires and is within curly braces {} is referred to as a compound requirement. Compound requirements can contain within them other requirements separated by a semicolon ;. If the expression inside is not prefixed by the requires keyword it must only be a valid C++ statement. However, what follows directly after requires without the surrounding curly braces must instead evaluate to true at compile time. Therefore line #3 means that the value of std::integral<T> must evaluate to true. If we remove requires from line #3 it would mean only that std::integral<T> is a valid C++ code without being evaluated further. Similarly line #4 tells us that the sizeof(T) must be greater than or equal to sizeof(int). Without the requires keyword it would only mean whether or not sizeof(T) >= sizeof(int) is a valid C++ expression. Line #5 means several things: a + a must be a valid expression, a + a must not throw any exceptions, and the result of a + a must return type T (or a type implicitly convertible to T). Notice that a + a is surrounded by curly braces that must contain only one expression without the trailing semicolon ;.

We can apply the can_add concept to a template function as follows:

template<typename T> requires can_add<T> T add(T x, T y) noexcept { returnx + y; }

Template function add can now only be invoked with types that satisfy the can_add concept.

So far I have limited the examples to standalone template functions, but the C++20 concepts can be applied to template classes, template member functions, and even variables.

Here’s an example of a template struct S with a template member function voidfunc(U); the struct can only be instantiated with integral types and the member function can only be called with floating point types as the parameter:

struct and member function example
template<typename T> requires std::integral<T>
struct S // ACCEPT only integral types
S(T) {}
template<typename U> requires std::floating_point<U>
void func(U) {} // ACCEPT only floating point types
S s{ 123 }; // ACCEPT only integral types
s.func(1.0); // ACCEPT only floating point types

See the source code below for more examples.


Example program:
concepts.cpp


Concepts example
#include <iostream>
#include <concepts>
#include <type_traits>
template<typename T> concept always_true = true;
template<typename T> concept always_true_2 = requires { requires true; };
template<typename T> requires always_true<T> void good(T) {} // ALWAYS compiles
template<typename T> requires always_true_2<T> void good_2(T) {} // ALWAYS compiles
template<typename T> concept always_false = false;
template<typename T> concept always_false_2 = requires { requires false; };
template<typename T> requires always_false<T> void bad(T) {} // NEVER compiles
template<typename T> requires always_false_2<T> void bad_2(T) {} // NEVER compiles
template<typename T> concept can_add = requires (T a)
requires std::integral<T>;
requires sizeof(T) >= sizeof(int);
{ a + a } noexcept -> std::same_as<T>;
template<typename T> requires can_add<T> // ONE 'requires' needed
T add(T x, T y) noexcept { return x + y; }
template<typename T> concept can_sub = requires (T a)
requires can_add<T>;
{ a * -1 } noexcept -> std::same_as<T>;
{ add<T>(a, a) } noexcept -> std::same_as<T>;
template<can_sub T> // NO 'requires' needed because 'can_sub' used instead of 'typename'
T sub(T x, T y) noexcept { return add(x, y * -1); }
template<typename T> concept can_add_and_sub = requires (T a)
requires can_add<T> and can_sub<T>; // SECOND 'requires' needed
{ sub<T>(a, a) } noexcept -> std::same_as<T>; // nothrow invocable AND returns type T, OR...
requires std::is_nothrow_invocable_r_v<T, decltype(sub<T>), T, T>; // same as above
template<can_add_and_sub T>
T add_sub(T x, T y, T z) noexcept { return x + y - z; }
template<typename T> concept can_add_and_sub_2 =
can_add<T> and can_sub<T> and
std::is_nothrow_invocable_r_v<T, decltype(sub<T>), T, T>;
template<typename T> requires can_add_and_sub_2<T>
T sub_add(T x, T y, T z) noexcept { return x - y + z; }
template<typename T> requires(std::integral<T> and sizeof(T) >= 4) // ACCEPT any integral type 32-bit or larger
void foo(T t) { std::cout << "1st foo overload called with t = " << t << std::endl; }
template<std::integral T> requires std::same_as<T, short> // ACCEPT only 'short'
void foo(T t) { std::cout << "2nd foo overload called with t = " << t << std::endl; }
void foo(auto t) requires std::same_as<decltype(t), char> // ACCEPT only 'char'
{ std::cout << "3rd foo overload called with t = " << (int)t << std::endl; }
void bar(std::integral auto t) // ACCEPT any integral type
{ std::cout << "1st bar overload called with t = " << t << std::endl; }
void bar(std::floating_point auto t) // ACCEPT any floating point type
{ std::cout << "2nd bar overload called with t = " << t << std::endl; }
template<typename T> requires std::integral<T>
struct S // ACCEPT only integral types
S(T) {}
template<typename U> requires std::floating_point<U>
void func(U) {} // ACCEPT only floating point types
template<typename T> auto qaz(T t) { return t; } // RETURN the type/value passed in
// RESTRICT global variable types to integral and floating point
[[maybe_unused]] std::integral       auto g_i = qaz(3); // ACCEPT only integral types
[[maybe_unused]] std::floating_point auto g_f = qaz(3.14159); // ACCEPT only floating point types
int main()
good(11); // ALWAYS GOOD because 'always_true' concept used
good_2(11); // ALWAYS GOOD because 'always_true_2' concept used
// bad(14); // ALWAYS ERROR because 'always_false' concept used
// bad_2(14); // ALWAYS ERROR because 'always_false_2' concept used
add(1, 2);
// add<short>(1, 2); // ERROR because of 'requires sizeof(T) >= sizeof(int);' in 'can_add'
sub(3, 4);
// sub<const char*>("3", "4"); // ERROR because 'requires std::integral<T>;' in 'can_add'
add_sub<long long>(5, 6, 7);
sub_add<long long>(5, 6, 7);
foo(11); // calls 1st overload
foo(short{14}); // calls 2nd overload
foo(char{17}); // calls 3rd overload
bar(20); // calls 1st overload
bar(23.f); // calls 2nd overload
// RESTRICT local variable types to integral and floating point
[[maybe_unused]] std::integral       auto i = 11; // int
[[maybe_unused]] std::floating_point auto f = 11.f; // float
[[maybe_unused]] std::floating_point auto d = 11.; // double
// std::integral       auto i2 = 11.f; // ERROR because 'int' is expected
// std::floating_point auto f2 = 11; // ERROR because 'float' is expected
S s{ 123 }; // ACCEPT only integral types
s.func(1.0); // ACCEPT only floating point types

Like this:

Loading...

Related


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK