Перейти к содержимому

За что отвечает функция move

  • автор:

Просто о сложном — move в языке C++

Здравствуйте уважаемые читатели. Данная публикация адресована начинающим разработчикам С++ которые только становятся на путь высокой производительности и «отстрелянных конечностей». Опытные разработчики найдут здесь скорее повторение тех вещей, которые сами мучительно осваивали в те далекие времена, когда в языке С++ появилась возможность удобного перемещения объектов.

Многие из вас уже слышали и надеюсь использовали функцию move() в своих проектах. Если нет, то пришло время с ней познакомиться.

Вопрос: Опять этот move, сколько уже можно? Есть же множество опубликованного материала по этой теме?

Ответ: Да, есть много статей. В свое время учился по ним, в том числе и тут, на Хабре [1, 2]. Но мне все равно было не понятно, значит, учитывая статистику, непонятно также и некоторому количеству читателей.

Как обычно начинаются туториалы по move? Рассмотрим lvalue объект, ему соответствует rvalue объект, между ними есть оператор присваивания (=). Тут появляются ссылки, да не просто, а ссылки на lvalue, на rvalue и пошло-поехало. Мозг перегружается, статья пролистывается до конца. Поэтому попробую рассказать о move c другой стороны — в стиле «от практики к теории» — так, как хотел бы чтобы мне рассказали.

Что обычно говорят о move? Это крутая штука, код с ней работает быстрее. А насколько быстрее? Давайте проверим.

Чтобы оценить быстродействие возьмем следующий класс:

class LogDuration < public: LogDuration(std::string id) : id_(std::move(id)) < >~LogDuration() < const auto end_time = std::chrono::steady_clock::now(); const auto dur = end_time - start_time_; std::cout (dur).count() private: const std::string id_; const std::chrono::steady_clock::time_point start_time_ = std::chrono::steady_clock::now(); >;

Не пугайтесь, он нам будет нужен только как условный секундомер для экспериментов. Чтобы с его помощью оценить время выполнения операции достаточно сделать так:

где фигурные скобки задают область видимости. При выходе за ее пределы запускаются деструкторы классов для объектов (исключение — статичные переменные), которые были созданы внутри данной области, в том числе и ~LogDuration(), который покажет время выполнения операций внутри блока.

Итак, начнем экспериментировать.

Говорят, что для векторов и строк (std::string) нужно по возможности использовать move. Проверим. Напишем такой код:

int main() < vectorbig_vector(1e9, 0); < LogDuration ld("vector copy"); vectorreciever(big_vector); > cout

Здесь мы создаем вектор big_vector из нулей длиной 10^9, а затем создаем новый вектор как копию данного. Время на создание копии выводится в консоль:

vector copy: operation time: 484 ms size of big_vector is 1000000000

Программа valgrind показывает, что за время выполнения программы было использовано 2 ГБ оперативной памяти:

total heap usage: 4 allocs, 4 frees, 2,000,073,728 bytes allocated

Итак, у нас получилось два одинаковых вектора, затрачено полсекунды и 2 ГБ оперативной памяти. Дальше вопрос — а что если исходный вектор нам дальше в коде никогда не понадобится, мы бы сэкономили 1 ГБ. Давайте посмотрим, что будет если добавить move. Произведем замену:

- vector reciever(big_vector); + vector reciever(move(big_vector));

И о чудо! Время выполнения уменьшилось почти в 10 раз, а размер исходного вектора стал равен нулю:

vector move: operation time: 34 ms size of big_vector is 0

Valgrind уже более оптимистичен:

total heap usage: 3 allocs, 3 frees, 1,000,073,728 bytes allocated

Получается, что воспользовавшись move мы выиграли в скорости, но пожертвовали исходным вектором. Случай с длинной строкой вместо вектора предлагаю проверить самостоятельно.

Теперь попробуем разобраться что тут вообще происходит. Давайте напишем свой вектор, точнее простую обертку над стандартным вектором

template class Vector < public: Vector(size_t size, T value) : data_(size, value) < >Vector(const Vector& rhs) < cout Vector(Vector&& rhs) noexcept < cout size_t size() < return data_.size(); >private: vector data_; >;

