C++20 STL Cookbook

By Bill Weinman
    What do you get with a Packt Subscription?

  • Instant access to this title and 7,500+ eBooks & Videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. Free Chapter
    Chapter 2: General STL Features
About this book

Fast, efficient, and flexible, the C++ programming language has come a long way and is used in every area of the industry to solve many problems. The latest version C++20 will see programmers change the way they code as it brings a whole array of features enabling the quick deployment of applications. This book will get you up and running with using the STL in the best way possible.

Beginning with new language features in C++20, this book will help you understand the language's mechanics and library features and offer insights into how they work. Unlike other books, the C++20 STL Cookbook takes an implementation-specific, problem-solution approach that will help you overcome hurdles quickly. You'll learn core STL concepts, such as containers, algorithms, utility classes, lambda expressions, iterators, and more, while working on real-world recipes. This book is a reference guide for using the C++ STL with its latest capabilities and exploring the cutting-edge features in functional programming and lambda expressions.

By the end of the book C++20 book, you'll be able to leverage the latest C++ features and save time and effort while solving tasks elegantly using the STL.

Publication date:
May 2022
Publisher
Packt
Pages
450
ISBN
9781803248714

 

Chapter 2: General STL Features

This chapter is a general potpourri of STL features and techniques. These are mostly new features introduced over the past few years, which may not yet be widely used. These are useful techniques that will improve the simplicity and readability of your code.

In this chapter we will cover the following recipes:

  • Use the new span class to make your C-arrays safer
  • Use structured binding to return multiple values
  • Initialize variables within if and switch statements
  • Use template argument deduction for simplicity and clarity
  • Use if constexpr to simplify compile-time decisions
 

Technical requirements

You can find the code for this chapter on GitHub at https://github.com/PacktPublishing/CPP-20-STL-Cookbook/tree/main/chap02.

 

Use the new span class to make your C-arrays safer

New for C++20, the std::span class is a simple wrapper that creates a view over a contiguous sequence of objects. The span doesn't own any of its own data, it refers to the data in the underlying structure. Think of it as string_view for C-arrays. The underlying structure may be a C-array, a vector, or an STL array.

How to do it…

You can create a span from any compatible contiguous-storage structure. The most common use case will involve a C-array. For example, if you try to pass a C-array directly to a function, the array is demoted to a pointer and the function has no easy way to know the size of the array:

void parray(int * a);  // loses size information

If you define your function with a span parameter, you can pass it a C-array and it will be promoted to span. Here's a template function that takes a span and prints out the size in elements and in bytes:

template<typename T>
void pspan(span<T> s) {
    cout << format("number of elements: {}\n", s.size());
    cout << format("size of span: {}\n", s.size_bytes());
    for(auto e : s) cout << format("{} ", e);
    cout << "\n";
}

You can pass a C-array to this function and it's automatically promoted to span:

int main() {
    int carray[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    pspan<int>(carray);
}

Output:

number of elements: 10
number of bytes: 40
1 2 3 4 5 6 7 8 9 10 

The purpose of span is to encapsulate the raw data to provide a measure of safety and utility, with a minimum of overhead.

How it works…

The span class itself doesn't own any data. The data belongs to the underlying data structure. The span is essentially a view over the underlying data. It also provides some useful member functions.

Defined in the <span> header, the span class looks something like:

template<typename T, size_t Extent = std::dynamic_extent>
class span {
    T * data;
    size_t count;
public:
    ... 
};

The Extent parameter is a constant of type constexpr size_t, which is computed at compile time. It's either the number of elements in the underlying data or the std:: dynamic_extent constant, which indicates that the size is variable. This allows span to use an underlying structure like a vector, which may not always be the same size.

All member functions are constexpr and const qualified. Member functions include:

Important Note

The span class is but a simple wrapper that performs no bounds checking. So, if you try to access element n+1 in a span of n elements, the result is undefined, which is tech for, "Bad. Don't do that."

 

Use structured binding to return multiple values

Structured binding makes it easy to unpack the values of a structure into separate variables, improving the readability of your code.

With structured binding you can directly assign the member values to variables like this:

things_pair<int,int> { 47, 9 };
auto [this, that] = things_pair;
cout << format("{} {}\n", this, that);

Output:

47 9

How to do it…

  • Structured binding works with pair, tuple, array, and struct. Beginning with C++20, this includes bit-fields. This example uses a C-array:
    int nums[] { 1, 2, 3, 4, 5 };
    auto [ a, b, c, d, e ] = nums;
    cout << format("{} {} {} {} {}\n", a, b, c, d, e);

Output:

1 2 3 4 5

Because the structured binding uses automatic type deduction, its type must be auto. The names of the individual variables are within the square brackets, [ a, b, c, d, e ].

In this example the int C-array nums holds five values. These five values are assigned to the variables (a, b, c, d, and e) using structured binding.

  • This also works with an STL array object:
    array<int,5> nums { 1, 2, 3, 4, 5 };
    auto [ a, b, c, d, e ] = nums;
    cout << format("{} {} {} {} {}\n", a, b, c, d, e);

Output:

1 2 3 4 5
  • Or you can use it with a tuple:
    tuple<int, double, string> nums{ 1, 2.7, "three" };
    auto [ a, b, c ] = nums;
    cout << format("{} {} {}\n", a, b, c);

Output:

1 2.7 three
  • When you use it with a struct it will take the variables in the order they're defined:
    struct Things { int i{}; double d{}; string s{}; };
    Things nums{ 1, 2.7, "three" };
    auto [ a, b, c ] = nums;
    cout << format("{} {} {}\n", a, b, c);

Output:

1 2.7 three
  • You can use a reference with a structured binding, which allows you to modify the values in the bound container, while avoiding duplication of the data:
    array<int,5> nums { 1, 2, 3, 4, 5 };
    auto& [ a, b, c, d, e ] = nums;
    cout << format("{} {}\n", nums[2], c);
    c = 47;
    cout << format("{} {}\n", nums[2], c);

Output:

3 3
47 47

Because the variables are bound as a reference, you can assign a value to c and it will change the value in the array as well (nums[2]).

  • You can declare the array const to prevent values from being changed:
    const array<int,5> nums { 1, 2, 3, 4, 5 };
    auto& [ a, b, c, d, e ] = nums;
    c = 47;    // this is now an error 

Or you can declare the binding const for the same effect, while allowing the array to be changed elsewhere and still avoid copying data:

array<int,5> nums { 1, 2, 3, 4, 5 };
const auto& [ a, b, c, d, e ] = nums;
c = 47;    // this is also an error 

How it works…

Structured binding uses automatic type deduction to unpack the structure into your variables. It determines the type of each value independently, and assigns a corresponding type to each variable.

  • Because structured binding uses automatic type deduction, you cannot specify a type for the binding. You must use auto. You should get a reasonable error message if you try to use a type for the binding:
    array<int,5> nums { 1, 2, 3, 4, 5 };
    int [ a, b, c, d, e ] = nums;

Output:

error: structured binding declaration cannot have type 'int'
note: type must be cv-qualified 'auto' or reference to cv-qualified 'auto'

Above is the error from GCC when I try to use int with the structured binding declaration.

  • It's common to use structured binding for a return type from a function:
    struct div_result {
        long quo;
        long rem;
    };
    div_result int_div(const long & num, const long & denom) {
        struct div_result r{};
        r.quo = num / denom;
        r.rem = num % denom;
        return r;
    }
    int main() {
        auto [quo, rem] = int_div(47, 5);
        cout << format("quotient: {}, remainder {}\n",
          quo, rem);
    }

Output:

quotient: 9, remainder 2
  • Because the map container classes return a pair for each element, it can be convenient to use structured binding to retrieve key/value pairs:
    map<string, uint64_t> inhabitants {
        { "humans",   7000000000 },
        { "pokemon", 17863376 },
        { "klingons",   24246291 },
        { "cats",    1086881528 }
    };
    // I like commas
    string make_commas(const uint64_t num) {
        string s{ std::to_string(num) };
        for(int l = s.length() - 3; l > 0; l -= 3) {
            s.insert(l, ",");
        }
        return s;
    }
    int main() {
        for(const auto & [creature, pop] : inhabitants) {
            cout << format("there are {} {}\n", 
                make_commas(pop), creature);
        }
    }

Output:

there are 1,086,881,528 cats
there are 7,000,000,000 humans
there are 24,246,291 klingons
there are 17,863,376 pokemon

Using structured binding to unpack structures should make your code clearer and easier to maintain.

 

Initialize variables within if and switch statements

Beginning with C++17, if and switch now have initialization syntax, much like the for loop has had since C99. This allows you to limit the scope of variables used within the condition.

How to do it…

You may be accustomed to code like this:

const string artist{ "Jimi Hendrix" };
size_t pos{ artist.find("Jimi") };
if(pos != string::npos) {
    cout << "found\n";
} else {
    cout << "not found\n";
}

This leaves the variable pos exposed outside the scope of the conditional statement, where it needs to be managed, or it can collide with other attempts to use the same symbol.

Now you can put the initialization expression inside the if condition:

if(size_t pos{ artist.find("Jimi") }; pos != string::npos) {
    cout << "found\n";
} else {
    cout << "not found\n";
}

Now the scope of the pos variable is confined to the scope of the conditional. This keeps your namespace clean and manageable.

How it works…

The initializer expression can be used in either if or switch statements. Here are some examples of each.

  • Use an initializer expression with an if statement:
    if(auto var{ init_value }; condition) {
        // var is visible 
    } else {
        // var is visible 
    } 
    // var is NOT visible 

The variable defined in the initializer expression is visible within the scope of the entire if statement, including the else clause. Once control flows out of the if statement scope, the variable will no longer be visible, and any relevant destructors will be called.

  • Use an initializer expression with a switch statement:
    switch(auto var{ init_value }; var) {
    case 1: ...
    case 2: ...
    case 3: ...
    ...
    Default: ...
    }
    // var is NOT visible 

The variable defined in the initializer expression is visible within the scope of the entire switch statement, including all the case clauses and the default clause, if included. Once control flows out of the switch statement scope, the variable will no longer be visible, and any relevant destructors will be called.

There's more…

One interesting use case is to limit the scope of a lock_guard that's locking a mutex. This becomes simple with an initializer expression:

if (lock_guard<mutex> lg{ my_mutex }; condition) { 
    // interesting things happen here 
}

The lock_guard locks the mutex in its constructor and unlocks it in its destructor. Now the lock_guard will be automatically destroyed when it runs out of the scope of the if statement. In the past you would have had to delete it or enclose the whole if statement in an extra block of braces.

Another use case could be using a legacy interface that uses output parameters, like this one from SQLite:

if(
    sqlite3_stmt** stmt, 
    auto rc = sqlite3_prepare_v2(db, sql, -1, &_stmt,
        nullptr);
    !rc) {
          // do SQL things
} else {  // handle the error 
    // use the error code 
    return 0;
}

Here I can keep the statement handle and the error code localized to the scope of the if statement. Otherwise, I would need to manage those objects globally.

Using initializer expressions will help keep your code tight and uncluttered, more compact, and easier to read. Refactoring and managing your code will also become easier.

 

Use template argument deduction for simplicity and clarity

Template argument deduction occurs when the types of the arguments to a template function, or class template constructor (beginning with C++17), are clear enough to be understood by the compiler without the use of template arguments. There are certain rules to this feature, but it's mostly intuitive.

How to do it…

In general, template argument deduction happens automatically when you use a template with clearly compatible arguments. Let's consider some examples.

  • In a function template, argument deduction usually looks something like this:
    template<typename T>
    const char * f(const T a) {
        return typeid(T).name();
    }
    int main() {
        cout << format("T is {}\n", f(47));
        cout << format("T is {}\n", f(47L));
        cout << format("T is {}\n", f(47.0));
        cout << format("T is {}\n", f("47"));
        cout << format("T is {}\n", f("47"s));
    }

Output:

T is int
T is long
T is double
T is char const *
T is class std::basic_string<char...

Because the types are easily discernable there is no reason to specify a template parameter like f<int>(47) in the function call. The compiler can deduce the <int> type from the argument.

Note

The above output shows meaningful type names where most compilers will use shorthand, like i for int and PKc for const char *, and so on.

  • This works just as well for multiple template parameters:
    template<typename T1, typename T2>
    string f(const T1 a, const T2 b) {
        return format("{} {}", typeid(T1).name(), 
            typeid(T2).name());
    }
    int main() {
        cout << format("T1 T2: {}\n", f(47, 47L));
        cout << format("T1 T2: {}\n", f(47L, 47.0));
        cout << format("T1 T2: {}\n", f(47.0, "47"));
    }

Output:

T1 T2: int long
T1 T2: long double
T1 T2: double char const *

Here the compiler is deducing types for both T1 and T2.

  • Notice that the types must be compatible with the template. For example, you cannot take a reference from a literal:
    template<typename T>
    const char * f(const T& a) {
        return typeid(T).name();
    }
    int main() {
        int x{47};
        f(47);  // this will not compile 
        f(x);   // but this will 
    }
  • Beginning with C++17 you can also use template parameter deduction with classes. So now this will work:
    pair p(47, 47.0);     // deduces to pair<int, double>
    tuple t(9, 17, 2.5);  // deduces to tuple<int, int, double>

This eliminates the need for std::make_pair() and std::make_tuple() as you can now initialize these classes directly without the explicit template parameters. The std::make_* helper functions will remain available for backward compatibility.

How it works…

Let's define a class so we can see how this works:

template<typename T1, typename T2, typename T3>
class Thing {
    T1 v1{};
    T2 v2{};
    T3 v3{};
public:
    explicit Thing(T1 p1, T2 p2, T3 p3)
    : v1{p1}, v2{p2}, v3{p3} {}
    string print() {
        return format("{}, {}, {}\n",
            typeid(v1).name(),
            typeid(v2).name(),
            typeid(v3).name()
        );
    }
};

This is a template class with three types and three corresponding data members. It has a print() function, which returns a formatted string with the three type names.

Without template parameter deduction, I would have to instantiate an object of this type like this:

Things<int, double, string> thing1{1, 47.0, "three" }

Now I can do it like this:

Things thing1{1, 47.0, "three" }

This is both simpler and less error prone.

When I call the print() function on the thing1 object, I get this result:

cout << thing1.print();

Output:

int, double, char const *

Of course, your compiler may report something effectively similar.

Before C++17, template parameter deduction didn't apply to classes, so you needed a helper function, which may have looked like this:

template<typename T1, typename T2, typename T3>
Things<T1, T2, T3> make_things(T1 p1, T2 p2, T3 p3) {
    return Things<T1, T2, T3>(p1, p2, p3);
}
...
auto thing1(make_things(1, 47.0, "three"));
cout << thing1.print();

Output:

int, double, char const *

The STL includes a few of these helper functions, like make_pair() and make_tuple(), etc. These are now obsolescent, but will be maintained for compatibility with older code.

There's more…

Consider the case of a constructor with a parameter pack:

template <typename T>
class Sum {
    T v{};
public:
    template <typename... Ts>
    Sum(Ts&& ... values) : v{ (values + ...) } {}
    const T& value() const { return v; }
};

Notice the fold expression in the constructor (values + ...). This is a C++17 feature that applies an operator to all the members of a parameter pack. In this case, it initializes v to the sum of the parameter pack.

The constructor for this class accepts an arbitrary number of parameters, where each parameter may be a different class. For example, I could call it like this:

Sum s1 { 1u, 2.0, 3, 4.0f };  // unsigned, double, int, 
                              // float
Sum s2 { "abc"s, "def" };     // std::sring, c-string

This, of course, doesn't compile. The template argument deduction fails to find a common type for all those different parameters. We get an error message to the effect of:

cannot deduce template arguments for 'Sum'

We can fix this with a template deduction guide. A deduction guide is a helper pattern to assist the compiler with a complex deduction. Here's a guide for our constructor:

template <typename... Ts>
Sum(Ts&& ... ts) -> Sum<std::common_type_t<Ts...>>;

This tells the compiler to use the std::common_type_t trait, which attempts to find a common type for all the parameters in the pack. Now our argument deduction works and we can see what types it settled on:

Sum s1 { 1u, 2.0, 3, 4.0f };  // unsigned, double, int, 
                              // float
Sum s2 { "abc"s, "def" };     // std::sring, c-string
auto v1 = s1.value();
auto v2 = s2.value();
cout << format("s1 is {} {}, s2 is {} {}",
        typeid(v1).name(), v1, typeid(v2).name(), v2);

Output:

s1 is double 10, s2 is class std::string abcdef
 

Use if constexpr to simplify compile-time decisions

An if constexpr(condition) statement is used where code needs to be executed based on a compile-time condition. The condition may be any constexpr expression of type bool.

How to do it…

Consider the case where you have a template function that needs to operate differently depending upon the type of the template parameter.

template<typename T>
auto value_of(const T v) {
    if constexpr (std::is_pointer_v<T>) {
        return *v;  // dereference the pointer
    } else {
        return v;   // return the value
    }
}
int main() {
    int x{47};
    int* y{&x};
    cout << format("value is {}\n", value_of(x));  // value
    cout << format("value is {}\n", value_of(y));  
                                                // pointer
    return 0;
}

Output:

value is 47
value is 47

The type of the template parameter T is available at compile time. The constexpr if statement allows the code to easily distinguish between a pointer and a value.

How it works…

The constexpr if statement works like a normal if statement except it's evaluated at compile time. The runtime code will not contain any branch statements from a constexpr if statement. Consider our branch statement from above:

if constexpr (std::is_pointer_v<T>) {
    return *v;  // dereference the pointer
} else {
        return v;   // return the value
    }

The condition is_pointer_v<T> tests a template parameter, which is not available at runtime. The constexpr keyword tells the compiler that this if statement needs to evaluate at compile time, while the template parameter <T> is available.

This should make a lot of meta programming situations much easier. The if constexpr statement is available in C++17 and later.

About the Author
  • Bill Weinman

    Bill Weinman has been involved in technology since he built his first computer at age 16, in 1971. He’s been coding in C and C++ since the early 1970s. He’s written systems and applications for major clients, including NASA, Bank of America, Xerox, IBM, and the US Navy. Also an electronics engineer, he worked on the Voyager II spacecraft, audio amplifiers for SAE, and sound systems for Altec Lansing.

    Since the mid-1990s, Mr. Weinman has focused on writing and teaching. His books and courses cover HTML, SQL, CGI, Python, and of course, C and C++. An early contributor to online learning, his clear, concise writing has made his courses a popular feature on lynda and LinkedIn Learning.

    Browse publications by this author
C++20 STL Cookbook
Unlock this book and the full library FREE for 7 days
Start now