Idriss Chaouch
9 min readOct 9, 2020

--

C++20 features (Part I)

The C++ programming language is a general-purpose multiparadigm language created by Bjarne Stroustrup. The development of the language started in 1979 under the name “C with classes.” As the name implies, it was an extension of the C language with the additional concept of classes. Stroustrup wanted to create a better C that combined the power and efficiency of C with high-level abstractions to better manage large development “projects. The resulting language was renamed C++ (pronounced “C-plus-plus”) in 1983. As a deliberate design feature, C++. C++ is updated and maintained by the C++ standards committee. In 1998, the first international standard was published, known informally as C++98. The language has since undergone five more revisions with further improvements, including C++03, C++11, C++14, C++17, and most recently C++20, which is the latest ISO standard for the C++ programming language released in 2020.

C++20 provides a “big” 4 major cores which are:

Concepts

Modules

Ranges

Coroutines

Concepts:

Concepts are a major feature in C++20 that provides a set of requirements for types. The basic idea behind concepts is the compile-time validation of template arguments. For example, to specify that the template argument must have a default constructor, we use the default_constructible concept. We can say that concepts are types that describe other types – meta-types, so to speak. They allow the compile-time validation of template parameters along with a function invocation based on type properties. Concepts introduce even more flexibility to generic programming. Now it is possible to set restrictions on template parameters, check for constraints, and discover inconsistent behavior at compile time. A template class declaration has the following form:

template <typename T>

class Wallet

{

// the body of the class using the T type

};

Pay attention to the typename keyword in the preceding code block. Concepts go even further: they allow replacing it with a type description that describes the template parameter. Let’s say we want the Wallet to work with types that can be added together, that is, they should be addable. Here’s how using a concept will help us achieve that in the code:

template <addable T>

class Wallet

{

// the body of the class using addable T’s

};

So, now we can create Wallet instances by providing types that are addable. Whenever the type doesn’t satisfy the constraint, the compiler will throw an error. It looks a bit supernatural. The following snippet declares two Wallet objects:

class Book

{

// doesn’t have an operator+

// the body is omitted for brevity

};

constexpr bool operator+(const Money& a, const Money& b) {

return Money{a.value_ + b.value_};

}

class Money

{

friend constexpr bool operator+(const Money&, const Money&);

// code omitted for brevity

private:

double value_;

};

Wallet<Money> w; // works fine

Wallet<Book> g; // compile error

The Book class has no + operator, so the construction of g will fail because of the template parameter type restriction.

The declaration of a concept is done using the concept keyword and has the following form:

template <parameter-list>

concept name-of-the-concept = constraint-expression;

As you can see, a concept is also declared using templates. We can refer to them as types that describe other types.

Concepts rely heavily on constraints. A constraint is a way to specify requirements for template arguments, and, as follows, a concept is a set of constraints. Here’s how we can implement the preceding addable concept:

template <typename T>

concept addable = requires (T obj) { obj + obj; }

Standard concepts are defined in the <concepts> header.

We can also combine several concepts into one by requiring the new concept to support the others. To achieve that we use the && operator. Let’s see how iterators leverage concepts and bring an example of an incrementable iterator concept that combines other concepts.

  1. Using iterators in C++20:

After the introduction to concepts, it is obvious that iterators are first to leverage them to the fullest. Iterators and their categories are now considered legacy because, starting from C++20, we use iterator concepts such as readable (which specifies that the type is readable by applying the * operator) and writable (which specifies that a value can be written to an object referenced by the iterator). As promised, let’s see how incrementable is defined in the <iterator> header:

So, the incrementable concept requires the type to be std::regular. That means it should be constructible by default and have a copy constructor and operator==(). Besides that, the incrementable concept requires the type to be weakly_incrementable, which means the type supports pre- and post-increment operators, except that the type is not required to be equality-comparable. That’s why the incrementable joins std::regular to require the type to be equality-comparable. Finally, the addition requires constraint points to the fact that the type should not change after an increment, that is, it should be the same type as before. Although std::same_as is represented as a concept (defined in <concepts>), in previous versions we used to use std::is_same defined in <type_traits>. They basically do the same thing, but the C++17 version – std::is_same_v – was verbose, with additional suffixes.

template <typename T>

concept incrementable = std::regular<T> && std::weakly_incrementable<T>

&& requires (T t) { {t++} -> std::same_as<T>; };

Coroutines:

Coroutines are special functions able to stop at any defined point of execution and resume later. Coroutines extend the language with the following new keywords:

  • co_await suspends the execution of the coroutine.
  • co_yield suspends the execution of the coroutine while also returning a value.
  • co_return is similar to the regular return keyword; it finishes the coroutine and returns a value.

Take a look at the following classic example:

generator<int> step_by_step(int n = 0) {

while (true) {

co_yield n++;

}}

A coroutine is associated with a promise object. The promise object stores and alerts the state of the coroutine.

Asynchronous systems are really useful in I/O operations because any input or output operation blocks the execution at the point of I/O call. For example, the following pseudo-code reads a file from a directory and then prints a welcome message to the screen:

auto f = read_file(“filename”);

cout << “Welcome to the app!”;

process_file_contents(f);

Attached to the synchronous execution pattern, we know that the message Welcome to the app! will be printed only after the read_file() function finishes executing. process_file_contents() will be invoked only after cout completes. When dealing with asynchronous code, all we know about code execution starts to behave like something unrecognizable. The following modified version of the preceding example uses the read_file_async() function to read the file contents asynchronously:

auto p = read_file_async(“filename”);

cout << “Welcome to the app!”;

process_file_contents(p); // we shouldn’t be able to do this

