Overview of C++20: Concepts

Posts in "Overview of C++20"

  1. Ranges
  2. Concepts
  3. TBA: ...
πŸ“Œ Disclaimers:
  1. The idea of this overview is self-education. Please consider this article as my notes during learning C++20.
  2. The main goal - keep it simple and short. At least try to do it.
There is no guarantee that below theoretical and practical parts are correkt and up-to-date.

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

  • The requires clause is between template parameter list and the function return type.

  • Trailing requires clause

  • The requires clause comes after the function parameter list (and the qualifiers - const, override, etc) and before the function body.

  • Constrained template parameter

  • Simply define a requirement on our template parameters right where we declare them.

  • Placeholder syntax / Abbreviated function template

  • No need for any template parameter list or 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:

  1. Add(a, a) should be a valid template initialization and marked noexcept.
  2. Any object of type T should be multipliable by -1. If an object overrides operator* such that (a * -1) is not T, the template deduction for Add will fail.
  3. The return types of Add and (a * -1) are T.

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


  1. C++20 Concepts: The Definitive Guide
  2. The concept behind C++ concepts and 4 ways to use C++ concepts in functions
  3. C++20: Concepts, the Details
  4. All C++20 core language features with examples
  5. Beginning C++20: From Novice to Professional
  6. Software Architecture with C++20
  7. C++20 Templates: The next level: Concepts and more - Andreas Fertig - CppCon 2021 

Comments

Popular posts from this blog

My 2020 overview

My 2021

Overview of C++20: Modules