Monday, January 2, 2023

Modern C++ features

Modern C++

In this article, I will provide a tutorial on features in modern C++ using very simple examples. The examples will be kept short for beginners to quickly grasp the features.

The complete list of features can be found in the following links: C++11, C++14, C++17, C++20. Please refer to these links for more information.

1 C++11

1.1 nullptr

Considering function overloading:

void fun(int);
void fun(char *);

Traditionally, NULL is often defined as 0. The issue arises with which function will foo(NULL) call?

Thus, C++11 introduces a new kerword, nullptr, as a distinghished null pointer consant.

#include <iostream>

void fun(char *p) {
        std::cout << "hi pointer" << std::endl;

}

void fun(int i) {
        std::cout << "hi integer" << std::endl;

}

int main()
{
        fun(0);       // Calls func(int)
        fun(nullptr); // Calls func(char*)
        //fun(NULL);  // This generates an error
        return 0;

}

1.2 Strongly typed enumeration

Enumerations in C++03 are effectively integers and do not have their own scope. It is possible to compare two enum values of different enumeration types.

#include <iostream>

int main()
{
        enum Animal {Cat, Dog, Elephant};
        enum Fish {Clam, Dolphin, Eel};

        Animal a = Dog;
        Fish f = Dolphin;

        if (a == f)
                std::cout << "Dog == Dolphin" << std::endl;
        else
                std::cout << "Dog != Dolphin" << std::endl;
        return 0;
}

C++11 introduces new strongly-typed enumerations by adding the keyword class or struct to classic enumerations. They do not implicitly convert to int and can only be accessed within the scope of the enumeration.

#include <iostream>

int main()
{
        enum class Animal {Cat, Dog, Elephant};
        enum class Fish {Clam, Dolphin, Eel};

        Animal a = Animal::Dog;
        Fish f = Fish::Dolphin;

#if 0
        // This will cause a compiling error
        if (a == f)
                std::cout << "Dog == Dolphin" << std::endl;
#endif
        std::cout << "a/Dog converted to int: " << static_cast<int>(a) << std::endl;
        std::cout << "f/Dolphin converted to int: " << static_cast<int>(f) << std::endl;
        return 0;
}

1.3 Uniform initialization

C++11 introduces a consistent syntax for variable initialization, using braces to enclose values:

type var_name{arg1, arg2, ...argn}

The following example demonstrates initializing different types of variables in a similar manner:

#include <iostream>

class C
{
public:
        int arr[3];

        C(int x, int y, int z)
                : arr{x, y, z} {}; // initialize an array member

        void show()
        {
                std::cout << arr[0] << " " << arr[1] << " " << arr[2] << std::endl;
        }
};

C fun(C c)
{
        int i{c.arr[0]}; // initialize an integer
        int j{c.arr[1]};
        int k{c.arr[2]};
        return {i, j, k}; // initalize an object to return
}

int main()
{
        C obj = fun({7, 8, 9}); // initialize a function argument
        obj.show();
        return 0;
}

Also, look into initializer lists, regular constructors, and aggregate initialization.

1.4 Ranged based for loop

C++11 extends the syntax of the for statement to allow easy iteration over a range of elements. All standard library containers that have begin-end pairs will work with the range-based for statement.

#include <iostream>
#include <vector>

int main()
{
        std::vector<int> v{1, 3, 5};
        for (int &i: v) {
                std::cout << i << " ";
        }
        std::cout << std::endl;
        return 0;
}

1.5 Array container

std::array is a container for constant size arrays. It wraps around traditional arrays to maintain size information even when the array is assigned to a pointer.

#include <array>
#include <iostream>
#include <algorithm>

int main()
{
        std::array<int, 6> a {{2, 6, 1, 5, 4, 3}}; // Double braces are used here
        std::cout << "size of a = " << a.size() << std::endl;
        std::sort(a.begin(), a.end());
        std::cout << "sorted a: ";
        for (auto i : a) {
                std::cout << i << " ";
        }
        std::cout << std::endl;
        return 0;
}

1.6 constexpr

In C++03, compiling the following code would result in an error because a function cannot be a constant expression:

int fun() {
        return 3;
}

int main()
{
        int a[2 + fun()]; // compiling error
        return 0;
}

C++11 introduces the constexpr keyword to allow for compile-time function evaluation:

