Просто о сложном — 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 << 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 и т.п. А более опытным разработчикам — если дочитали до конца, буду рад услышать Ваши комментарии и замечания.


ссылка на оригинал статьи https://habr.com/ru/articles/587644/


Комментарии

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

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