Углубляемся в C++: move семантика и rvalue
Разбираемся в Move семантикой и rvalue в C++
67К открытий72К показов
В этой статье разобраны основные преимущества и нюансы move семантики в C++11 и старше. Всё описанное в этой статье было проверено на компиляторе Clang 6.0.1 с библиотекой libc++ на x86, GNU/Linux.
Введение в move
Move семантика позволяет переместить объект вместо его копирования для увеличения производительности. Проще всего понять семантику перемещения на примере. В качестве этого примера будет использоваться класс String
:
При передаче объекта этого класса в функцию, принимающую его по значению, — назовём её by_value()
— произойдёт следующее:
Получается 4 обращения к аллокатору, что достаточно накладно. Но если объект String
больше не понадобится, а функцию by_value()
менять нельзя, то можно переместить объект, а не копировать. Для этого необходимо написать конструктор перемещения для класса String
:
Параметр конструктора перемещения other
, во-первых, неконстантный, т.к. конструктор его изменяет; во-вторых, является rvalue
-ссылкой (&&
), а не lvalue
-ссылкой (&
). Об их отличиях будет сказано далее. Сам конструктор переносит Си-строку с other
на this
, делая other
пустым.
Конструктор перемещения в общем случае не медленнее, а зачастую даже быстрее конструктора копирования, но ничего не мешает программисту поместить sleep(10'000)
в конструктор перемещения.
Для вызова конструктора перемещения вместо конструктора копирования можно использовать std::move()
. Теперь пример выглядит следующим образом:
Количество обращений к аллокатору уменьшилось вдвое!
rvalue и lvalue
Основное отличие rvalue
от lvalue
в том, что объекты rvalue
могут быть перемещены, тогда как объекты lvalue
всегда копируются.
Это «могут быть» лучше всего демонстрируют следующие два примера:
Этот код работает не так, как от него ожидается. std::move()
всё ещё конвертирует lvalue
в rvalue
, но конвертация сохраняет все модификаторы, в том числе и const
. Затем компилятор выбирает среди двух конструкторов класса String
самый подходящий. Из-за того что компилятор не может отправить const rlvaue
туда, где ожидается non-const rvalue
, он выбирает конструктор копирования, и const rvalue
конвертируется обратно в const lvalue
. Вывод: следите за модификаторами объектов, т.к. они учитываются при выборе одной из перегрузок функции.
Второй пример демонстрирует правило: аргументы и результат функции могут быть как rvalue
, так и lvalue
, но параметры функций могут быть только lvalue
. Аргумент — это то, что передаётся в функцию. Он инициализирует параметр, который доступен непосредственно внутри функции.
Хотя параметр string
функции f()
и имеет тип rvalue
-ссылки, он является lvalue
и требует явной конвертации в rvalue
перед передачей в функцию g()
. Этим и занимается std::move()
.
Универсальные ссылки
Универсальные ссылки могут быть как rvalue
-, так и lvalue
-ссылкой в зависимости от аргументов или результата функции. Используются в шаблонах и в auto&&
:
Компилятор на основе этого шаблона генерирует 2 функции, одна из которых принимает lvalue
, другая — rvalue
, если они будут использоваться. Если программист хочет использовать перемещение для rvalue
-ссылки и простое копирование для lvalue
-ссылки, то он может использовать std::forward()
, который приводит свой аргумент к rvalue
только тогда, когда его тип является rvalue
-ссылкой. std::forward()
требует явного указания шаблонного параметра даже с автоматическим выводом шаблонов в С++17.
Универсальная ссылка обязана быть шаблонным параметром (отсюда такое странное определение шаблона функции в примере) в формате T&&
. Например, std::vector<T>&&
уже не универсальная, а rvalue
-ссылка.
Свёртывание ссылок
На самом деле разницы между rvalue
-ссылкой и универсальной ссылкой нет. Универсальная ссылка — лишь удобная абстракция над rvalue
-ссылкой, которой многие пользуются. Но как тогда rvalue
-ссылка превращается в lvalue
-ссылку при lvalue
аргументе? Всё дело в свёртывании ссылок.
При вызове template_func(string)
компилятор генерирует следующий заголовок функции:
void template_func(String& && string);
И получается ссылка на ссылку! Вручную так сделать нельзя, но шаблоны могут. Далее компилятор свёртывает ссылку на ссылку. Свертывание производится по следующему правилу: результатом свертывания будет rvalue
-ссылка только тогда, когда обе ссылки являются rvalue
-ссылками.
Именно из-за этого безобразия проще использовать абстракцию универсальных ссылок.
Подробнее о 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)
и ~String()
.
В C++20 copy/move elision может быть расширен, благодаря чему в некоторых случаях использование std::move()
также снизит производительность. Подробнее о расширении copy/move elision можно узнать из видеозаписи со встречи рабочей группы по стандартизации С++ в московском офисе Яндекса.
67К открытий72К показов