Также не пугайтесь, тут нужно смотреть на то, что внутри секции public. Добавьте этот код перед main() в вашей программе, а внутри main замените первую букву в слове vector на заглавную везде, где он упоминается. Для случая:

Vector reciever(big_vector);

в консоли будет выведено:

copy constructor was called vector copy: operation time: 0 ms size of big_vector is 1000000000

А для варианта с move:

move constructor was called vector move: operation time: 0 ms size of big_vector is 1000000000

Здесь мы подходим к наблюдению, что функция move сама по себе не выполняет никаких перемещений, несмотря на название, а делает все возможное чтобы в данном конкретном примере вызвать конструктор перемещения — Vector(Vector&& rhs). Т.к. в приведенном классе-обертке в конструкторах выполяется только вывод текста, то понятно, что время операции столь мало, а исходный вектор никуда не исчезает.

Использование move не ограничивается конструкторами классов. Например:

void CopyFoo(string text) <> void CopyRefFoo(const string& text) <> void MoveFoo(string&& text) <> int main() < string text; text = "some text"; CopyRefFoo(text); CopyFoo(text); // MoveFoo(text); // compile error MoveFoo("another text"); MoveFoo(move(text));

Обратите внимание на строчку 12, где закомментирована операция. Сигнатура данной функции содержит "волшебные" символы && из-за которых не получается ей указать объект text. А какую-то бесхозную строку в кавычках можно. Теперь обратите внимание на строку 7, где объекту text присваивается "some text". Чем они различаются принципиально, кроме расположения лево-право от оператора присваивания?
А тем, что text имеет адрес в памяти, а выражение "some text" его не имеет, точнее его адрес не так просто найти. Адрес постоянного объекта можно узнать так:

cout 

Теперь смотрите, для того, чтобы функция MoveFoo приняла аргумент, он "не должен иметь адреса", как "another text" например. Такие объекты еще называют временными. Теперь мы можем подойти к тому моменту когда можно сказать, что делает функция move - она делает так, что ее аргумент притворяется "безадресным", т.е. временным, поэтому 14-я строка нормально компилируется. И если внутри функции MoveFoo ничего с text не делать, то он сам по себе никуда не пропадет, не перенесется, не исчезнет. Но зачем же тогда спрашивается все телодвижения? А вот если написать:

void MoveFoo(string&& text)

то после выполнения данной функции переменная text во внешнем блоке окажется пустой (компилятор gcc 7.5 c++17), как в самом начале для случая с перемещением вектора.

Теперь вернемся к вопросу почему исходный вектор "переместился" в новый вектор за такое короткое время?
У нас есть некоторые наблюдения: при использовании move памяти затрачивалось практически вровень размеру исходного массива.
Представим вектор как структуру данных, которая в самом упрощенном варианте хранит адрес (указатель) на место в памяти, где находятся все его элементы. Мы же помним, что в векторе все элементы расположены в памяти последовательно, без разрывов. А вторым полем будет переменная, хранящая текущий размер вектора. Также мы знаем, что после операции "перемещения" исходный вектор оказывается пустым. А теперь представьте, что встречаются два вектора - один с набором из 10^9 элементов, второй пустой. Самое простое решение им взять и "обменяться" своим содержимым. Новый просто изменит свой адрес, указывающий на начало блока данных на тот, что был у исходного. Также обновит свой размер. А исходный примет такие же поля от пустого вектора. Все просто. Если пройтись отладчиком по цепочке от конструктора перемещения, то можно обнаружить такой код в стандартной библиотеке в файле stl_vector.h:

 void _M_swap_data(_Vector_impl& __x) _GLIBCXX_NOEXCEPT

Там конечно, все намного сложнее, но общий принцип примерно таков.

Очень надеюсь, что теперь основные моменты использования move для вас прояснились. Дальше рекомендую уже ознакомиться с более научными работами по использованию move семантики, где легко, надеюсь, уловите аналогии с lvalue, rvalue и т.п. А более опытным разработчикам - если дочитали до конца, буду рад услышать Ваши комментарии и замечания.

Move-семантика в C++

Move-семантика даёт возможность перемещать объект, а не копировать его, что увеличивает производительность. Давайте посмотрим, как работает семантика на примере, в котором будем использовать класс String:

1-1801-c14d74.png

