...

воскресенье, 7 ноября 2021 г.

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

Adblock test (Why?)

Комментариев нет:

Отправить комментарий