From reserve() to piecewise_construct

From dynamic container operations to compile-time constants, C++ offers many interesting techniques (as in this famous meme 🙂). In this article, we will look at several advanced initialization methods: from reserve() And emplace_back for containers, up to piecewise_construct And forward_as_tuple for tuples. With these techniques, we can reduce the number of temporary objects and create variables more efficiently.

Let’s get started!

Introduction

We will use the following class as the basis for our research. It is well suited for illustrating when its special member functions are called. This way we can keep track of all temporary objects.

struct MyType {
    MyType() { std::cout << "MyType default\n"; }
    explicit MyType(std::string str) : str_(std::move(str)) { 
        std::cout << std::format("MyType {}\n", str_); 
    }
    ~MyType() { 
        std::cout << std::format("~MyType {}\n", str_);  
    }
    MyType(const MyType& other) : str_(other.str_) { 
        std::cout << std::format("MyType copy {}\n", str_); 
    }
    MyType(MyType&& other) noexcept : str_(std::move(other.str_)) { 
        std::cout << std::format("MyType move {}\n", str_);  
    }
    MyType& operator=(const MyType& other) { 
        if (this != &other)
            str_ = other.str_;
        std::cout << std::format("MyType = {}\n", str_);  
        return *this;
    }
    MyType& operator=(MyType&& other) noexcept { 
        if (this != &other)
            str_ = std::move(other.str_);
        std::cout << std::format("MyType = move {}\n", str_);  
        return *this; 
    }
    std::string str_;
};

I borrowed this type from my other article: Moved or Not Moved – That Is the Question! – C++ Stories

All with preparations. Let’s start with a relatively simple but very important element:

reserve and emplace_back: effectively growing

Vectors in C++ are dynamic arrays that can grow in size as needed. However, each time a vector grows beyond its current capacity, it may require a reallocation of memory, which can be quite expensive. To optimize this process, we can use the method reserve() in combination with emplace_back.

Method reserve does not change the size of the vector, but ensures that enough memory is allocated for the vector to hold the specified number of elements. Pre-reserved space helps prevent multiple reallocation when adding elements to the vector.

Let’s look at an example that compares these methods:

#include <iostream>
#include <vector>
#include <string>
#include <format>

// ... [здесь мы определяем MyType] ...

int main() {    
    {
        std::cout << "push_back\n";
        std::vector<MyType> vec;
        vec.push_back(MyType("First"));
        std::cout << std::format("capacity: {}\n", vec.capacity());
        vec.push_back(MyType("Second"));
    }
    {
        std::cout << "no reserve() + emplace_\n";
        std::vector<MyType> vec;
        vec.emplace_back("First");
        std::cout << std::format("capacity: {}\n", vec.capacity());
        vec.emplace_back("Second");
    }
    {
        std::vector<MyType> vec;
        vec.reserve(2);  // Резервируем место для двух элементов
        vec.emplace_back("First");
        vec.emplace_back("Second");
    }
}

And we will get the following output:

--- push_back
MyType First
MyType move First
~MyType 
capacity: 1
MyType Second
MyType move Second
MyType move First
~MyType 
~MyType 
~MyType First
~MyType Second
--- emplace_back
MyType First
capacity: 1
MyType Second
MyType move First
~MyType 
~MyType First
~MyType Second
--- reserve() + emplace_
MyType First
MyType Second
~MyType First
~MyType Second

Run in @CompilerExplorer

As I said, in this example you can see a comparison of three pasting techniques:

In the first case, we have to pass temporary objects to push_back – they are moved to initialize the elements of the vector. But at the same time, reallocation occurs, since when adding a second element, the vector must increase its size.

Technique emplace_back() slightly better and easier to write as no temporary objects are created.

But the third option is the most efficient, since we can reserve space in advance and then just create elements as needed.

Using reserve and then emplace_back, we ensure that the vector does not have to reallocate memory as elements are added within the reserved space. This combination is a powerful way to optimize performance, especially when adding a large number of elements to a vector.

constinit: compile-time initializations in C++20

constinit is a powerful tool for providing constant initialization, especially for static or thread-local variables. Introduced in C++20, this keyword solves a longstanding C++ problem: the static initialization order fiasco. By ensuring that variables are initialized at compile time, constinit gives us a more predictable and safe initialization process.