Обратите внимание, что во время передачи объекта этого класса в функцию, которая принимает его по значению (давайте назовём её by_value() ) произойдёт вот что:

2-1801-57e8e8.png

То есть мы видим четыре обращения к аллокатору, а это довольно накладно. Однако если объект String нам более не понадобится, при этом функцию by_value() изменять нельзя, мы можем переместить объект, не копируя его. Чтобы это осуществить, надо написать для класса String конструктор перемещения:

3-1801-9915eb.png

Итак, во-первых, параметр конструктора перемещения other является неконстантным, так как конструктор его меняет. Во-вторых, параметр является не lvalue-ссылкой (&), а rvalue-ссылкой (&&). При этом сам конструктор переносит Си-строку с other на this, то есть делает other пустым.

В общем случае конструктор перемещения не медленнее, а часто быстрее конструктора копирования, однако ничего не мешает разработчику поместить в конструктор перемещения sleep(10'000).

Чтобы вызвать конструктор перемещения, а не конструктор копирования, вы можете задействовать std::move() . В результате наш пример станет выглядеть так:

 
auto string = String("Hello, C++11"); by_value(std::move(string)); // перемещение string в by_value(), string теперь пустая stdout: String(const char *const c_string) // new[] String(String&& other) // за счёт замены на конструктор перемещения, пропал new[] ~String() // delete[] ~String() // delete[] на nullptr

Итог использования move-семантики следующий: число обращений к аллокатору уменьшилось в два раза!

Чем отличаются функции move и forward?

С функцией move вроде бы всё понятно: она приводит любую ссылку на объект к неконстантной rvalue-ссылке, которая в соответствующем контексте передастся либо конструктору перемещения, либо оператору присваивания перемещения. Но я иногда встречаю в коде на C++ вызов функции forward в таком же контексте, где я бы использовал move . То, что можно нагуглить про эту функцию, понять не получается. Что она делает?

Отслеживать
задан 18 ноя 2018 в 20:37
8,875 25 25 серебряных знаков 66 66 бронзовых знаков

Предположу, что forward более гибкий: он знает не только про право- и левосторонние ссылки, но и значимые (нессылочные) типы.

18 ноя 2018 в 20:47

Ну, это всё-таки не C#, в C++ нет разделения на ссылочные и значимые типы. По сути rvalue-ссылка - это нечто среднее между просто значением и lvalue-ссылкой.

18 ноя 2018 в 21:23

Посмотрите этот вопрос - там это разбиралось. Без конкретного кода ("я иногда встречаю в коде на C++ вызов функции forward в таком же контексте, где я бы использовал move") вам ответить трудно - я с таким не сталкивался..

19 ноя 2018 в 5:27

". вызов функции forward в таком же контексте, где я бы использовал move ". Приведите пример такого контекста, где бы вы "использовали move ".

19 ноя 2018 в 6:11

2 ответа 2

Сортировка: Сброс на вариант по умолчанию

forward нужен для того, чтобы пробрасывать изначальный тип дальше по цепочке. Отсюда его имя и единственное назначение. Вот пример:

template void secondLevel(Args&& . args) < . >template void firstLevel(Args&& . args) < secondLevel(std::forward(args). ); > 

Используя пробравсывающие ссылки, мы получаем «истинный» тип переданного нам выражения, а с помощью forward мы сохраняем этот же тип для функции secondLevel неизменным.

В этой статье я приводил ещё один пример.

Отслеживать
2,465 9 9 серебряных знаков 19 19 бронзовых знаков
ответ дан 19 ноя 2018 в 6:07
23.8k 3 3 золотых знака 47 47 серебряных знаков 61 61 бронзовый знак

Перефразируя, forward - это что-то вроде условного move . Если в универсальную (пробрасывающую) ссылку пришло rvalue, то он работает как move . Если lvalue - ничего не делает.

19 ноя 2018 в 7:44

@HolyBlackCat, я бы не стал вообще смешивать эти 2 функции. Они совершенно разные и для разных целей. Попытка их сравнить даёт неправильное впечатление, на мой взгляд.

19 ноя 2018 в 7:52

В Вашей статье я не понял раздельчик про перегрузку. Никак не могу понять, каких знаний мне не хватает, чтобы с этим разобраться.

21 ноя 2018 в 17:38

@Modus, тогда напишите вопросы комментариями к статье (если они не большие), либо составьте вопросы для этого сайта и задайте их здесь.

21 ноя 2018 в 18:23

Функция forward , как и функция move , выполняет приведение аргумента к rvalue. Отличие ее от move в том, что приведение она выполняет условное - в зависимости от аргумента результатом forward может быть как rvalue, так и lvalue. Используется forward в идиоматической конструкции прямой передачи (perfect forwarding), а выглядит эта идиома так:

template void f(T&& arg) < . . . g(std::forward(arg)); > 

Суть этой идиомы в том, что если ссылка на шаблонизированный тип имеет строго вид T&& , и если в процессе вывода типа компилятор выполняет свертывание ссылок, то в результате свертывания результирующая ссылка может быть выведена как rvalue, так и lvalue. Использовать move в таком случае нельзя, так как программист, вызывающий функцию f с обычной переменной в качестве аргумента, может не подозревать, что ее аргумент исчезнет. Тогда как forward в таком случае корректно передаст аргумент в g как lvalue.

В стандарте такая ссылка называется "передаваемая ссылка" (forwarding reference), но с подачи Скотта Мейерса используется и название "универсальная ссылка" (universal reference).

Шаблон может быть и неявным, например:

int j = 0; auto&& r = j; // Вывод типа происходит, так что r - передаваемая ссылка. 

Ситуация с auto&& типична для лямбда функций, так что там тоже надо употреблять move с осторожностью.

Простой пример работы forward и move

std::string gstr; template void g(T arg) < gstr = std::move(arg); >template void f(T&& arg) < g(std::move(arg)); >int main() < std::string str < "test" >; f(str); 

После выполнения f(str) строка "test" перемещается в gstr , чего программист, вероятно, не ожидал. Но вот если вызов g записать как g(std::forward(arg)) , то функция forward распознает аргумент arg как lvalue и строка корректно скопируется.

Перемещение — прошлый век! Альтернативы std::move в «C++ будущего»

Каждый раз, когда мы пишем класс, управляющий ресурсами, мы задумываемся о том, что, скорее всего, для него придётся писать move-конструктор и move-присваивание. Ведь иначе объекты такого типа становятся неуклюжими, как std::mutex , ими тяжело пользоваться на практике: ни вернуть из функции, ни передать в функцию по значению, ни положить в вектор — а если положить его в другой класс как один из членов, то тот класс также «заболевает».

Положим, мы преодолели свою лень (хотя в Rust таких проблем нет!) и садимся писать move-операции для нашего класса. Проблема в том, что move-семантика в C++ имеет фундаментальное ограничение: каждый владеющий ресурсами тип с move-операциями должен иметь пустое состояние, то есть состояние с украденными ресурсами. Его нужно описывать в документации и предоставлять ему поддержку, то есть тратить время и силы на то, что нам не нужно.

Для абстрактных типов данных пустое состояние обычно бессмысленно — если у объекта украли его ресурсы, то он не сможет выполнять свои обычные функции. Но мы вынуждены это делать, чтобы реализовать move-семантику. Для некоторых типов пустое состояние недопустимо: open_file (в противовес теоретическому file ), not_null_unique_ptr (в противовес unique_ptr ).

Говоря словами Arthur O'Dwyer, мы заказывали телепорт, а нам дали «вас клонируют и убивают первоначальную копию». Чтобы вернуть себе телепорт, проходите под кат!

Я опишу несколько предложений к стандарту C++, которые объединены одной темой: свести к минимуму число перемещений. Но для начала, ещё раз: почему меня должно это заботить?

  1. Я не хочу тратить усилия на реализацию move-семантики для всех типов, владеющих ресурсами
  2. Я не хочу иметь во всех своих типах пустое состояние. Часто оно не к месту. Бывает, что его сложно или невозможно добавить. И всегда это лишние усилия на поддержку
  3. Даже если move-семантика реализуема, она может быть непозволительна из-за того, что мы хотим раздать указатели на этот объект
  4. Даже если перемещение допустимо, будет затрачено время на то, чтобы «занулить» первоначальный объект, и потом удалить его по всем правилам. И нет, компиляторы не могут это оптимизировать: раз, два

P1144: Trivially relocatable

Это предложение к стандарту, за авторством Arthur O'Dwyer, добавляет новый атрибут [[trivially_relocatable]] , которым можно пометить типы, которые можно передавать более эффективно, чем через move. А именно, мы копируем объект на новое место через memcpy и забываем про первоначальный объект, не вызывая для него деструктор. Правда, таким образом нельзя перемещать локальные переменные, так как компилятор вызывает их деструкторы за нас, не спрашивая, и у этой проблемы нет простого решения.

Атрибут можно применить к вашим классам при их определении. На практике атрибут будет нужен нечасто: компилятор автоматически помечает класс [[trivially_relocatable]] , если все его члены являются таковыми, и вы не определили кастомные move-конструктор с деструктором (rule of zero). Классы стандартной библиотеки будут помечены [[trivially_relocatable]] для повышения производительности существующего кода, однако какие именно будут помечены, оставляется на усмотрение реализации. std::vector и прочие будут использовать новую функцию relocate_at , которая делает relocation или move, в зависимости от того, что тип поддерживает.

template class [[trivially_relocatable]] unique_ptr < . >; std::vector> v; for (auto x : . ) < // Старые unique_ptr перемещаются через relocation, а не move v.push_back(std::make_unique(x)); >

С proposal есть несколько проблем, которые обсуждаются:

  • Можно пометить класс как [[trivially_relocatable]] , даже если его члены таковыми не являются. Например, таким образом можно сломать std::mutex , обернув его в свой [[trivially_relocatable]] класс
  • У класса всё равно должен быть реализован конструктор копирования (будем добиваться отмены ограничения)
  • Trivially relocatable типы всё равно нельзя передавать в регистрах. Например, std::unique_ptr по-прежнему будет передаваться в функции как указатель на указатель

P2025: Guaranteed NRVO

Рассмотренный выше proposal применим тогда, когда объект приходится перемещать, но можно сделать это эффективнее, чем сейчас. Тем не менее, в том случае указатели на объект всё равно «ломаются». В отличие от него, P2025 позволяет устранить саму причину перемещений в некоторых случаях.

C++17 исключил перемещения, когда мы вычисляем значение в return и тут же возвращаем его. Это называется Return Value Optimization (RVO). P2025 исключает также перемещения, когда мы возвращаем локальную переменную (NRVO). При этом она может быть не-перемещаемой, вроде std::mutex или наших абстрактных типов данных:

widget setup_widget(int x) < return widget(x); // OK, C++17 >widget setup_widget(int x) < auto w = widget(x); w.set_y(process(x)); return w; // OK, P2025 >

Кстати, proposal мой 🙂

P0927: Lazy parameters

Фактически, предлагается аналог @autoclosure из Swift. Параметр функции может быть помечен специальным образом, чтобы соответствующий аргумент при вызове автоматически оборачивался в лямбду. Перемещение при таком способе передачи параметров не происходит, объект создаётся сразу там, где нужно:

void vector::super_emplace_back([] -> T value) < void* p = reserve_memory(); new (p) T(value()); >vector v; v.super_emplace_back(widget()); // нет move v.super_emplace_back([&] < return widget(); >); // под капотом

P0573: Abbreviated lambdas

Это решение более общее, чем предыдущее, и затрагивает также другие проблемные темы. Сокращённый синтаксис лямбда-выражений сделает работу с коллекциями и «ленивыми параметрами» в C++ такой же приятной, как и в нормальных других языках. Правда, с синтаксисом P0573 есть проблемы, но я готов предложить несколько других вариантов, к тому же, более коротких:

// Текущий синтаксис auto add = [&](auto&& x, auto&& y) < return x + y; >; auto dbl = [&](auto&& x) < return x * 2; >; auto life = [&] < return 42; >; // P0573 auto add = [&](x, y) => x + y; auto dbl = [&](x) => x * 2; auto life = [&]() => 42; // Мой #1: из Rust auto add = |x, y| x + y; auto dbl = |x| x * 2; auto life = || 42; // Мой #2 auto add = x y: x + y; auto dbl = x: x * 2; auto life = :42;

На этом всё! Желаю всем предложениям исправить пробелы и быть принятыми в C++23. Любые вопросы, замечания, пожелания оставляйте в комментариях.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *