🛠 Основы move semantics в C++
В этой статье мы поговорим о том, что такое move semantics, зачем и когда она нужна, и как при помощи этого механизма оптимизировать программы на C++.
Что нужно знать перед прочтением этой статьи?
Предполагается, что читатель знаком с концепцией ссылок в C++, классов, конструкторов, конструкторов копирования, переопределённых операторов и операторов копирования, а также правилом трёх.
Что такое 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.
Для ясности предлагаем взглянуть на диаграмму Венна:
До 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
), о которых мы тоже писали.
std::swap
тоже использует std::move
.Вывод
Move semantics проявляется лишь в определённых случаях. Move semantics позволяет забирать ресурсы у временных объектов, которые, как правило, в скором времени будут уничтожены, тем самым избегая лишнего копирования. Основными инструментами языка и стандартной библиотеки для реализации move semantics являются:
- ссылки на rvalue;
- конструктор и оператор перемещения;
std::move
.
Конструктор перемещения вызывается, когда объект инициализируется rvalue, оператор перемещения – когда объекту присваивается rvalue. std::move
отмечает объекты, ресурсы которых могут быть перемещены, превращая эти объекты в rvalue (xvalue).
Источники
- https://en.cppreference.com/w/cpp/language/value_category
- https://en.cppreference.com/w/cpp/language/value_category#lvalue
- https://en.cppreference.com/w/cpp/language/value_category#prvalue
- https://en.cppreference.com/w/cpp/language/value_category#xvalue
- https://en.cppreference.com/w/cpp/language/lifetime
- https://en.cppreference.com/w/cpp/language/value_category#glvalue
- https://en.cppreference.com/w/cpp/language/value_category#rvalue
- https://en.cppreference.com/w/cpp/language/reference#Rvalue_references
- https://en.cppreference.com/w/cpp/language/rule_of_three
- https://stackoverflow.com/a/3279550/11681638
- https://en.cppreference.com/w/cpp/utility/move
- https://en.cppreference.com/w/cpp/utility/forward
- https://en.cppreference.com/w/cpp/language/copy_elision#:~:text=When%20copy%20elision%20occurs%2C%20the
- have%20been%20destroyed%20without%20the
- https://en.cppreference.com/w/cpp/language/member_functions