🛠 Основы move semantics в C++

В этой статье мы поговорим о том, что такое move semantics, зачем и когда она нужна, и как при помощи этого механизма оптимизировать программы на C++.

Что нужно знать перед прочтением этой статьи?

Предполагается, что читатель знаком с концепцией ссылок в C++, классов, конструкторов, конструкторов копирования, переопределённых операторов и операторов копирования, а также правилом трёх.

Примечание
Move semantics была добавлена в C++ 11, следовательно, при использовании слишком старого компилятора данная фича будет недоступна.

Что такое rvalue и lvalue

Каждое выражение в C++ характеризуется двумя свойствами: типом и категорией значения (value category [1]). В контексте разбора move semantics нас интересует только последнее. Полное описание категорий значений – тема для отдельной статьи, однако мы приведём необходимые сведения о каждой из существующих категорий значений.

Стандарт языка определяет три основные категории значений и ещё две составные, которые определяются на основе первых трёх.

Базовыми категориями значений являются lvalue, prvalue и xvalue:

  • lvalue [2] (от left-hand value – значение слева от равно) – фактически всё, чему может быть присвоено значение, например, переменная, результат разыменовывания указателя, ссылка.
  • prvalue [3] (от pure rvalue) – выражение, которое непосредственно инициализирует объект или описывает операнд, например, результат вызова функции, не являющийся ссылкой, результат постфиксных инкремента или декремента, результат арифметической операции.
  • xvalue [4] (от expiring value) – объекты, которые близки к концу времени жизни (lifetime [5]). Фактически xvalue – это анонимные ссылки на rvalue (о ссылках на rvalue – чуть позже), например, результаты вызова функций, возвращающих ссылки на rvalue.

Определив три основные категории значений, можно определить две оставшиеся (составные) – glvalue и rvalue:

  • glvalue [6] (от generalized lvalue) – либо lvalue, либо xvalue.
  • rvalue [7] (от right-hand value – значение справа от равно) – либо prvalue, либо xvalue.

Для ясности предлагаем взглянуть на диаграмму Венна:

Примечание
lvalue не обязательно всегда находится слева от знака равно, а rvalue – справа от него. Так было до введения move semantics в C++ 11.

До C++ 11 мы имели лишь lvalue и rvalue, а после – rvalue разделили на два вида: xvalue и prvalue, в то время как совокупность xvalue и lvalue стали называть glvalue.

Грубо говоря, lvalue – всё, чему может быть явно присвоено значение. rvalue – это временные объекты или значения, не связанные ни с какими объектами; что-то витающее в воздухе и ни за чем не закреплённое.

Ссылки на rvalue

Оставив самое сложное позади, поговорим о более близких к практике вещах, о ссылках на rvalue.

При выполнении программы на C++ постоянно создаются и уничтожаются различного рода временные объекты (rvalue). До C++ 11 мы не имели возможности сохранить эти объекты для будущего использования, потому что не могли ссылаться на них (вернее, могли, но используя только константные ссылки, а значит, лишаясь возможности изменения).

С приходом C++ 11 всё изменилось: появилась возможность ссылаться на rvalue (и изменять rvalue через эти ссылки) так же, как мы до этого ссылались на lvalue (кстати говоря, то, что в C++ мы обычно называем просто ссылками, является на самом деле ссылками на lvalue). Время для примера:

Листинг 1
#include <iostream>

//Подопытный класс
class X {
public:
    void setA(double a) {
        //Какой-то сеттер
    }
};

X someFunctionReturningX() {
    X x;
    return x;
}

int main() {
    // X& xLvalueRef = someFunctionReturningX(); //Не скомпилируется - нельзя привязать rvalue к ссылке на lvalue
    const X& xConstLvalueRef = someFunctionReturningX();
    //xConstLvalueRef.setA(0); //Не скомпилируется
    X&& xRvalueRef = someFunctionReturningX(); //Привязывание временного объекта к ссылке на rvalue - объект можно менять
    xRvalueRef.setA(0);
}
  • 1-я строка main, будь она раскомментирована, не скомпилировалась бы, т.к. Стандарт запрещает привязывать временные объекты (rvalue) к ссылкам на lvalue. Однако он разрешает привязывать rvalue к константным ссылкам на lvalue, что и происходит во 2-й строке. Но с константными ссылками есть проблема: они константные! Мы не можем ничего сделать с привязанным rvalue, используя такую ссылку, что показывает 3-я строка.
  • 4-я строка начинается новым синтаксисом – двумя амперсандами (&&), обозначающими объявление ссылки на rvalue [8]. Далее к этой ссылке привязывается rvalue, которое, как видно из 5-й строки, мы можем изменять.
Примечание
Время жизни rvalue, привязанного к ссылке на rvalue, расширяется до времени жизни этой ссылки.

Важно понимать, что сама ссылка на rvalue является lvalue.

Это всё очень хорошо, скажете вы, но как это поможет мне оптимизировать мои программы? Об этом – ниже.

Что такое move semantics и когда она имеет место

Добавим в наш класс X конструктор по умолчанию, конструктор и оператор копирования, а также объявление указателя на int.

Листинг 2
X() {
    resource = new int[100];
}

X(const X& x) {
    for (int i = 0; i < 100; ++i)
        resource[i] = x.resource[i];
}

X& operator=(const X& x) {
    X copy(x);
    std::swap(resource, copy.resource);

    return *this;
}

~X() {
    delete[] resource;
}

private:
    int* resource = nullptr;

resource здесь – это какие-то данные, которые, с точки зрения производительности, тяжело и долго копируются и лишнего копирования которых стоит избегать.

Заменим main из листинга 1 на следующий:

Листинг 3
int main() {
    X x;
    x = someFunctionReturningX(); //Вызов оператора копирования, внутри которого создаётся копия временного объекта
}
Примечание
Предположим, что листинг 3 компилируется, как C++03, в котором move semantics ещё не существовало.

Заметили, да? Мы копируем содержимое временного объекта, в то время как копирования фактически можно избежать, просто забрав (переместив) ресурс из временного объекта, т.к. этот объект всё равно очень скоро (после выхода из конструктора копирования) будет уничтожен и никто не пострадает, если его содержимое станет пустым (или не пустым, но невалидным). Это и есть move semantics.

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

Это всё звучит привлекательно, но как это реализовать? Очень просто, для этого нам понадобятся…

Конструктор и оператор перемещения

С++ 11 дал нам два инструмента для реализации move semantics в пользовательских классах – конструктор перемещения и оператор перемещения. Это своего рода аналоги конструктора и оператора копирования, но предназначенные не для копирования, а для перемещения. Добавим их в наш класс X:

Листинг 4
X(X&& x) noexcept {
    std::swap(resource, copy.resource);
}

X& operator=(X&& x) noexcept {
    std::swap(resource, copy.resource);

    return *this;
}

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

Теперь при использовании компилятора, поддерживающего C++ 11, код из листинга 3 больше не будет вызывать оператор копирования, а вместо него будет вызывать оператор перемещения. Почему? Потому что в данном случае справа от знака равно находится rvalue, а конструктор и оператор копирования предназначены для работы именно с rvalue.

Резюмируя последние четыре раздела статьи:

  • C++ позволяет вашей программе отличать временные объекты от невременных (rvalue от lvalue);
  • позволяет ссылаться на эти временные объекты;
  • в случае, если мы используем их для присваивания или инициализации какого-то другого объекта, C++ вызывает специальные конструктор либо оператор, в которых мы можем делать, что угодно, например, забирать ресурсы у временного объекта, “ломая” и “портя” его, но избегая при этом потенциально медленного копирования. “Испорченный” временный объект делает то же самое, что сделал бы и не будь он “испорченным”, а именно – уничтожается (на то он и временный).

Обратите внимание на то, что и конструктор и оператор копирования должны быть помечены как noexcept.

Стоит заметить, правило, известное как правило трёх, становится правилом пяти [9]: если вы реализуете в вашем классе один пункт из следующего списка, вы должны реализовать все пять:

  • конструктор копирования;
  • конструктор перемещения;
  • оператор копирования;
  • оператор перемещения;
  • деструктор.
Примечание
На самом деле, правильная реализация copy-and-swap idiom совмещает операторы копирования и перемещения [10].

Внимательный читатель наверняка задался вопросом, что делать, если класс содержит поля не примитивного типа (например, std::string), не являющиеся указателями, ведь в таком случае при вызове std::swap произойдет копирование*. Для таких ситуаций С++11 предлагает нам воспользоваться…

std::move

std::move [11] – это функция из стандартной библиотеки, определённая в хедере <utility>, которая позволяет взять, что угодно (например, lvalue), и сделать из этого rvalue (xvalue, если быть точным).

Круто... И что это нам даёт? Это даёт нам возможность перемещать объекты, rvalue-ссылок на которые у нас нет.

Допустим, наш класс X имеет поле типа std::string. Как реализовать конструктор и оператор перемещения правильно?

Листинг 5
X(X&& x) : stringField(std::move(x.stringField)) noexcept {
    std::swap(resource, copy.resource);
}

X& operator=(X&& x) noexcept {
    stringField = std::move(x.stringField);
    std::swap(resource, copy.resource);

    return *this;
}

Теперь в конструкторе перемещения для поля типа std::string (stringField) вызывается конструктор перемещения класса std::string, потому что вызов std::move “сделал” из x.stringField rvalue! В операторе перемещения для stringField вызывается оператор перемещения std::string, потому что вызов std::move “сделал” из x.stringField rvalue.

С точки зрения семантики, обёртка в std::move позволяет отметить какой-либо объект как объект, чьи ресурсы могут быть перемещены.

std::move также активно используется в совокупности с умными указателями (std::unique_ptr), о которых мы тоже писали.

Примечание*
Копирования не произошло бы, потому что внутри std::swap тоже использует std::move.
Утешение
Тема move semantics в C++ объективно непростая. Это нормально, если вы не всё поняли с первого раза. Перечитайте нашу статью, обратитесь к документации или другим статьям по этой теме и всё станет на свои места.

Вывод

Move semantics проявляется лишь в определённых случаях. Move semantics позволяет забирать ресурсы у временных объектов, которые, как правило, в скором времени будут уничтожены, тем самым избегая лишнего копирования. Основными инструментами языка и стандартной библиотеки для реализации move semantics являются:

  • ссылки на rvalue;
  • конструктор и оператор перемещения;
  • std::move.

Конструктор перемещения вызывается, когда объект инициализируется rvalue, оператор перемещения – когда объекту присваивается rvalue. std::move отмечает объекты, ресурсы которых могут быть перемещены, превращая эти объекты в rvalue (xvalue).

Не рассмотренными остались темы rvalue-ссылок в контексте C++ шаблонов, в частности, темы perfect forwarding и std::forward [12], тема copy/move elision и RVO [13], а также тонкая возможность C++ – ref-qualified методы [14].

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

matyushkin
29 марта 2020

ТОП-10 книг по C++: от новичка до профессионала

Книги по C++ на русском языке с лучшими оценками. Расставлены в порядке воз...
Библиотека программиста
31 января 2019

Лучшие инструменты и советы начинающему C++ программисту

Хотите изучать C++? Делимся важными навыками, фреймворками и советами, кото...