constexpr int fun() { // a function that always returns a constant integer
        return 3;
}

int main()
{
        int a[2 + fun()]; // an array of 5 intgeters
        a[4] = 6;
        return 0;
}

constexpr is a declaration specifier that can be applied to:

  • the definition of a variable
  • the declaration of a function or function template
  • the declaration of a static data member

constexpr guarantees that the function or variable can be evaluated at compile-time, allowing the compiler to perform certain optimizations.

1.7 Explicitly deleted and defaulted functions

C++11 allows the explicit declaration of default functions by appending the =default specifier to their declaration. This controls the automatic generation of special member functions.

In the example below, we force the compiler to generate a default constructor. Otherwise, there would be a compiling error since the default constructor is not automatically generated when a parameterized constructor is defined.

class A {
public:
        A(int a) {};
        A() = default;
};

int main(void)
{
        A a; // default constructor
        A b(1); // parameterized constructor
        return 0;
}

C++11 also allows the disabling of certain functions by using the =delete specifier. Deleted functions provide a simple language feature to prevent problematic type promotions in function arguments. The following example generates two errors:

class A {
public:
        A(int a) {};
        A(double) = delete; // disable: conversion from double to int
        A& operator=(const A&) = delete; // disable: assignment operator
};

int main(void)
{
        A a(3);
        A b(2.2); // error: conversion from double to int is disabled
        a = b;    // error: assignment operator si disabled
        return 0;
}

The first error at A b(2.2); is because the conversion from double to integer was disabled. The second error at a = b; is because the assignment operator was disabled.

1.8 Delegating constructers

Many classes have multiple constructors that do similar things. It is useful for a constructor to call another constructor to delegate initialization.

In the following example:

class A {
        int x, y, z;
public:
        A()
        {
                x = 0;
                y = 0;
                z = 0;
        }
        A(int z)
        {
                x = 0; // redundant
                y = 0; // redundant
                this->z = z;
        }
};

int main(void)
{
        A a(3);
        return 0;
}

We can remove the duplicated code with constructor delegation:

class A {
        int x, y, z;
public:
        A() : x(0), y(0), z(0) { }
        A(int z) : A() {
                this->z = z;
        }
};

int main(void)
{
        A a(3);
        return 0;
}

1.9 Automatic type deduction and decltype

In traditional C++, we must specify the type of an object when declaring it. C++11 allows us to declare objects without specifying their types by taking advantage of the initializer in an object's declaration.

auto a = 1; // a is an integer
auto c = 'x'; // c is a character

The auto keyword used to denote automatic storage duration, but C++11 has changed its meaning: auto now declares an object whose type is deducible from its initializer.

It is particularly useful when the type of an object is verbose. Consider:

for (std::vector<int>::const_iterator ci = vec.begin(); ci != vec.end(); ci++)

With auto type deduction, it can be simplified to:

for (auto ci = vec.begin(); ci < vec.end(); ci++)

C++ offers a similar mechanism for capturing the type of an expression: decltype inspects the type of an declared entity.

C++ also offers a mechanism for capturing the type of an expression: decltype inspects the declared type of an entity.

int a = 1;
decltype(a) b = a + 3; // b has the type of a - int

People tend to use auto more often for variable initialization. decltype is used when the type of something other than a variable is needed, such as for a return type.

1.10 Lambda functions

C++11 provides the ability to create anonymous functions, called lambda functions, with the syntax:

[ capture ] (parameters) -> return-type
{
  lambda body
}

It allows a function to be defined at the point needed within another expression.

#include <algorithm>
#include <vector>
#include <iostream>

int main()
{
        std::vector<int> v{3, 2, 4, 5, 1};
        sort(v.begin(), v.end(),
                [](const int& i, const int& j) { return i > j; }
        );
        for_each(v.begin(), v.end(),
                [](int i) { std::cout << i << " "; }
        );
        return 0;
}

1.11 Hash tables

Hashing is a technique of mapping keys to values in a hash table using a hash function.

Classical C++ has four associative containers (ordered), and C++11 has added four additional (unordered) containers:

  • Classical containers: std::set, std::multiset, std::map, std::multimap
  • C++11 new containers: std::unordered_set, std::unordered_multiset, std::unordered_map, std::unordered_multimap

