Пять продвинутых техник инициализации в C++: От reserve() до piecewise_construct

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

От операций с динамическими контейнерами до констант времени компиляции — C++ предлагает много интересных техник (как в этом знаменитом меме :)). В этой статье мы рассмотрим несколько продвинутых методов инициализации: от reserve() и emplace_back для контейнеров, до piecewise_construct и forward_as_tuple для кортежей. Благодаря этим техникам мы можем уменьшить количество временных объектов и более эффективно создавать переменные.

Давайте приступим!

Введение

В качестве основы для наших изысканий мы будем использовать следующий класс. Он хорошо подходит для иллюстрации того, когда вызываются его специальные функции-члены. Таким образом, мы сможем отследить все временные объекты.

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_;
};

   Этот тип я позаимствовал из другой своей статьи: Moved or Not Moved - That Is the Question! - C++ Stories

С приготовлениями все. Начнем с относительно простого, но очень важного элемента:

reserve и emplace_back: эффективно растущие      

Векторы в C++ — это динамические массивы, которые по мере необходимости могут увеличиваться в размерах. Однако каждый раз, когда вектор вырастает за пределы своей текущей емкости, это может потребовать перераспределение памяти, что может быть весьма дорогостоящей операцией. Для оптимизации этого процесса мы можем использовать метод reserve() в сочетании с emplace_back.

Метод reserve не изменяет размер вектора, но гарантирует, что под вектор будет выделено достаточно памяти для хранения заданного количества элементов. Заранее зарезервированное место позволяет предотвратить многократную реаллокацию при добавлении элементов в вектор.

Давайте рассмотрим пример, в котором сравниваются эти методы:

#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");
    }
}

И мы получим следующий вывод:

--- 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

Запустить в @Compiler Explorer

Как я уже говорил, в этом примере можно увидеть сравнение трех техник вставки:

  • только push_back()

  • только emplace_back()

  • reserve() с emplace_back

В первом случае нам приходится передавать временные объекты в push_back – они перемещаются для инициализации элементов вектора. Но при этом происходит реаллокация, так как при добавлении второго элемента вектор должен увеличить свой размер.

Техника emplace_back() немного лучше и проще в написании, так как не создается никаких временных объектов.

Но наиболее эффективен третий вариант, поскольку мы можем заранее зарезервировать место, а затем просто создавать элементы по мере необходимости.

Используя reserve, а затем emplace_back, мы гарантируем, что вектору не придется перераспределять память по мере добавления элементов в пределах зарезервированного объема. Эта комбинация является мощным способом оптимизации производительности, особенно при добавлении большого количества элементов в вектор.

constinit: инициализации во время компиляции в C++20      

constinit — это мощный инструмент для обеспечения константной инициализации, особенно для статических или локальных переменных потока. Введенное в C++20, это ключевое слово решает давнюю проблему C++: фиаско с порядком статической инициализации. Обеспечивая инициализацию переменных во время компиляции, constinit дает нам более предсказуемый и безопасный процесс инициализации.

По своей сути constinit гарантирует, что переменная, которую он квалифицирует, будет инициализирована во время компиляции. Это особенно полезно для глобальных и статических переменных, поскольку позволяет избежать проблем с порядком динамической инициализации.

Рассмотрим следующий пример:

#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;
}

В приведенном выше коде глобальная переменная инициализируется во время компиляции с помощью функции compute. Однако, в отличие от const и constexpr, constinit не делает переменную иммутабельной. Это означает, что, хотя ее начальное значение задается во время компиляции, оно может быть изменено во время выполнения, как это показано в функции main. Кроме того, поскольку переменная constinit не является constexpr, ее нельзя использовать для инициализации другого constinit-объекта (например, int another).

Подробнее об этом вы можете почитать в других моих статьях: const vs constexpr vs consteval vs constinit in C++20 - C++ Stories и Solving Undefined Behavior in Factories with constinit from C++20 - C++ Stories.