Coroutines are connected to callers. In the preceding example, the function that call sprocess_image() transfers execution to the coroutine and the pause by the coroutine (also known as yielding) transfers the execution back to the caller. As we stated, the heap is used to store the state of the coroutine, but the actual function-specific data (arguments, and local variables) are stored on the caller’s stack. That’s it – the coroutine is associated with an object that is stored on the caller function’s stack. Obviously, the coroutine lives as long as its object.

Coroutines might give a wrong impression of redundant complexity added to the language, but their use cases are great in improving applications that use asynchronous I/O code (as in the preceding example) or lazy computations.

Ranges:

The ranges library provides a new way of working with ranges of elements. To use them, you should include the <ranges> header file. Let’s look at ranges with an example. A range is a sequence of elements having a beginning and an end. It provides a begin iterator and an end sentinel. Consider the following vector of integers:

import <vector>

int main()

{

std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};

}

Ranges accompanied by range adapters (the | operator) provide powerful functionality to deal with a range of elements. For example, examine the following code:

“import <vector>

import <ranges>

int main()

{

std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};

for (int current : elements | ranges::view::filter([](int e) { return

e % 2 == 0; }))

{

std::cout << current << “ “;

}

}

In the preceding code, we filtered the range for even integers using ranges::view::filter(). Pay attention to the range adapter | applied to the elements vector.

Ranges are tied to views. We will examine them both in this section.

Simply put, a range is a traversable entity; that is, a range has a begin() and an end(), much like the containers we’ve worked with so far. In these terms, every STL container can be treated as a range. STL algorithms are redefined to take ranges as direct arguments. By doing this, they allow us to pass a result from one algorithm directly to the other instead of storing intermediary results in local variables. For instance, std::transform, which we used earlier with a begin() and an end(), has the following form if applied to a range (the following code is pseudocode). By using ranges, we can rewrite the previous example in the following way:

ProductList apples = filter(

transform(vec, [](ProductPtr p){/* normalize the name */}),

[](ProductPtr p){return p->name() == “apple”;}

);

Don’t forget to import the <ranges> header. The transform function will return a range containing Product pointers whose names are normalized; that is, the numeric value is replaced with a string value.The filter function will then take the result and return the range of products that have apple as their name.

Finally, the overloaded operator, |, which we used in the example at the beginning of this chapter, allows us to pipe ranges together. This way, we can compose algorithms to produce a final result, as follows:

ProductList apples = vec | transform([](ProductPtr p){/* normalize the name */})

| filter([](ProductPtr p){return p->name() == “apple”;});

We used piping instead of nesting function calls. This might be confusing at first because we used to use the | operator as a bitwise OR. Whenever you see it applied to a collection, it refers to piping ranges.

Modules:

One of the most anticipated features is modules, which provide the ability to declare modules and export types and values within those modules. You can consider modules an improved version of header files with the now redundant include-guards.

Modules fix header files with annoying include-guard issues. We can now get rid of preprocessor macros. Modules incorporate two keywords, import and export. To use a module, we import it. To declare a module with its exported properties, we use export. Before listing the benefits of using modules, let’s look at a simple usage example. The following code declares a module:

export module test;

export int twice(int a) { return a * a; }

The first line declares the module named test. Next, we declared the twice() function and set it to export. This means that we can have functions and other entities “that are not exported, thus, they will be private outside of the module. By exporting an entity, we set it public to module users. To use module, we import it as done in the following code:

import test;

int main()

{

twice(21);

}

Modules are a long-awaited feature of C++ that provides better performance in terms of compilation and maintenance. The following features make modules better in the competition with regular header files:

  • A module is imported only once, similar to precompiled headers supported by custom language implementations. This reduces the compile time drastically. Non-exported entities have no effect on the translation unit that imports the module.
  • Modules allow expressing the logical structure of code by allowing you to select which units should be exported and which should not. Modules can be bundled together into bigger modules.

Modules can be used together with header files. We can both import and include headers in the same file, as demonstrated in the following example:

import <iostream>;

#include <vector>

int main()

{

std::vector<int> vec{1, 2, 3};

for (int elem : vec) std::cout << elem;

}

When creating modules, you are free to export entities in the interface file of the module and move the implementations to other files. The logic is the same as in managing .h and .cpp files.

Besides notable features added in C++20, there is a list of other features that we will discuss in this story:

  • The spaceship operator: operator<=>(). The verbosity of operator overloading can now be controlled by leveraging operator<=>().
  • constexpr conquers more and more space in the language. C++20 now has the consteval function, constexpr std::vector and std::string, and many more.
  • Math constants, such as std::number::pi and std::number::log2e.
  • Major updates to the Thread library, including stop tokens and joining threads.
  • The iterator concepts.
  • Move-only views and other features.

To better understand some new features and also dive into the essence of the language, we will introduce the language’s core starting from previous versions. This will help us to find better uses for new features compared to older ones, and will also help in supporting legacy C++ code. Let’s now start by gaining an understanding of the C++ application building-process.

Constexpr:

The constexpr specifier declares that the value of the function is possible to evaluate at compile time. The same definition applies to variables as well. The name itself consists of const and expression. C++20 introduces the consteval specifier, allowing you to insist on the compile-time evaluation of the function result. In other words, a consteval function produces a constant expression at compile time. The specifier makes the function an immediate one, which will produce an error if the function call cannot lead to a constant expression. The main() function cannot be declared as constexpr. C++20 also introduces the constinit specifier. We use constinit to declare a variable with static or thread storage duration.

TO BE CONTINUED..

--

--

Idriss Chaouch

C++ | STL | Semiconductors | VHDL | SoCs | FPGAs | Python | Visual Studio