The naming conventions are as follows:

  • "map": contains an associated value
  • "multi": allows more than one identical key
  • "unordered": indicates that the keys are not sorted

Here is a table summarizing the properties:

Associative Container Value Ident. keys OK Sorted
std::set      
std::unordered_set     N
std::map Y    
std::unordered_map Y   N
std::multiset   Y  
std::unordered_multiset   Y N
std::multimap Y Y  
std::unordered_multimap Y Y N

The following example demonstrates some basic usages for set and unordered_set:

#include <unordered_set>
#include <set>
#include <iostream>

int main()
{
        std::set<int> os {2, 10};
        os.insert(6);
        os.erase(10);
        std::cout << "(ordered) set: ";
        for (auto &s: os) {
                std::cout << s << " ";
        }
        std::cout << std::endl;

        std::unordered_set<int> us {2, 10};
        us.insert(6);
        us.erase(10);
        std::cout << "unordered_set: ";
        for (auto &n: us) {
                std::cout << n << " ";
        }
        std::cout << std::endl;
        return 0;
}

Output:

(ordered) set: 2 6 unordered_set: 6 2

1.12 rvalue reference and move semantics

Move semantics are introduced to avoid unnecessary copying of objects. Consider the following example (from stackoverflow) of a String class that holds a pointer to allocated heap memory:

class String {
        char* data;

public:
        String(const char* p)
        {
                size_t size = strlen(p) + 1;
                data = new char[size];
                memcpy(data, p, size);
                std::cout << data << " created" << std::endl;
        }

        void Print()
        {
                std::cout << "Printing " << data << std::endl;
        }
};

Its destructor and copy constructor are implemented as follows:

class String {
public:
        ~String()
        {
                delete[] data;
                std::cout << "Destroyed" << std::endl;
        }

        String(const String& s)
        {
                size_t size = strlen(s.data) + 1;
                data = new char[size];
                memcpy(data, p, size);
                std::cout << data << " copied" << std::endl;
        }
};

The copy constructor defines what it means to copy String objects. We then implement a Person class to pass String into:

class Person {
        String name;

public:
        Person(const String& s) : name(s) { }

        void PrintName()
        {
                name.Print();
        }
};

In main, we create a temporary String object as the parameter to create a Person object:

int main()
{
        Person foo {String("Foo")};
        foo.PrintName();
        return 0;
}

The output is:

Foo created Foo copied Destroyed Printing Foo Destroyed

Now we can go through the steps to understand the process:

  • Firstly, we create a String object, so the class constructor is called, which prints "Foo created."
  • Next, in the constructor of the Person object, it copies the String object to the data variable and prints "Foo copied." This is where the copy constructor is called.
  • Then, the original String object that we created is destroyed, so the destructor is called, which prints "Destroyed."
  • Finally, we have the Person object print "Printing Foo," and then we exit the program, which destroys the created Person object and thus prints the second "Destroyed."

Allocating memory twice is unnecessary, especially when dealing with a lot of dynamic data.

In the example, the created String object (Foo) is an rvalue because it has no name underneath. It is a temporary object that is to be destroyed at the next semicolon—at the end of the full expression containing the rvalue. The client has no way to inspect the String object again later. So, we could do whatever we want with the source String, and the client would not be able to tell the difference.

C++11 introduces a new type of reference—/rvalue/ reference—which allows us to detect rvalue (temporary) arguments via function overloading. To do so, we add a constructor with an rvalue reference parameter. In that constructor, we can do whatever we want with the source String:

class String {
        String(String&& s)
        {
                data = s.data;
                s.data = nullptr;
                std::cout << data << " moved" << std::endl;
        }
};

Instead of copying the heap data, we copy the pointer and set the original pointer to nullptr. In effect, we transfer ownership of the data that originally belongs to the source String. This operation is called a "move constructor." Its job is to move resources from one object to another, not to copy them.

Setting the original pointer to nullptr is crucial so that when the destructor is called on the source object, the memory we transferred will not be deleted.

The Person class is also updated to accept an rvalue reference:

class Person {
public:
        Person(String&& s) : name(std::move(s)) { }
};

The main function remains the same. The program produces the following output:

Foo created Foo moved Destroyed Printing Foo Destroyed

Great! No more unnecessary copying; we simply transfer the memory. This is the essence of move semantics.