Лямбда-выражения и инициализация      

В C++14 лямбда-выражения претерпели существенное обновление: появилась возможность инициализировать новые члены данных непосредственно в списке захвата лямбда-выражения. Эта фича, известная как захват с инициализатором или обобщенный лямбда-захват, обеспечивает большую гибкость и точность при работе с лямбдами.

Традиционно лямбда-выражения могли захватывать переменные из окружающей их области видимости. В C++14 появилась возможность создавать и инициализировать новые переменные-члены непосредственно в списке захвата, что сделало лямбды еще более универсальными.

Рассмотрим следующий пример:

#include <iostream>

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

Вывод:

42

В этом примере мы создаем новую переменную z, которая инициализируется суммой x и y. Эта инициализация происходит в момент определения лямбды, а не в момент ее вызова. В результате, даже если x и y будут изменены после определения лямбды, z сохранит свое изначальное значение.

Чтобы лучше продемонстрировать эту особенность я покажу вам, как лямбда переводится в вызываемый тип:

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

По сути, лямбда становится экземпляром неименованной структуры с методом operator()() и членом данных z.

Захват с инициализатором не ограничивается простыми типами. Можно также захватывать ссылки.

Когда нам может пригодиться этот прием? По крайней мере, в двух случаях:

  • захват по значению move-able-only типов 

  • оптимизация

Рассмотрим первый сценарий; вот как можно захватить 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();
}

Раньше в C++11 уникальный указатель нельзя было захватить по значению. Нам был доступен только захват по ссылке. Начиная с C++14, мы можем переместить в замыкание объект:

Еще о вариантом использования может быть оптимизация:

Если вы захватываете переменную, а затем вычисляете некоторый временный объект:

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

Почему бы не вычислить его один раз и не хранить внутри лямбда-объекта:

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

Таким образом, savedString вычисляется один раз, а не при каждом вызове функции.

make_unique_for_overwrite: оптимизация инициализации памяти в C++20      

С появлением умных указателей мы получили инструменты, существенно снижающие риски, связанные с динамическим выделением памяти. Однако, как и в любом инструменте, здесь всегда есть место для совершенствования и оптимизации.

При использовании make_unique (или make_shared) для аллокации массивов по умолчанию происходит инициализация значения каждого элемента. Это означает, что для встроенных типов каждый элемент устанавливается в ноль, а для пользовательских – вызываются их конструкторы по умолчанию. Хотя это гарантирует, что память инициализируется в известное состояние, это выливается в накладные расходы, особенно если выделенную память предполагается перезаписать немедленно.

Рассмотрим следующее:

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

Эта строка не только выделяет память под 1000 целых чисел, но и инициализирует каждое из них нулем. Если на следующем этапе эта память будет заполнена данными из файла или результата запроса, то предварительное обнуление будет лишним и бесполезным.

Для устранения этой неэффективности в C++20 появились функции make_unique_for_overwrite и make_shared_for_overwrite. Эти функции выделяют память, не инициализируя ее предварительными значениями, что делает их более быстрыми в тех случаях, когда предполагается непосредственная перезапись памяти.

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

Функции _for_overwrite следует использовать только, когда выделенная память сразу же перезаписывается другими данными. Если память не будет сразу же перезаписана, то она будет содержать неопределенные значения, что может привести к неопределенному поведению при обращении к ней.

Эти новые функции могут привести к заметному повышению производительности приложений, предполагающих большие объемы операций с памятью, как, например, средства обработки данных или игровые движки.

piecewise_construct и forward_as_tuple     

И, наконец, мы добрались до пятого приема — прямой инициализации пар или кортежей с помощью многопараметрических конструкторов.

Здесь на помощь приходят std::piecewise_construct и std::forward_as_tuple.

Например:

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

Приведенный выше код создает пару без каких-либо лишних временных объектов MyType.