At its core constinit ensures that the variable it qualifies will be initialized at compile time. This is especially useful for global and static variables because it avoids problems with dynamic initialization order.

Consider the following example:

#include <array>

// Инициализация на этапе компиляции
constexpr int compute(int v) { return v*v*v; }
constinit int global = compute(10);

// Это не будет работать:
// constinit int another = global;

int main() {
    // Но допускает изменения в дальнейшем...
    global = 100;

    // global не является константой!
    // std::array<int, global> arr;
}

In the code above, the global variable is initialized at compile time with the function compute. However, unlike const And constexpr, constinit does not make the variable immutable. This means that although its initial value is set at compile time, it can be changed at run time, as shown in the function main. Moreover, since the variable constinit is not constexprit cannot be used to initialize another constinitobject (for example, int another).

You can read more about this in my other articles: const vs constexpr vs consteval vs constinit in C++20 – C++ Stories And Solving Undefined Behavior in Factories with constint from C++20 – C++ Stories.

Lambda expressions and initialization

In C++14, lambda expressions have received a major update, with the ability to initialize new data members directly in the lambda expression’s capture list. This feature, known as capturing with an initializer or generalized lambda captureprovides more flexibility and precision when working with lambdas.

Traditionally, lambda expressions have been able to capture variables from their surrounding scope. C++14 introduced the ability to create and initialize new member variables directly in the capture list, making lambdas even more versatile.

Consider the following example:

#include <iostream>

int main() {
    int x = 30;
    int y = 12;
    const auto foo = [z = x + y]() { std::cout << z; };
    x = 0;
    y = 0;
    foo();
}

Conclusion:

42

In this example, we are creating a new variable zwhich is initialized by the sum x And y. This initialization happens at the moment the lambda is defined, not at the moment it is called. As a result, even if x And y will be changed after the lambda is defined, z will retain its original value.

To better demonstrate this feature, I’ll show you how a lambda is translated into a callable type:

struct _unnamedLambda {
    void operator()() const {
        std::cout << z;
    }
    int z;
} someInstance;

Essentially, the lambda becomes an instance of an unnamed struct with a method operator()() and data member z.

Capturing with an initializer is not limited to simple types. You can also capture links.

When can we use this technique? In at least two cases:

Consider the first scenario; here’s how to capture std::unique_ptr:

#include <iostream>
#include <memory>

int main(){
    std::unique_ptr<int> p(new int{10});
    const auto bar = [ptr=std::move(p)] {
        std::cout << "pointer in lambda: " << ptr.get() << '\n';
    };
    std::cout << "pointer in main(): " << p.get() << '\n';
    bar();
}

Previously, in C++11, a unique pointer could not be captured by value. We only had access to capture by link. Since C++14, we can move an object into a closure:

More about the use case can be optimization:

If you are capturing a variable and then evaluating some temporary object:

auto result = std::find_if(vs.begin(), vs.end(),
        [&prefix](const std::string& s) {
            return s == prefix + "bar"s; 
        }
    );

Why not evaluate it once and store it inside a lambda object:

result = std::find_if(vs.begin(), vs.end(), 
        [savedString = prefix + "bar"s](const std::string& s) { 
            return s == savedString; 
        }
    );

Thus, savedString is evaluated once, not every time the function is called.

make_unique_for_overwrite: C++20 memory initialization optimization

With the advent of smart pointers, we have tools that significantly reduce the risks associated with dynamic memory allocation. However, as with any tool, there is always room for improvement and optimization.

Using make_unique (or make_shared) to allocate arrays by default, the value of each element is initialized. This means that for built-in types, each element is set to zero, and for custom types, their default constructors are called. While this ensures that the memory is initialized to a known state, it comes at a cost, especially if the allocated memory is supposed to be overwritten immediately.

Consider the following:

auto ptr = std::make_unique<int[]>(1000); 

This line not only allocates memory for 1000 integers, but also initializes each of them to zero. If at the next stage this memory is filled with data from the file or the result of the request, then preliminary zeroing will be superfluous and useless.

To eliminate this inefficiency, C++20 introduced functions make_unique_for_overwrite And make_shared_for_overwrite. These functions allocate memory without initializing it with preliminary values, which makes them faster in cases where memory is expected to be overwritten directly.