Here is the full code, including the copy constructor for reference:

#include <cstring>
#include <iostream>

class String {
        char* data;

public:
        String(const char* p)
        {
                size_t size = strlen(p) + 1;
                data = new char[size];
                memcpy(data, p, size);
                std::cout << data << " created" << std::endl;
        }

        ~String()
        {
                delete[] data;
                std::cout << "Destroyed" << std::endl;
        }

        String(const String& s)
        {
                size_t size = strlen(s.data) + 1;
                data = new char[size];
                memcpy(data, s.data, size);
                std::cout << data << " copied" << std::endl;
        }

        String(String&& s)
        {
                data = s.data;
                s.data = nullptr;
                std::cout << data << " moved" << std::endl;
        }

        void Print()
        {
                std::cout << "Printing " << data << std::endl;
        }
};

class Person {
        String name;

public:
        Person(const String& s) : name(s) { }

        Person(String&& s) : name(std::move(s)) { }

        void PrintName()
        {
                name.Print();
        }
};

int main()
{
        Person foo {String("Foo")};
        foo.PrintName();
        return 0;
}

1.13 Smart pointer

Smart pointers are used to help ensure programs are free of memory leaks.

The following example leaks memory by losing the pointer to the allocated memory:

int int main(void)
{
        int* a = new int(5);
        a = nullptr; // the allocated memory is not deleted
}

C++11 provides smart pointers - std::unique_ptr, std::shared_ptr and std::weak_ptr to make sure an object is deleted if it is no longer referenced.

#include <memory>

int main(void)
{
        std::unique_ptr<int> a(new int(5));
        int b = *a;
        return 0;
}

In the above example, an explicit delete is not required. The unique_ptr destructor is always called when the unique_ptr goes out of scope, ensuring the object is deleted, regardless of how the function scope is exited, whether through a return statement or an exception.

A unique_ptr does not share its pointer. It cannot be copied nor passed by value to a function - it can only be moved:

#include <memory>

int main(void)
{
        std::unique_ptr<int> a(new int(5));
        //std::unique_ptr<int> b = a; // This will cause a compile-time error
        std::unique_ptr<int> c = std::move(a); // 'a' can no longer be used
        return 0;
}

A shared_ptr is a reference counting smart pointer, that can be used to store and pass a reference:

#include <memory>
#include <iostream>

class A {
        std::shared_ptr<int> a;
public:
        A(int val) {
                this->a = std::shared_ptr<int>(new int(a));
        }

        std::shared_ptr<int> getA() {
                return a;
        }
};

int main(void)
{
        A foo(3);
        std::shared_ptr<int> bar = foo.getA();
        std::cout << *bar << std::endl;
        return 0;
}

Using std::make_shared to initialize the shared pointer is more efficient because it typically performs only one memory allocation for both the object and its control block, whereas using new with std::shared_ptr results in two allocations.

class A {
        std::shared_ptr<int> a;
public:
        A(int val) : a(std::make_shared<int>(val)) {}
        // ...
};

All instances point to the same object and share a single control block that increments and decrements the reference count. When the reference count reaches zero, the control block deletes the memory resource and itself.

std::weak_ptr is used to access the underlying object of a std::shared_ptr without causing the reference count to be incremented. It is typically required when you have cyclic references between std::shared_ptr instances.

The smart pointer std::auto_ptr is deprecated and has been replaced by std::unique_ptr.

2 C++14

2.1 Binary literals

Numeric literals can be specified in binary form, using prefixes 0b or 0B.

auto a = 0b101; // a is an integer with the decimal value 5

2.2 Digit separators

Single quotes can be used as digit separators to enhance the readability of numeric literals, including both integers and floating-point numbers.

auto i = 1'000'000'000;
auto b = 0b100'0001;

2.3 Function return type deductions

It is necessary, for example, when we have a function template with more than two parameters and cannot determine the return type of the function.

In C++11, there are two ways to specify the return type: trailing return types or auto keyword combined with decltype.

#include <iostream>

template <typename A, typename B>
auto sum(A a, B b) -> decltype(a + b) {
        return a + b;
}

int main(void)
{
        std::cout << typeid(sum(2, 3)).name() << std::endl;
        std::cout << typeid(sum(2.2, 3.3)).name() << std::endl;
        std::cout << typeid(sum(true, false)).name() << std::endl;
        return 0;
}

