Просто о сложном — 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(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 big_vector(1e9, 0);
{
LogDuration ld("vector copy");
vector 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 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 << "copy constructor was called\n";
}
Vector(Vector&& rhs) noexcept {
cout << "move constructor was called\n";
}
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 << &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 и т.п. А более опытным разработчикам — если дочитали до конца, буду рад услышать Ваши комментарии и замечания.