Здравствуйте уважаемые читатели. Данная публикация адресована начинающим разработчикам С++ которые только становятся на путь высокой производительности и "отстрелянных конечностей". Опытные разработчики найдут здесь скорее повторение тех вещей, которые сами мучительно осваивали в те далекие времена, когда в языке С++ появилась возможность удобного перемещения объектов.
Многие из вас уже слышали и надеюсь использовали функцию 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 << id_ << ": "; std::cout << "operation time" << ": " << std::chrono::duration_cast<std::chrono::milliseconds>(dur).count() << " ms" << std::endl; } private: const std::string id_; const std::chrono::steady_clock::time_point start_time_ = std::chrono::steady_clock::now(); };
Не пугайтесь, он нам будет нужен только как условный секундомер для экспериментов. Чтобы с его помощью оценить время выполнения операции достаточно сделать так:
{ LogDuration ld("identifier"); // some operations }
где фигурные скобки задают область видимости. При выходе за ее пределы запускаются деструкторы классов для объектов (исключение - статичные переменные), которые были созданы внутри данной области, в том числе и ~LogDuration(), который покажет время выполнения операций внутри блока.
Итак, начнем экспериментировать.
Говорят, что для векторов и строк (std::string) нужно по возможности использовать move. Проверим. Напишем такой код:
int main() { vector<uint8_t> big_vector(1e9, 0); { LogDuration ld("vector copy"); vector<uint8_t> reciever(big_vector); } cout << "size of big_vector is " << big_vector.size() << '\n'; }
Здесь мы создаем вектор 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<uint8_t> reciever(big_vector); + vector<uint8_t> 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 <typename T> class Vector { public: Vector(size_t size, T value) : data_(size, value) { } Vector(const Vector& rhs) { cout << "copy constructor was called\n"; } Vector(Vector&& rhs) noexcept { cout << "move constructor was called\n"; } size_t size() { return data_.size(); } private: vector<T> data_; };
Также не пугайтесь, тут нужно смотреть на то, что внутри секции public. Добавьте этот код перед main() в вашей программе, а внутри main замените первую букву в слове vector на заглавную везде, где он упоминается. Для случая:
Vector<uint8_t> 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 << &text << '\n'; // 0x7ffdfd45dce0
Теперь смотрите, для того, чтобы функция MoveFoo приняла аргумент, он "не должен иметь адреса", как "another text" например. Такие объекты еще называют временными. Теперь мы можем подойти к тому моменту когда можно сказать, что делает функция move - она делает так, что ее аргумент притворяется "безадресным", т.е. временным, поэтому 14-я строка нормально компилируется. И если внутри функции MoveFoo ничего с text не делать, то он сам по себе никуда не пропадет, не перенесется, не исчезнет. Но зачем же тогда спрашивается все телодвижения? А вот если написать:
void MoveFoo(string&& text) { string tmp(move(text)); }
то после выполнения данной функции переменная text во внешнем блоке окажется пустой (компилятор gcc 7.5 c++17), как в самом начале для случая с перемещением вектора.
Теперь вернемся к вопросу почему исходный вектор "переместился" в новый вектор за такое короткое время?
У нас есть некоторые наблюдения: при использовании move памяти затрачивалось практически вровень размеру исходного массива.
Представим вектор как структуру данных, которая в самом упрощенном варианте хранит адрес (указатель) на место в памяти, где находятся все его элементы. Мы же помним, что в векторе все элементы расположены в памяти последовательно, без разрывов. А вторым полем будет переменная, хранящая текущий размер вектора. Также мы знаем, что после операции "перемещения" исходный вектор оказывается пустым. А теперь представьте, что встречаются два вектора - один с набором из 10^9 элементов, второй пустой. Самое простое решение им взять и "обменяться" своим содержимым. Новый просто изменит свой адрес, указывающий на начало блока данных на тот, что был у исходного. Также обновит свой размер. А исходный примет такие же поля от пустого вектора. Все просто. Если пройтись отладчиком по цепочке от конструктора перемещения, то можно обнаружить такой код в стандартной библиотеке в файле stl_vector.h:
void _M_swap_data(_Vector_impl& __x) _GLIBCXX_NOEXCEPT { std::swap(_M_start, __x._M_start); std::swap(_M_finish, __x._M_finish); std::swap(_M_end_of_storage, __x._M_end_of_storage); }
Там конечно, все намного сложнее, но общий принцип примерно таков.
Очень надеюсь, что теперь основные моменты использования move для вас прояснились. Дальше рекомендую уже ознакомиться с более научными работами по использованию move семантики, где легко, надеюсь, уловите аналогии с lvalue, rvalue и т.п. А более опытным разработчикам - если дочитали до конца, буду рад услышать Ваши комментарии и замечания.