Since C++14, we can simplify this without the trailing return type specifier.

#include <iostream>

template <typename A, typename B>
auto sum(A a, B b) {
        return a + b;
}

int main(void)
{
        std::cout << typeid(sum(2, 3)).name() << std::endl;
        std::cout << typeid(sum(2.2, 3.3)).name() << std::endl;
        std::cout << typeid(sum(true, false)).name() << std::endl;
        return 0;
}

2.4 Generic lambdas

In C++11, lambda function parameters need to be declared with concrete types. A lambda function summing up two integers would look like this:

[](int a, int b) -> int {return a + b;}

To obtain the sum of two floating-point values, another lambda expression is needed.

[](double a, double b) -> double {return a + b;}

C++14 relaxes this requirement by allowing the parameters of a lambda function to be declared with the auto type specifier.

[](auto a, auto b) -> {return a + b;}

The following is an example of generalized lambda to sum up:

#include <iostream>
#include <string>

int main(void)
{
        auto sum = [](auto a, auto b) {
                           return a + b;
                   };
        std::cout << sum(2, 3) << std::endl;
        std::cout << sum(2.2, 3.3) << std::endl;
        std::cout << sum(std::string("22"), std::string("33")) << std::endl;
        return 0;
}

An important application is to enhance algorithms, such as the following example using the sort() function.

#include <iostream>
#include <algorithm>
#include <vector>

int main(void)
{
        auto greater = [](auto a, auto b) -> bool {
                               return a > b;
                       };

        std::vector<int> vi = {9, 3, 5, 6};
        std::vector<double> vd = {9.9, 3.3, 5.5, 6.6};
        std::vector<std::string> vs = {"nine", "three", "five", "six"};

        sort(vi.begin(), vi.end(), greater);
        sort(vd.begin(), vd.end(), greater);
        sort(vs.begin(), vs.end(), greater);
        return 0;
}

2.5 Generalized lambda captures

In C++11, lambdas can only capture existing object in their scope.

int a = 3;
auto myld = [a](int i){ std::cout << i << "&" << a << std::endl; };

C++14 allows captured members to be initialized with arbitrary expression.

#include <iostream>

int main(void)
{
        int a = 3;
        auto myld = [b = a + 3](int i){
                            std::cout << i << " & " << b << std::endl;
                    };
        myld(9);
        return 0;
}

2.6 Variable templates

In prior versions of C++, only functions, classes, or type aliases could be templated. C++14 introduced the ability to create templated variables.

#include <iostream>
#include <iomanip>

template<class T>
constexpr T pi = T(3.1415926535897932385L); // variable template

template<class T>
T circular_area(T r)
{
        return pi<T> * r * r; // pi<T> is a variable template instantiation
}

int main(void)
{
        std::cout << std::setprecision(13);
        std::cout << circular_area(3) << std::endl;
        std::cout << circular_area(3.0f) << std::endl;
        std::cout << circular_area(3.0) << std::endl;
        return 0;
}

2.7 Extended constexpr

C++11 constexpr functions can only contain a single return expression.

C++14 relaxes these restrictions, allowing constexpr functions to include:

  • local variable declarations
    • not static, thread_local
    • no uninitialized variables
  • mutating objects whose lifetime began with the constant expression evaluation
  • statements such as if, switch, for, while, do-while

    constexpr float exp(float x, int n)
    {
            return n == 0 ? 1 :
                    n % 2 == 0 ? exp(x * x, n / 2) :
                    exp(x * x, (n - 1) / 2) * x;
    }
    

3 C++17

3.1 Nested namespaces

Before C++17 we have to use a verbose syntax to declare classes in nested namespaces.

namespace Company {
        namespace Module {
                namespace Part {
                        class MyClass {
                        };
                }
        }
}

In C++17, this can be simplified.

namespace Company::Module::Part {
        class MyClass {
        };
}

3.2 Variable declaration in if and switch

C++17 allows the declaration of variables inside if and switch statements:

if (init; condition) {
    // ...
}
switch (init; condition) {
    // ...
}

This is useful when we want to prevent the variable from leaking into the enclosing scope. Consider the following example:

const auto it1 = myString.find("foo");
if (it1 != std::string::npos) {
        std::cout << it1 << std::endl;
}
const auto it2 = myString.find("bar");
if (it1 != std::string::npos) {
        std::cout << it2 << std::endl;
}

It can be rewritten with each variable in separate if scope.

if (const auto it = myString.find("foo"); it != std::string::npos)
        std::cout << it1 << std::endl;
if (const auto it = myString.find("bar"); it != std::string::npos)
        std::cout << it2 << std::endl;

3.3 constexpr if statement

C++17 introduces the constexpr if statement to evaluate an if statement at compile time. This feature is particularly useful for template coding.

#include <iostream>
#include <type_traits>
#include <cstring>

template<typename T>
int getLen(T &t)
{
        if constexpr (std::is_same<T, const char *>::value)
                return std::strlen(t);
        else if constexpr (std::is_array<T>::value)
                return std::size(t);
        else
                return 1;
}

int main(void)
{
        const char *s = "hello";
        int a[] = {3, 6, 9};
        int i = 3;
        std::cout << getLen(s) << std::endl;
        std::cout << getLen(a) << std::endl;
        std::cout << getLen(i) << std::endl;
        return 0;
}

In the example, for the three calls to getLen(), a compile-time predicate determines which branch the program will take, allowing the compiler to optimize accordingly.

Here is another example, where constexpr if is used to define a get<N> function that works for structured bindings:

#include <iostream>
#include <string>

struct S {
        int i;
        std::string s;
        float f;
};

template<std::size_t I>
auto& get(S& s)
{
        if constexpr (I == 0)
                return s.i;
        else if constexpr (I == 1)
                return s.s;
        else if constexpr (I == 2)
                return s.f;
}

int main(void)
{
        S obj {3, "hello", 6.9f};
        std::cout << get<0>(obj) << "," << get<2>(obj) << std::endl;
        return 0;
}

Without constexpr if, we would need to have three separate templates:

template <> auto& get<0>(S &s) { return s.i; }
template <> auto& get<1>(S &s) { return s.s; }
template <> auto& get<2>(S &s) { return s.f; }

3.4 Structured binding declarations

C++17 allows us to bind specified names to elements of an initializer. The following example binds a to the integer 3 and b to "hello" of type const char*.

auto [a, b] = std::make_pair(3, "hello");

This can simplify the code, for example, when we have multiple return values from a function. In the following program, std::set::insert returns a std::pair consisting of an iterator and a bool value.

#include <string>
#include <set>
#include <iostream>

int main(void)
{
        std::set<std::string> strs;
        auto [iter, inserted] = strs.insert("hello");

        if (inserted)
                std::cout << "inserted" << std::endl;
        return 0;
}

Without structured binding, we would need to declare a std::pair:

std::pair<std::set<std::string>::iterator, bool> p = s.insert(x);
std::set<std::string>::iterator iter = p.first;
bool inserted = p.second;

Or use the magical std::tie:

std::set<std::string>::iterator it;
bool inserted;
std::tie(it, inserted) = strs.insert("hello");

Either way is more verbose.

Structured binding is not limited to tuple-like types. It can also be used for binding to an array or struct data members.

3.5 Folding expressions

C++11 introduced variadic templates to work with a variable number of input arguments.

#include <iostream>

auto sum() {
        return 0;
}

// C++11 variadic template
template<typename T1, typename... T>
auto sum(T1 s, T... ts) {
        return s + sum(ts...);
}

int main(void)
{
        std::cout << sum(1, 3, 6, 9, 19) << std::endl;
        return 0;
}

Folding expressions in C++17 offer new ways to unpack variadic parameters:

  1. ( pack op … )
  2. ( … op pack )
  3. ( pack op … op init )
  4. ( init op … op pack )

The example can be simplified as follows:

#include <iostream>

template<typename... T>
auto sum(T... ts)
{
        return (ts + ...);
}

int main(void)
{
        std::cout << sum(1, 3, 6, 9, 19) << std::endl;
        return 0;
}

4 C++20

4.1 Immediate functions

An immediate function is a consteval function. Unlike C++11 constexpr functions, which can be evaluated either at compile time or runtime, consteval functions must be evaluated at compile time.

#include <iostream>

consteval int sum(int const a, int const b) {
        return a + b;
}

int main(void)
{
        constexpr int c = sum(2, 5);
        std::cout << c << std::endl;

        int x = 3, y = 6;
        const int z = sum(x, y);
        std::cout << z << std::endl;
        return 0;
}

4.2 Abbreviated function templates

C++20 abbreviated function templates are equivalent to generic functions, using auto in parameters instead of the verbose template syntax.

The following function template:

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

In C++20 can be written as:
BEGIN_SRC cpp
auto sum(auto a, auto b)
{
        return a + b;
}

This is actually an unconstrained abbreviated function template, meaning no constraints are specified on the arguments. However, constraints can be specified, as in the following example:

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

This is a constrained abbreviated function template - an abbreviation for the following:

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

4.3 Lambda templates

With the C++14 generic lambda:

auto sum = [](auto a, auto b) {return a + b;};

The compiler generates objects like:

struct _lambda_1 {
        template<typename T, typename U>
        inline auto operator()(T t, U u) {
                return t + u;
        }
} sum;

It could be problematic in cases where the arguments are of the same type. Thus, C++20 introduces lambda templates, allowing the definition of generic lambdas using a template:

template<template_parameter_list>
concept concept_name = constraint_expression;

4.4 Modules

A module is a self-contained unit of code that can be imported into other translation units. Modules can contain declarations, definitions, and other code.

Modules provide a new way to organize code that is more flexible than the traditional header system. They can be compiled separately, which allows for faster builds and better separation of concerns.

import <iostream>;

int main(void)
{
        std::cout << "Hello modules" << std::endl;
        return 0;
}

4.5 Concepts

Consider the following template function to print various types:

#include <iostream>
#include <vector>

template<typename T>
void print(const T& msg)
{
        std::cout << msg << std::endl;
}

int main(void)
{
        print(3);
        print('c');
        print("str");

        std::vector<int> v{2, 3, 6};
        //print(v); //error!
        return 0;
}

If we try to print something that is not supported, such as a vector, there will be verbose error messages because std::vector<T> does not provide an overloaded operator<<.

C++20 concepts are provided to help. They are used to perform compile-time validation of template arguments, in the form:

template <template-parameter-list>
concept concept-name = constraint-expression;

In the following example, we define a Printable concept with a proper requires clause and impose the constraint on the print method.

#include <iostream>
#include <vector>

template<typename T>
concept Printable = requires(std::ostream& os, const T& msg)
{
        {os << msg};
};

template <Printable T>
void print(const T& msg)
{
        std::cout << msg;
}

int main(void)
{
        print(3);
        print('c');
        print("str");

        std::vector<int> v{2, 3, 6};
        print(v); // error!
        return 0;
}

Now, if we compile the program, the error messages will be clearer - there is a constraint on type T which must satisfy the Printable concept that requires T to provide an interface for std::ostream.

concept.cpp:23:2: error: no matching function for call to 'print'
print(v); //error!
^~~~~
concept.cpp:11:6: note: candidate template ignored: constraints not satisfied [with T = std::vector<int, std::allocator<int> >]
void print(const T& msg)
^
concept.cpp:10:11: note: because 'std::vector<int, std::allocator<int> >' does not satisfy 'Printable'
template <Printable T>
^
concept.cpp:7:6: note: because 'os << msg' would be invalid: invalid operands to binary expression ('std::ostream' (aka 'basic_ostream<char>') and 'const std::vector<int, std::allocator<int> >')
        {os <<EOF }
 msg};
            ^
1 error generated.

Concepts allow developers to express in code what used to be in documentation about the requirements for a template argument. They also serve as a more expressive alternative to SFINAE.

4.6 Ranges

A range is conceptually a pair of iterators, begin and end. It provides a new way to write algorithms instead of using the verbose syntax of STL begin and end iterators.

So the following sample:

std::sort(vec.begin(), vec.end());

can be simplied:

std::range::sort(vec);

For the following regular STL example,

#include <vector>
#include <numeric>
#include <algorithm>
#include <iostream>

int main(void)
{
        std::vector<int> v(30), even;

        std::iota(v.begin(), v.end(), 1);
        std::transform(v.begin(), v.end(), v.begin(),
                [](int i) {return i * i;} );
        std::copy_if(v.begin(), v.end(), std::back_inserter(even),
                [](int i) {return 0 == i % 2;} );
        for (int i = 0; i < 3; ++i)
                std::cout << even[i] << ' ';
        std::cout << std::endl;
        return 0;
}