Но как быть в случае, когда у нас имеется дополнительный конструктор, принимающий два аргумента:

MyType(std::string str, int a)

В этом случае попытка:

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

не удается, так как для компилятора этот вызов неоднозначен.

В таких случаях на помощь приходит std::piecewise_construct. Это тег, который указывает std::pair выполнить конструкцию по частям. В сочетании со std::forward_as_tuple, который создает кортеж из lvalue или rvalue ссылок, мы можем передавать конструкторам элементов пары сразу несколько аргументов.

{
    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));
}

Если мы запустим эту программу, то увидим следующий вывод:

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

Запустить в @Compiler Explorer

Как видно, здесь мы получили два временных объекта, созданных в рамках обычного способа. С помощью опции piecewise мы можем передавать параметры элементам пары напрямую.

std::piecewise_construct особенно полезен при использовании таких контейнеров, как std::map и std::unordered_map, хранящих пары ключ-значение (std::pair). Полезность std::piecewise_construct становится очевидной, когда необходимо вставить элементы в эти контейнеры, а ключ или значение (или оба) имеют многопараметрические конструкторы или являются некопируемыми.

Смотрите пример ниже:

#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) 
    );
}

Запустить в @Compiler Explorer

Заключение  

В этой статье рассматриваются различные приемы инициализации языка C++. Мы погружались в сложные современные возможности C++, включая эффективность reserve и emplace_back, точность constinit и гибкость лямбда-инициализации. Кроме того, мы рассматривали тонкие возможности функций piecewise и forward_as_tuple. Эти продвинутые техники демонстрируют постоянную эволюцию и силу языка C++, давая разработчикам возможность писать более выразительный, эффективный и универсальный код.

Кто-то может посчитать это ненужным усложнением языка, но я придерживаюсь другой точки зрения. Например, функция emplace(), которая может улучшить вставку элементов в контейнер. Однако если оптимизация особо не нужна, то вместо нее можно передавать временные объекты, используя более простой код. Язык C++ обеспечивает простой подход, но при этом позволяет пользователям углубляться во внутреннее устройство для создания оптимального кода, при необходимости работая "под капотом".

Дорогой читатель!

Приведенный здесь список продвинутых техник не претендует быть исчерпывающим. Мне было бы интересно узнать о других полезных приемах инициализации объектов более эффективным и в то же время сложным способом. Пожалуйста, не стесняйтесь поделиться своими соображениями на эту тему в комментариях.


Перевод статьи подготовлен для будущих студентов курса "C++ Developer. Professional".

А начинающих разработчиков на плюсах приглашаем на открытый урок, на котором познакомимся с одной из самых популярных IDE — Visual Studio Code. На этой встрече с нуля настроим VS Code, соберем и отладим небольшой C++ проект, а также познакомимся с инструментами из экосистемы C++. Записаться можно на странице специализации C++.

Источник: https://habr.com/ru/companies/otus/articles/757648/


Интересные статьи

Интересные статьи

В свое время невероятное количество многоотраслевых разработчиков с завидным успехом, в качестве такого аналогового прибора, как логарифмическая линейка претворили в жизн...
Краткий обзорСегодня даже консервативные организации начинают внедрять Kubernetes. Платформа предлагает очевидные преимущества, — удобное развертывание, высокая скор...
Вчера мы поговорили о трех парах «напольников» от Arslab, DALI и Monitor Audio, а сегодня — обсудим беспроводную портативку, полочники, проигрыватели винила и подборки сц...
Недавно я провёл 600-е собеседование на interviewing.io (IIO). Хотелось бы поделиться опытом, рассказать, как я подхожу к интервью, и пролить свет на типичные проблемы у кандидатов. Кажды...
Не так давно, удалось мне обзавестись известными датчиками температуры и влажности от Xiaomi. Эти датчики заслуженно приобрели широкую известность, так как при своей достаточно низкой цене, доста...