Overview of C++20: Concepts
Posts in "Overview of C++20"
- The idea of this overview is self-education. Please consider this article as my notes during learning C++20.
- The main goal - keep it simple and short. At least try to do it.
Introduction
C++20 introduces concepts as a way to write powerful and self-documenting templates. Concepts are all about putting the right level of constraints onto the operating types. They constrain what types your template can match, and check that at compile time. The syntax for defining a concept is:
Previously, you could use tag dispatching; if constexpr
; SFINAE or static_assert
to achieve more or less the same behaviour as with concepts. In the next section we will start considering some of these techniques before diving into the concepts details.
So What Was Before?
SFINAE-based constraints
The main point of SFINAE is to deactivate a piece of template code for some types. A way to achieve this is with the help of std::enable_if. Using std::enable_if
we can define a function that adds two numbers as follows:
Not clear? Good news... The latest C++ standard brings the alias templates, e.g. std::enable_if_t and std::is_integral_v. Applying them we can get:
Still unclear? Admittedly, std::enable_if
is clumsy, and even std::enable_if_t
does not help much, though it is a bit less verbose. Nevertheless, let's test the function by adding 1 and 2. The result is 3. Good! But what about adding 1.2 and 3.4? We will get a compilation error. The error message for the second example should be:
That is not a big output and it is pretty understandable. Some sources point out that a compiler typically unloads a boatload of baffling errors onto you. Probably it is related to the older compilers, because the latest GCC and Clang output above error message. So in order to fix the second example we have to add a floating point support:
Now everything must be ok. Let's just stop right there. I leave a few notes here before jumping to the next section:
- Ville's Jacksonville paper explained the pitfalls of SFINAE-based constraints when compared to concepts.
- Writing
enable_if
statements could slow down your compilation times.
static_assert
Constraints on type parameters can also be specified as static_assert
statements inside a function call or class definition. A function that adds two numbers can look like this:
The above definition of Add
function is equivalent to the one using SFINAE. The function compiles only if a
and b
are numbers. For non-numeric types, the compiler throws the following error:
Need to admit that this error is better than SFINAE. Please note, the error message depends on the compilers and their versions.
To sum up, let's check out a few real world examples of using static_assert
:
Back To The Concepts
Now let's try to achieve the same behaviour with the concepts. Like type_traits, the concepts header contains many predefined concepts that can be used directly. For our purpose, we will use std::integral and std::floating_point concepts. It is worth noting here the constraints of a concept definition are logical expressions that also consist of conjuctions (&&) and/or disjunctions (||) of one or more constant expressions of type boolean.
The above definition of Add
is equivalent to the SFINAE and static_assert
examples. However, defining the function this way is self-documenting. Any user can look at the signature and guess that it will work for numeric types. For non-numeric types, the compilation error should be something like as for static_assert
.
4 Ways Of Using Concepts
In the previous section we tested one way. Let's test all 4:
Using the
requires
clause
requires
clause is between template parameter list and the function return type.Trailing
requires
clause
requires
clause comes after the function parameter list (and the qualifiers - const
, override
, etc) and before the function body.Constrained template parameter
Placeholder syntax / Abbreviated function template
requires
. We can use the concept directly where the function arguments are enumerated. Also, you could notice that all previous implementations do not compile if you pass an int and a float, e.g. Add(1, 2.3)
. But using placeholder syntax we can take different types without specifying multiple template parameters. Awesome!Let us now look at how to write user-defined constraint expressions.
Writing Constraint Expressions
We just used std::integral
in the previous section and it is defined as:
This is as simple as it can be. Type trait std::is_integral_v is defined in the type_traits header that evaluates to true if the template parameter is an integral type. Moreover C++ provides powerful operators in constraint expressions that extend its functionality to check for availability of operators, member functions, etc - sometimes that is not possible with static_assert
. All of these features are provided through the requires
keyword. So the syntax for requires expressions is:
The optional-parameter-list
is a comma-separated list of parameters like a function definition. The variables specified in this list can just be used to assist in specifying compile time requirements and do not have any lifetime, initialization, storage, etc. If no variables are required, the parentheses can be omitted. The body of a requires expression consists of a sequence of requirements
. Each requirement ends with a semicolon. All subsequent sections will provide some examples of requirements
.
Simple requirement: expression must be valid
In order to check if type parameter can be used as intended, we can introduce a variable in the optional-parameter-list
and write the intended usage as a requirements expression. In the example of the Add
template function above, if we want to allow the function to add any type that can be added, we can write the following concept and appropriate function declaration:
It is imperative to note that the a + b
in the requirements expression is not being executed. The compiler is only verifying whether it can be compiled successfully. Add
function is now able to add any two variables of the same type that can be added, e.g.
Type requirement: type T must be a valid type
The typename
keyword can be used in the requirements expression to check whether a type is valid or if an instantiation of another class template satisfies the constraints imposed on it.
Here is how we can check if a template parameter contains an iterator_type
parameter:
Similarly, to check if an object can be placed in a vector:
Compound requirement: check return type and noexcept
Let's create a Sub
function (with related concept) that uses Add
internally to perform (a - b)
:
The above concept introduces few requirements:
Add(a, a)
should be a valid template initialization and markednoexcept
.- Any object of type
T
should be multipliable by -1. If an object overridesoperator*
such that(a * -1)
is notT
, the template deduction forAdd
will fail. - The return types of
Add
and(a * -1)
areT
.
One interesting thing to know is that std::same_as is a concept that accepts two template parameters and is defined as:
However, we pass only one template argument. This is because C++ automatically fills the first template argument with the return type of expression. That is really awesome!
Nested requirement: test boolean expressions
Concepts allow to evaluate boolean expressions inside a requires
block. Let's look a few examples to gauge the difference. The following concept checks if the size of the template parameter T
is equal to 4:
You may ask, what if we write it like this:
The difference between Is4Bytes1
and Is4Bytes2
is that last one verifies whether the statement compiles. Therefore Is4Bytes1<int64_t>
returns false, but Is4Bytes2<int64_t>
returns true because (8 == 4)
is a valid expression.
Furthermore, it is possible to write boolean expressions without the need for requires
. A concept equivalent to Is4Bytes1
can be written like so:
Overloading With Concepts
The overloading of functions or specialization of class templates can be based on concepts. We are going to try to define own implementation of std::advance
algorithm. Basically it increments given iterator by n elements. The main challange here is that containers have different types of iterators which need to be supported. Leveraging the concepts it should not be a big task:
The best version of Advance
function for different containers is used here. For more detail please look at std::advance algorithm implementation.
Summary
Many topics were covered and I think we can make the summary short. Personally I see one huge advantage here - it is all about making software engineers life more easier during developing the constrained templates. That is really one step forward!
Sources
- C++20 Concepts: The Definitive Guide
- The concept behind C++ concepts and 4 ways to use C++ concepts in functions
- C++20: Concepts, the Details
- All C++20 core language features with examples
- Beginning C++20: From Novice to Professional
- Software Architecture with C++20
- C++20 Templates: The next level: Concepts and more - Andreas Fertig - CppCon 2021
Comments
Post a Comment