Основы Move semantics в C

Основы Move semantics в C

Каждое выражение в 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.

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

Основы Move semantics в C

До 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 ), о которых мы тоже писали .