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) { return a + 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) void foo(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:
1 2 3 4 5 6 |
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 { return x + 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 void func(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:
1 2 3 4 5 6 7 8 9 10 11 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
#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 } |