Каждое выражение в 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.
Для ясности предлагаем взглянуть на диаграмму Венна:
До 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-я строка main, будь она раскомментирована, не скомпилировалась бы, т.к. Стандарт запрещает привязывать временные объекты (rvalue) к ссылкам на lvalue. Однако он разрешает привязывать rvalue к константным ссылкам на lvalue, что и происходит во 2-й строке. Но с константными ссылками есть проблема: они константные! Мы не можем ничего сделать с привязанным rvalue, используя такую ссылку, что показывает 3-я строка.
- 4-я строка начинается новым синтаксисом – двумя амперсандами ( && ), обозначающими объявление ссылки на rvalue [8]. Далее к этой ссылке привязывается rvalue, которое, как видно из 5-й строки, мы можем изменять.
Важно понимать, что сама ссылка на rvalue является lvalue.
Это всё очень хорошо, скажете вы, но как это поможет мне оптимизировать мои программы? Об этом – ниже.
Что такое move semantics и когда она имеет место
Добавим в наш класс X конструктор по умолчанию, конструктор и оператор копирования, а также объявление указателя на int.
resource здесь – это какие-то данные, которые, с точки зрения производительности, тяжело и долго копируются и лишнего копирования которых стоит избегать.
Заменим main из листинга 1 на следующий:
Заметили, да? Мы копируем содержимое временного объекта, в то время как копирования фактически можно избежать, просто забрав ( переместив ) ресурс из временного объекта, т.к. этот объект всё равно очень скоро (после выхода из конструктора копирования) будет уничтожен и никто не пострадает, если его содержимое станет пустым (или не пустым, но невалидным). Это и есть move semantics.
Важно понять, что move semantics не является способом увеличить производительность каждой строки вашего кода. Move semantics – это механизм, работающий только в определённых случаях. Несмотря на это, он может здорово повысить общую скорость работы вашей программы.
Это всё звучит привлекательно, но как это реализовать? Очень просто, для этого нам понадобятся…
Конструктор и оператор перемещения
С++ 11 дал нам два инструмента для реализации move semantics в пользовательских классах – конструктор перемещения и оператор перемещения. Это своего рода аналоги конструктора и оператора копирования, но предназначенные не для копирования, а для перемещения. Добавим их в наш класс X :
В конструкторе перемещения указатель на ресурс объекта, в который мы перемещаем, меняется на указатель на ресурс объекта, из которого мы перемещаем, и наоборот. То же самое происходит в операторе перемещения. В результате объект получает тяжеловесный ресурс, но при этом никакого копирования не происходит!
Теперь при использовании компилятора, поддерживающего C++ 11, код из листинга 3 больше не будет вызывать оператор копирования, а вместо него будет вызывать оператор перемещения. Почему? Потому что в данном случае справа от знака равно находится rvalue, а конструктор и оператор копирования предназначены для работы именно с rvalue.
Резюмируя последние четыре раздела статьи:
- C++ позволяет вашей программе отличать временные объекты от невременных (rvalue от lvalue);
- позволяет ссылаться на эти временные объекты;
- в случае, если мы используем их для присваивания или инициализации какого-то другого объекта, C++ вызывает специальные конструктор либо оператор, в которых мы можем делать, что угодно, например, забирать ресурсы у временного объекта, “ломая” и “портя” его, но избегая при этом потенциально медленного копирования. “Испорченный” временный объект делает то же самое, что сделал бы и не будь он “испорченным”, а именно – уничтожается (на то он и временный).
Обратите внимание на то, что и конструктор и оператор копирования должны быть помечены как noexcept .
Стоит заметить, правило, известное как правило трёх, становится правилом пяти [9]: если вы реализуете в вашем классе один пункт из следующего списка, вы должны реализовать все пять:
- конструктор копирования;
- конструктор перемещения;
- оператор копирования;
- оператор перемещения;
- деструктор.
Внимательный читатель наверняка задался вопросом, что делать, если класс содержит поля не примитивного типа (например, std::string ), не являющиеся указателями, ведь в таком случае при вызове std::swap произойдет копирование*. Для таких ситуаций С++11 предлагает нам воспользоваться…
std::move
std::move [11] – это функция из стандартной библиотеки, определённая в хедере <utility> , которая позволяет взять, что угодно (например, lvalue), и сделать из этого rvalue (xvalue, если быть точным).
Круто. И что это нам даёт? Это даёт нам возможность перемещать объекты, rvalue-ссылок на которые у нас нет.
Допустим, наш класс X имеет поле типа std::string . Как реализовать конструктор и оператор перемещения правильно?
Теперь в конструкторе перемещения для поля типа 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 ), о которых мы тоже писали .