Главная Как пользоваться Move semantic на примерах
Пост
Отмена

Как пользоваться Move semantic на примерах

В 2022 году всё ещё существует много компаний, для которых C++11 считается новым стандартом языка. С людьми лучше, множество конференций, статей, книжек, гайдов на ютубе танцев в тиктоке неплохо объяснили идеи и рапространили знания. Для новоприбывших же, возможно, дерево типов ссылок в C++, разные приколы и эффекты могут повергнуть в уныние. Потому, здесь я покажу без особых теоретических запилов как в 80% случаев надо пользоваться семантикой перемещения в версиях языка C++11 и новее. Если вы душный дед из твитора и испытываете желание самоудовлетвориться самоутвердиться, что теория лёгкая, и вы её за две минуты освоили в третьем классе на батином инженерном калькуляторе, то вы ошиблись дверью, хабрахабр дальше по коридору. Остальным велкам.

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

Типы конструкторов и подготовка

Здесь нам понадобятся два типа ссылок: обычная ссылка lvalue, которая может стоять слева (и справа) от присваивания (а значит имеет имя), и ссылка rvalue, которая может находиться только справа. Значениями типа rvalue являются результаты вызова функций, литералы.

Начнём, пожалуй с того, что напишем пару килограммов бойлерплейта (нудного, объемного, жизненно необходимого, но весьма скучного кода). Будем использовать для экспериментов вот такой класс:

class ClassName
{
public:
    ClassName()
    {
        std::cout << "Default ctor\n";
    }

    ~ClassName()
    {
        std::cout << "Default dtor\n";
    }

    ClassName(int const max_letter_number)
    : v(max_letter_number, "aaa"), s("Vector of triplets")
    {
        std::cout << "User defined ctor\n";
    }

    ClassName(ClassName const&other)
    : v(other.v), s(other.s)
    {
        std::cout <<"Copy ctor\n";
        std::this_thread::sleep_for(std::chrono::seconds(5));
        std::cout <<"Copy ctor done\n";
    }

    ClassName& operator=(ClassName const&other)
    {
        std::cout << "Copy assignment\n";
        std::this_thread::sleep_for(std::chrono::seconds(5));
        v = other.v;
        s = other.s;
        std::cout << "Copy assignment done\n";
        return *this;
    }

    ClassName(ClassName &&other)
    : v(std::move(other.v)), s(std::move(other.s))
    {
        std::cout << "Move ctor\n";
    }

    ClassName& operator=(ClassName &&other)
    {
        std::cout << "Move assignment\n";
        v = std::move(other.v);
        s = std::move(other.s);
        return *this;
    }

    std::size_t get_size() const
    {
        return v.size();
    }

    void add_two()
    {
        v.push_back("ddd");
        v.push_back("fff");
        return;
    }

private:
    std::vector<std::string> v;
    std::string s;
};

Что произошло? Не впадая в мерзкое упрощение с целыми числами, мы завели класс со строчкой и вектором строчек внутри. Для наглядности. Явно реализовали конструктор по умолчанию (без аргументов), пользовательский конструктор (с аргументами) и деструктор. Явно написали копирующие и перемещающие операции. Опять же, чтобы не вспоминать сейчас правило трёх пяти, и что там когда компилятор нам генерирует. Про это потом.

Перемещающие операции отличаются синтаксически лишь двойным амперсандом (так обозначается rvalue ссыль) в списке параметров и отсутствием const. И вызовами std::move(), которая во время компиляции приводит тип аргумента как раз к нужной нам rvalue. Для встроенных типов в любом случае произойдёт копирование, для них недорогостоящее. Для обьектов, которые не содержат перемещающего конструктора, попытка перемещение так же будет заменена копированием. Так же есть шансы нарваться на копирование, если неправильно написаны и перемещающие операции. Перемещающие конструктор и оператор присваивания принимают аргумент rvalue, то есть с &&, но в теле функции оно становится lvalue, так как мы уже дали имя формальному параметру. Короче, для него в теле надо снова вызывать std::move(), если хочется его куда-нибудь переместить или нужет rvalue от него.

Как передать и принять аргументы

В объявлении функции f(ClassName a, ClassName const b, ClassName &c, ClassName const& d, ClassName &&e); первые два аргумента принимаются по значению, с вызовами копирующих конструкторов для T, вторые два lvalue ссылки, и последний - rvalue ссылка. С lvalue ссылками всё тут понятно, остаётся только внимательно проследить за временем жизни ссылок, чтобы ненароком кто-то другой (другой поток, обработчик исключения или системного сигнала) не удалил оригинал и у нас не образовалась висячая ссылка.

Передача по значению, приём по значению

Рассмотрим первый и последний случаи. Приём по значению, вызов возможно дорогих копирующих конструкторов, долго.

void foo(ClassName arg)
{
    std::cout << "foo\n";
    arg.add_two();
    std::cout << arg.get_size() << "\n";
    return;
}
...
// Вызывающий код
ClassName instance(3);
foo(instance1);
std::cout << instance1.get_size() << "\n";

// Вывод такой
// Copy ctor
// foo
// 5
// 3

Передача rvalue, приём по значению

void foo(ClassName arg)
{
    std::cout << "foo\n";
    arg.add_two();
    std::cout << arg.get_size() << "\n";
    return;
}
...
// Вызывающий код
ClassName instance(3);
foo(std::move(instance1));
std::cout << instance1.get_size() << "\n"; // так делать нельзя, это UB

// Вывод теперь такой
// User defined ctor
// Move ctor
// foo
// 5
// 0 - у разных компиляторов может быть по разному

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

Приём rvalue

В случае, когда же у нас в списке параметров стоит ClassName &&arg, мы передаём ссылку. Сам обьект всё ещё остётся принадлежать вызывающему коду. И точно так же как и с lvalue ссылками можно нарваться на повисание этой ссылки. Например в одной функции обьект будет удален, или из него будет выполнено перемещение, как в предыдущем разделе. И всё, привет UB в другой функции.

void foo(ClassName &&arg)
{
    std::cout << "foo\n";
    auto local_arg = std::move(arg); // воруем
    std::cout << local_arg.get_size() << "\n";
    return;
}
...
// Вызывающий код
ClassName instance(3);
foo(std::move(instance1));
std::cout << instance1.get_size() << "\n"; // так делать нельзя, это UB

Всё будет норм, если мы просто попользуемся ссылкой или присвоим её локальной переменной без использования std::move – там вызовется копирующий конструктор, и ссылка останется действительной.

Что ещё

Здесь в примерах нет никакого выведения типов и, соответственно perfect forwarding’а. Так же предполагается, что запускается всего один поток, с многопоточностью там больше приседаний. Про это потом.

Код примеров можно посмотреть тут Ещё можно глянуть вот этот доклад с C++ Russia 2019

Этот пост размещен под лицензией CC BY 4.0 автором.

Популярные теги