Углубляемся в C++: move семантика и rvalue

В этой статье разобраны основные преимущества и нюансы move семантики в C++11 и старше. Всё описанное в этой статье было проверено на компиляторе Clang 6.0.1 с библиотекой libc++ на x86, GNU/Linux.

Введение в move

Move семантика позволяет переместить объект вместо его копирования для увеличения производительности. Проще всего понять семантику перемещения на примере. В качестве этого примера будет использоваться класс String:

class String
{
public:
    explicit String(const char *const c_string)
    {
        std::cout << "String(const char *const c_string)\n";
        size = strlen(c_string) + 1;
        this->c_string = new char[size];
        strcpy(this->c_string, c_string);
    }

    String(const String& other)
    {
        std::cout << "String(const String& other)\n";
        c_string = new char[other.size];
        strcpy(c_string, other.c_string);
        size = other.size;
    }

    ~String() noexcept
    {
        std::cout << "~String()\n";
        delete[] c_string; // delete на nullptr не даёт никакого эффекта
    }

private:
    char *c_string;
    size_t size;
};

При передаче объекта этого класса в функцию, принимающую его по значению, — назовём её by_value()— произойдёт следующее:

auto string = String("Hello, C++11"); 
by_value(string); // копирование string в by_value()

stdout:
String(const char *const c_string) // new[]
String(const String& other) // new[]
~String() // delete[]
~String() // delete[]

Получается 4 обращения к аллокатору, что достаточно накладно. Но если объект String больше не понадобится, а функцию by_value() менять нельзя, то можно переместить объект, а не копировать. Для этого необходимо написать конструктор перемещения для класса String:

String(String &&other) noexcept
{
    std::cout << "String(String&& other)\n";
    c_string = other.c_string;
    size = other.size;
    other.c_string = nullptr;
    other.size = 0;
}

Параметр конструктора перемещения other, во-первых, неконстантный, т.к. конструктор его изменяет; во-вторых, является rvalue-ссылкой (&&), а не lvalue-ссылкой (&). Об их отличиях будет сказано далее. Сам конструктор переносит Си-строку с other на this, делая other пустым.

Конструктор перемещения в общем случае не медленнее, а зачастую даже быстрее конструктора копирования, но ничего не мешает программисту поместить sleep(10'000) в конструктор перемещения.

Для вызова конструктора перемещения вместо конструктора копирования можно использовать std::move(). Теперь пример выглядит следующим образом:

auto string = String("Hello, C++11");
by_value(std::move(string)); // перемещение string в by_value(), string теперь пустая

stdout:
String(const char *const c_string) // new[]
String(String&& other) // благодаря замене на конструктор перемещения, пропал new[]
~String() // delete[]
~String() // delete[] на nullptr

Количество обращений к аллокатору уменьшилось вдвое!

rvalue и lvalue

Основное отличие rvalue от lvalue в том, что объекты rvalue могут быть перемещены, тогда как объекты lvalue всегда копируются.

Это «могут быть» лучше всего демонстрируют следующие два примера:

class TextView
{
public:
    explicit TextView(const String string)
            : text(std::move(string)) // stdout: String(const String& other)
    {}

private:
    String text;
};

Этот код работает не так, как от него ожидается. std::move() всё ещё конвертирует lvalue в rvalue, но конвертация сохраняет все модификаторы, в том числе и const. Затем компилятор выбирает среди двух конструкторов класса String самый подходящий. Из-за того что компилятор не может отправить const rlvaue туда, где ожидается non-const rvalue, он выбирает конструктор копирования, и const rvalue конвертируется обратно в const lvalue. Вывод: следите за модификаторами объектов, т.к. они учитываются при выборе одной из перегрузок функции.

Второй пример демонстрирует правило: аргументы и результат функции могут быть как rvalue, так и lvalue, но параметры функций могут быть только lvalue. Аргумент — это то, что передаётся в функцию. Он инициализирует параметр, который доступен непосредственно внутри функции.

void f(String&& string)
{
    g(string); // Clang: no known conversion from 'String' to 'String &&' for 1st argument
}

void g(String&& string) {}

Хотя параметр string функции f() и имеет тип rvalue-ссылки, он является lvalue и требует явной конвертации в rvalue перед передачей в функцию g(). Этим и занимается std::move().

Универсальные ссылки

Универсальные ссылки могут быть как rvalue-, так и lvalue-ссылкой в зависимости от аргументов или результата функции. Используются в шаблонах и в auto&&:

template <class T = String> 
void template_func(T&& string)
{
    by_value(std::forward<T>(string));
}

// template_func() принимает любую Xvalue
template_func(string); // String(const String& other)
template_func(std::move(string)); String(String&& other)

Компилятор на основе этого шаблона генерирует 2 функции, одна из которых принимает lvalue, другая — rvalue, если они будут использоваться. Если программист хочет использовать перемещение для rvalue-ссылки и простое копирование для lvalue-ссылки, то он может использовать std::forward(), который приводит свой аргумент к rvalue только тогда, когда его тип является rvalue-ссылкой. std::forward() требует явного указания шаблонного параметра даже с автоматическим выводом шаблонов в С++17.

Универсальная ссылка обязана быть шаблонным параметром (отсюда такое странное определение шаблона функции в примере) в формате T&&. Например, std::vector<T>&& уже не универсальная, а rvalue-ссылка.

Свёртывание ссылок

На самом деле разницы между rvalue-ссылкой и универсальной ссылкой нет. Универсальная ссылка — лишь удобная абстракция над rvalue-ссылкой, которой многие пользуются. Но как тогда rvalue-ссылка превращается в lvalue-ссылку при lvalue аргументе? Всё дело в свёртывании ссылок.

При вызове template_func(string) компилятор генерирует следующий заголовок функции:

void template_func(String& && string);

И получается ссылка на ссылку! Вручную так сделать нельзя, но шаблоны могут. Далее компилятор свёртывает ссылку на ссылку. Свертывание производится по следующему правилу: результатом свертывания будет rvalue-ссылка только тогда, когда обе ссылки являются rvalue-ссылками.

Именно из-за этого безобразия проще использовать абстракцию универсальных ссылок.

Подробнее о move семантике и rvalue можно узнать из книги С. Мейерса «Эффективный и современный С++: 42 рекомендации по использованию С++ 11 и С++14»  ISBN: 978-5-8459-2000-3.

Copy/move elision

Copy/move elision — это оптимизация, при которой компилятор может убрать некоторые вызовы конструктора копирования и деструктора, но в данный момент только при возврате объекта из функции, и только если тип возвращаемого объекта полностью совпадает с типом функции.

Поэтому при возврате из функции использование std::move() может снизить производительность, ограничив компилятор в Copy elision оптимизации, ведь отсутствие конструктора быстрее, чем конструктор перемещения.

String non_copy_elision()
{
    return std::move(String("")); 
}

Здесь std::move() только замедляет код, добавляя лишний вызов String(String&& other) и ~String().

В C++20 copy/move elision может быть расширен, благодаря чему в некоторых случаях использование std::move() также снизит производительность. Подробнее о расширении copy/move elision можно узнать из видеозаписи со встречи рабочей группы по стандартизации С++ в московском офисе Яндекса.

Иван Борисов