auto ptr = std::make_unique_for_overwrite<int[]>(1000);

Functions _for_overwrite should only be used when the allocated memory is immediately overwritten with other data. If the memory is not immediately overwritten, it will contain undefined values, which can lead to undefined behavior when accessing it.

These new features can lead to noticeable performance improvements in memory-intensive applications such as data processing or game engines.

piecewise_construct and forward_as_tuple

And finally, we got to the fifth trick – direct initialization of pairs or tuples using multi-parameter constructors.

Here come to the rescue std::piecewise_construct And std::forward_as_tuple.

For example:

std::pair<MyType, MyType> p { "one", "two" };

The above code creates a pair without any extra temporary objects MyType.

But what if we have an additional constructor that takes two arguments:

MyType(std::string str, int a)

In this case try:

std::pair<MyType, MyType> p { "one", 1, "two", 2 };

fails because this call is ambiguous to the compiler.

In such cases, it comes to the rescue std::piecewise_construct. This is the tag that indicates std::pair complete the construction piecemeal. Combined with std::forward_as_tuplewhich creates a tuple of lvalue or rvalue references, we can pass multiple arguments to the pair’s element constructors at once.

{
    std::cout << "regular: \n";
    std::pair<MyType, MyType> p { MyType{"one", 1}, MyType{"two", 2}};
}
{
    std::cout << "piecewise + forward: \n";
    std::pair<MyType, MyType>p2(std::piecewise_construct,
               std::forward_as_tuple("one", 1),
               std::forward_as_tuple("two", 2));
}

If we run this program, we will see the following output:

regular: 
MyType one, 1
MyType two, 2
MyType move one
MyType move two
~MyType 
~MyType 
~MyType two
~MyType one
piecewise + forward: 
MyType one, 1
MyType two, 2
~MyType two
~MyType one

Run in @CompilerExplorer

As you can see, here we got two temporary objects created in the usual way. With option piecewise we can pass parameters to the elements of the pair directly.

std::piecewise_construct especially useful when using containers like std::map And std::unordered_mapstoring key-value pairs (std::pair). Utility std::piecewise_construct becomes obvious when it is necessary to insert elements into these containers, and the key or value (or both) has multi-parameter constructors or is non-copyable.

See example below:

#include <string>
#include <map>

struct Key {
    Key(int a, int b) : sum(a + b) {}
    int sum;
    bool operator<(const Key& other) const { 
        return sum < other.sum; 
    }
};

struct Value {
    Value(const std::string& s, double d) : name(s), data(d) {}
    std::string name;
    double data;
};

int main() {
    std::map<Key, Value> myMap;

    // не компилируется: неоднозначность
    // myMap.emplace(3, 4, "example", 42.0);

    // а это работает:
    myMap.emplace(
        std::piecewise_construct,
        std::forward_as_tuple(3, 4),  
        std::forward_as_tuple("example", 42.0) 
    );
}

Run in @Compiler Explorer

Conclusion

This article discusses various techniques for initializing the C++ language. We dived into complex modern C++ features, including reserve efficiency and emplace_backaccuracy constinit and the flexibility of lambda initialization. In addition, we considered the subtle possibilities of the functions piecewise And forward_as_tuple. These advanced techniques demonstrate the constant evolution and power of the C++ language, enabling developers to write more expressive, efficient, and versatile code.

Someone may consider this an unnecessary complication of the language, but I have a different point of view. For example, the function emplace(), which can improve the insertion of elements into the container. However, if the optimization is not particularly needed, then temporary objects can be passed instead using simpler code. The C++ language provides a simple approach, yet allows users to delve into the internals to create optimal code, working “under the hood” if necessary.

Dear reader!

The list of advanced techniques given here is not meant to be exhaustive. I would be interested to know about other useful tricks for initializing objects in a more efficient and at the same time complex way. Please feel free to share your thoughts on this topic in the comments.


Translation of the article prepared for future students course “C++ Developer. Professional”.

And we invite beginner developers on the pluses to an open lesson, where we will get acquainted with one of the most popular IDEs – Visual Studio Code. At this meeting, we will set up VS Code from scratch, build and debug a small C++ project, and get acquainted with tools from the C++ ecosystem. You can sign up on the C++ specialization page.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *