Углубляемся в C++: move семантика и rvalue
Разбираемся в Move семантикой и rvalue в C++
71К открытий80К показов
В этой статье разобраны основные преимущества и нюансы 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 можно узнать из видеозаписи со встречи рабочей группы по стандартизации С++ в московском офисе Яндекса.
71К открытий80К показов