It creates a list of numbers, squares them, copies the even numbers to another list called even, and prints the first three numbers. We may rewrite it with ranges.

#include <iostream>
#include <ranges>

int main(void)
{
        for (int n : std::views::iota(1)
                   | std::views::transform( [](int i) {return i * i;} )
                   | std::views::filter( [](int i) {return 0 == i % 2;} )
                   | std::views::take(3))
        {
                std::cout << n << ' ';
        }
        std::cout << std::endl;
        return 0;
}

The second program is clearly easier to read.

4.7 Coroutines

Coroutines are beneficial for implementing asynchronous programming and require fewer resources than threads. They are useful for the following:

  • State machines within a single subroutine
  • Actor model of concurrency
  • Generators
  • Communication sequential processes
  • Reverse communication

Unlike a function that is called once and returns at one point, a coroutine can be suspended and resumed before its final return.

A coroutine has keywords:

  • co_return to complete the execution and optionally final return a value
  • co_yield to suspend the execution and return a value
  • co_await to suspend the execution until resumed

The following is the simplest coroutine that returns directly.

#include <coroutine>

struct RetObj {
        struct promise_type {
                RetObj get_return_object() { return {}; }
                std::suspend_never initial_suspend() { return {}; }
                std::suspend_never final_suspend() noexcept { return {}; }
                void return_void() {}
                void unhandled_exception() {}
        };
};

RetObj myCoroutine() {
        co_return;
}

int main() {
        myCoroutine();
}

A valid coroutine return signature must be a class or struct with a nested promise_type including the member functions:

  • get_return_object to return the same as outer scope RegObj
  • initial_suspend to return an awaitable object
  • final_suspend to return an awaitable object
  • unhandled_exception to handle the exception if any

The promise is responsible for controlling co_return and co_yield behaviors with methods including return_void, return_value, and yield_value. In the example, we use return_void for the coroutine returning directly.

Now let's create a slightly more complex example.

#include <iostream>
#include <coroutine>

struct RetObj {
        struct promise_type {
                RetObj get_return_object() { return {}; }
                std::suspend_never initial_suspend() { return {}; }
                std::suspend_never final_suspend() noexcept { return {}; }
                void unhandled_exception() {}
        };
};

struct Awaiter {
        std::coroutine_handle<> *hp;
        constexpr bool await_ready() const noexcept { return false; }
        void await_suspend(std::coroutine_handle<> h) { *hp = h; }
        constexpr void await_resume() const noexcept {}
};

RetObj myCoroutine(std::coroutine_handle<> *h)
{
        Awaiter a{h};
        for (int i = 0; ; ++i) {
                co_await a;
                std::cout << "Coroutine: " << i << std::endl;
        }
}

int main(void)
{
        std::coroutine_handle<> h;
        myCoroutine(&h);
        for (int i = 0; i < 3; ++i) {
                std::cout << "Main: " << i << std::endl;
                h();
        }
        h.destroy();
}

Instead of returning, this coroutine suspends and waits to be resumed.

When evaluating the expression co_await a, the compiler creates a coroutine handle and passes it to the method await_suspend. The coroutine handle is a callable object that, when called will resume the coroutine at the point following the co_awiat expression.

a can be either an awaitable (that supports co_awiat operator) or awaiter object. Here we use an awaiter object - defining how the coroutine will behave, when being suspended or resumed. Three special methods must be provided:

  • await_ready returns a boolean value indicating if to suspend or not (false means to suspend).
  • await_suspend is to be called when the coroutine is suspended, with the coroutine's handle.
  • await_resume is to be called when the coroutine is resumed, and its result is the result of the whole co_await expression.

The coroutine handle i.e. std::coroutine_handle can be used to resume the execution of the coroutine, destroy the coroutine frame, or check its a lifetime.

In the example, myCoroutine always counts and prints the incrementing integer. With the co_awiat expression, myCoroutine suspends giving the control to main. Then, the coroutine handle is invoke to resume the execution of myCoroutine. The variable i maintains its value even when myCoroutine is not in control.

Output:

Main: 0
Coroutine: 0
Main: 1
Coroutine: 1
Main: 2
Coroutine: 2

5 references

Author: TPECWL2089XD

Created: 2024-05-07 Tue 15:13

Validate

No comments: