Углубляемся в C++: move семантика и rvalue
Авторизуйтесь
Углубляемся в C++: move семантика и rvalue
В этой статье разобраны основные преимущества и нюансы move семантики в C++11 и старше. Всё описанное в этой статье было проверено на компиляторе Clang 6.0.1 с библиотекой libc++ на x86, GNU/Linux.
Введение в move
Move семантика позволяет переместить объект вместо его копирования для увеличения производительности. Проще всего понять семантику перемещения на примере. В качестве этого примера будет использоваться класс String :
При передаче объекта этого класса в функцию, принимающую его по значению, — назовём её by_value() — произойдёт следующее:
Получается 4 обращения к аллокатору, что достаточно накладно. Но если объект String больше не понадобится, а функцию by_value() менять нельзя, то можно переместить объект, а не копировать. Для этого необходимо написать конструктор перемещения для класса String :
Конструктор перемещения в общем случае не медленнее, а зачастую даже быстрее конструктора копирования, но ничего не мешает программисту поместить sleep(10’000) в конструктор перемещения.
Количество обращений к аллокатору уменьшилось вдвое!
rvalue и lvalue
Основное отличие rvalue от lvalue в том, что объекты rvalue могут быть перемещены, тогда как объекты lvalue всегда копируются.
Это «могут быть» лучше всего демонстрируют следующие два примера:
Универсальные ссылки
Свёртывание ссылок
При вызове template_func(string) компилятор генерирует следующий заголовок функции:
Именно из-за этого безобразия проще использовать абстракцию универсальных ссылок.
Подробнее о move семантике и rvalue можно узнать из книги С. Мейерса «Эффективный и современный С++: 42 рекомендации по использованию С++ 11 и С++14» ISBN: 978-5-8459-2000-3.
Copy/move elision
Copy/move elision — это оптимизация, при которой компилятор может убрать некоторые вызовы конструктора копирования и деструктора, но в данный момент только при возврате объекта из функции, и только если тип возвращаемого объекта полностью совпадает с типом функции.
Поэтому при возврате из функции использование std::move() может снизить производительность, ограничив компилятор в Copy elision оптимизации, ведь отсутствие конструктора быстрее, чем конструктор перемещения.
Здесь std::move() только замедляет код, добавляя лишний вызов String(String&& other) и
В C++20 copy/move elision может быть расширен, благодаря чему в некоторых случаях использование std::move() также снизит производительность. Подробнее о расширении copy/move elision можно узнать из видеозаписи со встречи рабочей группы по стандартизации С++ в московском офисе Яндекса.
std::move vs. std::forward
Несмотря на то, что материалов на тему move-семантики и идеальной передачи в Интернете предостаточно, вопросов типа «что я должен здесь использовать: move или forward?» не становится меньше или мне просто «везет» на них. Поэтому и решено было написать эту статью. Предполагается, что читатель хотя бы немного знаком с rvalue-ссылками, move-семантикой и идеальной передачей.
Для чего нужен шаблон функции std::move?
Функция std::move выполняет приведение передаваемого lvalue-аргумента в rvalue-ссылку. Зачем это нужно? Вернемся во времена С++98 и рассмотрим классический пример:
В этом примере для встроенных типов проблем не возникает, но для таких типов как vector или string копирование может являться крайне дорогой операцией. Необходимо было добавлять специализации для шаблона swap, чтобы выполнять это действие более эффективно, и многих такой подход не устраивал.
Что же здесь нужно изменить? Нам не нужно выполнять копирование, а нужно «перемещать» объекты. Как это сделать? Нужно вызывать специальный конструктор или оператор присваивания, которые не будут копировать содержимое классов, а будут обмениваться им (по сути выполнять swap для всех членов).
Для этого в C++11 ввели rvalue-ссылки, которые обозначаются через && и позволяют ссылаться на временные объекты или определять объекты как «перемещаемые». Также появились конструктор перемещения (move constructor) и оператор перемещающего присваивания (move assignment), которые отличаются от копирующих «коллег» тем, что в качестве аргумента принимают неконстантную rvalue-ссылку:
Теперь операцию swap мы можем переписать с использованием функции std::move, которая возвращает rvalue-ссылку и тем самым сообщает компилятору, что параметр является перемещаемым:
Функция std::move не выполняет никаких перемещений. Как уже было сказано выше, она выполняет приведение типа к rvalue-ссылке. Давайте посмотрим на ее код:
То есть это просто обертка для static_cast, которая «убирает» ссылку у переданного аргумента с помощью remove_reference_t и, добавив &&, преобразует тип в rvalue-ссылку. Давайте глянем на то, как именно remove_reference_t избавляется от ссылок:
Для чего нужен std::forward?
Функция std::forward, как известно, применяется при идеальной передаче (perfect forwarding).
Идеальная передача позволяет создавать функции-обертки, передающие параметры без каких-либо изменений (lvalue передаются как lvalue, а rvalue – как rvalue) и тут std::move нам не подходит, так как она безусловно приводит свой результат к rvalue.
Поэтому, была разработана функция std::forward, которая выполняет примерно следующую работу:
То есть, если ссылка была передана как rvalue, то вызываем духов std::move, а иначе просто возвращаем то, что передали.
Если посмотреть исходники стандартной библиотеки у MS, то мы увидим следующую реализацию:
Может быть немного сложнее, но смысл тот же. И пусть Вас не пугает перегруженная версия forward(remove_reference_t && _Arg). В ней просто добавлена проверка на случай компиляции чудесатых конструкций вроде такой:
На стадии компиляции получим сразу ошибку: bad forward call. И я полагаю, это единственное, ради чего добавлена еще одна сигнатура, поскольку нет никакого смысла городить, например, такие конструкции:
Если у кого-то есть идеи, для чего еще может понадобиться forward(remove_reference_t && _Arg), дайте знать и заранее спасибо.
Возможно сейчас у читателя возникает в голове вопрос: а может на самом деле нет нужды вызывать forward и просто вызывать static_cast (_Arg)? Да можно, конечно, просто forward более безопасен в плане очепяток и путаниц. Кроме того, forward (Arg) в коде выглядит более осмысленным и понятным, нежели static_cast (Arg). Никто же не любит писать комментарии, поэтому лучше писать сразу хорошо читаемый код. То же самое касается и move, и тут придется писать более громоздкую конструкцию:
Пример
Я скопировал код move и forward, вставив в них вывод в cout, чтобы можно было увидеть, что же происходит. Далее мы берем и последовательно вызываем функцию foo, передавая сперва lvalue значение, а затем rvalue. Обратите внимание, что у функции foo аргумент является «универсальной ссылкой» в терминологии Скотта Мейерса, или передаваемой ссылкой (forwarding reference) в терминологии комитета стандартизации С++, то есть она:
Внутри функции foo, вызываем перегруженную функцию bar для константной lvalue-ссылки, для lvalue-ссылки и, наконец, для «универсальной» ссылки.
На выводе получаем:
Итак, разбираем lvalue. В функции foo тип T будет выведен как int&. Почему T выводится как lvalue-сылка, т.е. как int&? По правилам вывода аргумента шаблона для универсальных ссылок, если в качестве аргумента передано lvalue значение, то T выводится как lvalue-ссылка. Согласно правилам сжатия ссылок (reference collapsing) аргумент p также будет выведен как int&, так как foo(int& && p) превратится в foo(int& p). Таким образом, мы получаем следующую версию foo:
bar(p); вызовет bar(int& v), и тут вопросов возникать не должно.
bar(_move(p)); сперва вызовет move, которая вернет rvalue-ссылку, а значит будет вызвана bar(int&& v).
bar(_forward (p)); сперва вызовет _forward(int& _Arg), которая должна вернуть lvalue-ссылку, и значит будет вызвана опять bar(int& v). Что при этом произойдет внутри _forward? Применив правила сжатия ссылок, мы получим следующую версию _forward:
Разберем теперь rvalue. В функции foo тип T будет выведен как int. Согласно все тех же правил вывода аргумента шаблона для универсальных ссылок, если в качестве аргумента передано rvalue, то T выводится как бессылочный тип. Это необходимо для корректной работы функции forward, как мы это увидим далее. Итак, наша foo выглядит теперь следующим образом:
bar(p); как и ранее, вызовет bar(int& v), т.к. p является l-value, не смотря на то, что выглядит как r-value ссылка.
bar(_move(p)); как и ранее, сперва вызовет move, которая вернет rvalue-ссылку, а значит будет опять вызвана bar(int&& v).
Теперь посмотрим как же будет инстанцирована forward :
Давайте подставим вместо move и forward их содержимое, чтобы наглядно посмотреть на разницу:
Вывод: для перемещаемых объектов необходимо использовать std::move, а для идеальной передачи – std::forward.
Примеры использования move и forward
Кроме приведенного выше примера std::swap, использование std:move можно найти в различных алгоритмах, где нужно менять элементы местами (различные сортировки, или, например, в функции std::unique).
Если необходимо «передать» умный указатель std::unique_ptr, то сделать мы можем это только через std::move (либо через release() и сырой указатель, но это не по фень-шую).
В функции std::vector::push_back для rvalue можно обнаружить:
Таким образом, legacy-код, добавляющий новый элемент в вектор через rvalue волшебным образом начинает работать через перемещение, а не копирование.
Если ваша функция возвращает кортеж (или пару), то стоит обратить внимание на возможность перемещения некоторых или даже всех его элементов:
Обратите внимание на то, что не нужно использовать std::move при возврате из функции, возвращающий локальный объект:
Здесь нужно убрать std::move. Всю работу сделает copy/move elision – специальная оптимизация, которую выполняет компилятор, убирая лишние создания объектов.
Функция std::forward и вариативные шаблоны являются фундаментом, на котором строятся такие функции-обертки как std::make_unique, std::make_shared, std::make_pair, std::make_tuple и другие. Например, make_unique делает очень простую работу:
Семейство emplace методов также работает через forward, зачастую просто вызывая конструктор через placement-new.
Что еще можно почитать на эту тему:
Книга Скотта Мейерса «Эффективный и современный С++. 42 рекомендации по использованию C++11 и C++14»
Просто о сложном — move в языке C++
Здравствуйте уважаемые читатели. Данная публикация адресована начинающим разработчикам С++ которые только становятся на путь высокой производительности и «отстрелянных конечностей». Опытные разработчики найдут здесь скорее повторение тех вещей, которые сами мучительно осваивали в те далекие времена, когда в языке С++ появилась возможность удобного перемещения объектов.
Многие из вас уже слышали и надеюсь использовали функцию move() в своих проектах. Если нет, то пришло время с ней познакомиться.
Вопрос: Опять этот move, сколько уже можно? Есть же множество опубликованного материала по этой теме?
Ответ: Да, есть много статей. В свое время учился по ним, в том числе и тут, на Хабре [1, 2]. Но мне все равно было не понятно, значит, учитывая статистику, непонятно также и некоторому количеству читателей.
Что обычно говорят о move? Это крутая штука, код с ней работает быстрее. А насколько быстрее? Давайте проверим.
Чтобы оценить быстродействие возьмем следующий класс:
Не пугайтесь, он нам будет нужен только как условный секундомер для экспериментов. Чтобы с его помощью оценить время выполнения операции достаточно сделать так:
LogDuration(), который покажет время выполнения операций внутри блока.
Итак, начнем экспериментировать.
Говорят, что для векторов и строк (std::string) нужно по возможности использовать move. Проверим. Напишем такой код:
Здесь мы создаем вектор big_vector из нулей длиной 10^9, а затем создаем новый вектор как копию данного. Время на создание копии выводится в консоль:
Программа valgrind показывает, что за время выполнения программы было использовано 2 ГБ оперативной памяти:
И о чудо! Время выполнения уменьшилось почти в 10 раз, а размер исходного вектора стал равен нулю:
Valgrind уже более оптимистичен:
Получается, что воспользовавшись move мы выиграли в скорости, но пожертвовали исходным вектором. Случай с длинной строкой вместо вектора предлагаю проверить самостоятельно.
Теперь попробуем разобраться что тут вообще происходит. Давайте напишем свой вектор, точнее простую обертку над стандартным вектором
Также не пугайтесь, тут нужно смотреть на то, что внутри секции public. Добавьте этот код перед main() в вашей программе, а внутри main замените первую букву в слове vector на заглавную везде, где он упоминается. Для случая:
в консоли будет выведено:
А для варианта с move:
Использование move не ограничивается конструкторами классов. Например:
Обратите внимание на строчку 12, где закомментирована операция. Сигнатура данной функции содержит «волшебные» символы && из-за которых не получается ей указать объект text. А какую-то бесхозную строку в кавычках можно. Теперь обратите внимание на строку 7, где объекту text присваивается «some text». Чем они различаются принципиально, кроме расположения лево-право от оператора присваивания?
А тем, что text имеет адрес в памяти, а выражение «some text» его не имеет, точнее его адрес не так просто найти. Адрес постоянного объекта можно узнать так:
то после выполнения данной функции переменная text во внешнем блоке окажется пустой (компилятор gcc 7.5 c++17), как в самом начале для случая с перемещением вектора.
Там конечно, все намного сложнее, но общий принцип примерно таков.
Урок №192. Функция std::move()
Обновл. 15 Сен 2021 |
Как только вы начнете регулярно использовать семантику перемещения, вы поймете насколько полезной она может быть.
Проблема
Очень часто объекты, с которыми вам придется работать, будут не r-values, а l-values. Например, рассмотрим следующий шаблон функции swap():
Принимая два объекта типа T (в данном случае std::string) функция swap() меняет местами их значения, делая при этом три копии.
Следовательно, результат выполнения программы:
x: Anton
y: Max
x: Max
y: Anton
Но как? Проблема состоит в том, что параметры x и y являются ссылками l-value, а не ссылками r-value, поэтому у нас нет способа вызвать конструктор перемещения или оператор присваивания перемещением вместо конструктора копирования и оператора присваивания копированием. По умолчанию у нас используется семантика копирования. Что делать?
Функция std::move()
Функция std::move() — это стандартная библиотечная функция, которая конвертирует передаваемый аргумент в r-value. Мы можем передать l-value в функцию std::move(), и std::move() вернет нам ссылку r-value. Для работы с std::move() нужно подключить заголовочный файл utility.
Вот вышеприведенная программа, но уже с функцией swap(), которая использует std::move() для преобразования l-values в r-values, чтобы мы имели возможность использовать семантику перемещения вместо семантики копирования:
Результат выполнения один и тот же:
x: Anton
y: Max
x: Max
y: Anton
Еще один пример
Мы также можем использовать std::move() для заполнения контейнерных классов (таких как std::vector) значениями l-values.
В следующей программе мы сначала добавляем элемент в вектор, используя семантику копирования, а затем добавляем элемент в вектор, используя семантику перемещения:
Результат выполнения программы:
Copying str
str: Bye
vector: Bye
Moving str
str:
vector: Bye Bye
В первом случае мы передаем l-value в push_back(), поэтому используется семантика копирования для добавления элемента в вектор. По этой причине переменная str остается с прежним значением.
Во втором случае мы передаем r-value (фактически l-value, которое конвертируется в r-value через std::move()) в push_back(), поэтому используется семантика перемещения для добавления элемента в вектор. Это более эффективно, так как элемент вектора может украсть значение переменной std::string, вместо его копирования. По этой же причине str лишается своего значения.
На этом этапе стоит сообщить, что std::move() как бы подсказывает компилятору, что нам больше не нужен этот объект (по крайней мере, в его текущем состоянии). Следовательно, вы не должны использовать std::move() с любым «постоянным» объектом, который вы не хотите изменять, и вам не следует ожидать, что объекты, которые используются с std::move(), останутся прежними.
Функции перемещения
Как мы уже говорили на предыдущем уроке, вы должны оставлять объекты, ресурсы которых вы перемещаете, в четко определенном состоянии. В идеале это должно быть «нулевое состояние» (null/nullptr).
Теперь мы можем поговорить о том, почему при использовании функции std::move(), объект, ресурсы которого перемещаются, может не быть временным. Дело в том, что пользователь может захотеть повторно использовать этот же (теперь пустой) объект или протестировать его каким-либо образом.
В примере, приведенном выше, строка str остается пустой после выполнения перемещения (что всегда делает std::string после успешного перемещения). Таким образом, мы можем повторно её использовать, если, конечно, захотим, или проигнорировать, если она нам больше не нужна.
Чем еще полезна функция std::move()?
Функция std::move() также может быть полезна при сортировке элементов массива. Многие алгоритмы сортировки (такие как «метод выбора» и «сортировка пузырьком») работают путем замены целых пар элементов. На предыдущих уроках нам приходилось использовать семантику копирования для выполнения таких замен. Теперь же мы можем использовать семантику перемещения, которая эффективнее.
Функция std::move() также может быть полезна при перемещении содержимого из одного умного указателя в другой.
Заключение
Функция std::move() может использоваться всякий раз, когда нужно обрабатывать l-value как r-value с целью использования семантики перемещения вместо семантики копирования.
Поделиться в социальных сетях:
Урок №191. Конструктор перемещения и Оператор присваивания перемещением
🛠 Основы move semantics в C++
wcobalt
Что нужно знать перед прочтением этой статьи?
Предполагается, что читатель знаком с концепцией ссылок в C++, классов, конструкторов, конструкторов копирования, переопределённых операторов и операторов копирования, а также правилом трёх.
Что такое rvalue и lvalue
Каждое выражение в C++ характеризуется двумя свойствами: типом и категорией значения ( value category [1] ). В контексте разбора move semantics нас интересует только последнее. Полное описание категорий значений – тема для отдельной статьи, однако мы приведём необходимые сведения о каждой из существующих категорий значений.
Стандарт языка определяет три основные категории значений и ещё две составные, которые определяются на основе первых трёх.
Базовыми категориями значений являются lvalue, prvalue и xvalue:
Определив три основные категории значений, можно определить две оставшиеся (составные) – glvalue и rvalue:
Для ясности предлагаем взглянуть на диаграмму Венна:
До 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). Время для примера:
Важно понимать, что сама ссылка на rvalue является lvalue.
Это всё очень хорошо, скажете вы, но как это поможет мне оптимизировать мои программы? Об этом – ниже.
Что такое move semantics и когда она имеет место
Добавим в наш класс X конструктор по умолчанию, конструктор и оператор копирования, а также объявление указателя на int.
resource здесь – это какие-то данные, которые, с точки зрения производительности, тяжело и долго копируются и лишнего копирования которых стоит избегать.
Заменим main из листинга 1 на следующий:
Заметили, да? Мы копируем содержимое временного объекта, в то время как копирования фактически можно избежать, просто забрав ( переместив ) ресурс из временного объекта, т.к. этот объект всё равно очень скоро (после выхода из конструктора копирования) будет уничтожен и никто не пострадает, если его содержимое станет пустым (или не пустым, но невалидным). Это и есть move semantics.
Важно понять, что move semantics не является способом увеличить производительность каждой строки вашего кода. Move semantics – это механизм, работающий только в определённых случаях. Несмотря на это, он может здорово повысить общую скорость работы вашей программы.
Это всё звучит привлекательно, но как это реализовать? Очень просто, для этого нам понадобятся…
Конструктор и оператор перемещения
С++ 11 дал нам два инструмента для реализации move semantics в пользовательских классах – конструктор перемещения и оператор перемещения. Это своего рода аналоги конструктора и оператора копирования, но предназначенные не для копирования, а для перемещения. Добавим их в наш класс X :
В конструкторе перемещения указатель на ресурс объекта, в который мы перемещаем, меняется на указатель на ресурс объекта, из которого мы перемещаем, и наоборот. То же самое происходит в операторе перемещения. В результате объект получает тяжеловесный ресурс, но при этом никакого копирования не происходит!
Теперь при использовании компилятора, поддерживающего C++ 11, код из листинга 3 больше не будет вызывать оператор копирования, а вместо него будет вызывать оператор перемещения. Почему? Потому что в данном случае справа от знака равно находится rvalue, а конструктор и оператор копирования предназначены для работы именно с rvalue.
Резюмируя последние четыре раздела статьи:
Стоит заметить, правило, известное как правило трёх, становится правилом пяти [9]: если вы реализуете в вашем классе один пункт из следующего списка, вы должны реализовать все пять:
Внимательный читатель наверняка задался вопросом, что делать, если класс содержит поля не примитивного типа (например, std::string ), не являющиеся указателями, ведь в таком случае при вызове std::swap произойдет копирование*. Для таких ситуаций С++11 предлагает нам воспользоваться…
std::move
Круто. И что это нам даёт? Это даёт нам возможность перемещать объекты, rvalue-ссылок на которые у нас нет.
С точки зрения семантики, обёртка в std::move позволяет отметить какой-либо объект как объект, чьи ресурсы могут быть перемещены.








