Мотивация и использование конструкторов перемещения в C ++

17

Недавно я читал о конструкторах перемещения в C ++ (см., Например, здесь ), и я пытаюсь понять, как они работают и когда мне следует их использовать.

Насколько я понимаю, конструктор перемещения используется для устранения проблем с производительностью, вызванных копированием больших объектов. На странице википедии написано: «Хроническая проблема с производительностью в C ++ 03 - это дорогостоящие и ненужные глубокие копии, которые могут происходить неявно, когда объекты передаются по значению».

Я обычно обращаюсь к таким ситуациям

  • передавая объекты по ссылке, или
  • с помощью интеллектуальных указателей (например, boost :: shared_ptr) для передачи объекта (интеллектуальные указатели копируются вместо объекта).

В каких ситуациях описанные выше два метода недостаточны и использование конструктора перемещения более удобно?

Джорджио
источник
1
Помимо того факта, что семантика перемещения может достичь гораздо большего (как сказано в ответах), вам не следует спрашивать, в каких ситуациях передача по ссылке или с помощью умного указателя недостаточна, но если эти методы действительно являются наилучшим и наиболее чистым способом сделать это (дай бог, shared_ptrпросто ради быстрого копирования), и если семантика перемещения может достичь того же самого, практически без штрафа за кодирование, семантику и чистоту.
Крис говорит восстановить Монику

Ответы:

16

Семантика перемещения представляет собой целое измерение в C ++ - оно не просто позволяет дешево возвращать значения.

Например, без семантики перемещения std::unique_ptrне работает - посмотрите std::auto_ptr, что устарело с введением семантики движения и удалено в C ++ 17. Перемещение ресурса сильно отличается от его копирования. Это позволяет передать право собственности на уникальный предмет.

Например, давайте не будем смотреть std::unique_ptr, так как это довольно хорошо обсуждено. Давайте посмотрим, скажем, на объект буфера вершин в OpenGL. Буфер вершин представляет память на графическом процессоре - его нужно распределять и освобождать с помощью специальных функций, возможно, с жесткими ограничениями на то, как долго он может жить. Также важно, чтобы его использовал только один владелец.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Теперь это может быть сделано с std::shared_ptr- но этот ресурс не подлежит обмену. Это затрудняет использование общего указателя. Вы можете использовать std::unique_ptr, но это все еще требует семантики перемещения.

Очевидно, я не реализовал конструктор перемещения, но вы поняли идею.

Здесь важно то, что некоторые ресурсы не копируются . Вы можете передавать указатели вместо перемещения, но если вы не используете unique_ptr, возникает проблема владения. Стоит быть настолько ясным, насколько это возможно, в отношении цели кода, поэтому, вероятно, лучшим подходом является конструктор перемещения.

Максимум
источник
Спасибо за ответ. Что произойдет, если здесь использовать общий указатель?
Джорджио
Я пытаюсь ответить самому себе: использование общего указателя не позволит контролировать время жизни объекта, в то время как требуется, чтобы объект мог жить только определенное количество времени.
Джорджио
3
@ Джорджио Вы можете использовать общий указатель, но это будет семантически неправильно. Невозможно поделиться буфером. Кроме того, это по сути заставит вас передать указатель на указатель (поскольку vbo - это в основном уникальный указатель на память GPU). Кто-то, просматривающий ваш код позже, может спросить: «Почему здесь общий указатель? Это общий ресурс? Это может быть ошибкой! Лучше быть настолько ясным, насколько это возможно, относительно первоначального намерения.
Макс
@ Джорджио Да, это тоже часть требования. Когда «рендер» в этом случае хочет освободить некоторый ресурс (возможно, недостаточно памяти для новых объектов в графическом процессоре), не должно быть никаких других дескрипторов памяти. Использование shared_ptr, которое выходит из области видимости, будет работать, если вы не будете хранить его где-либо еще, но почему бы не сделать его совершенно очевидным, когда сможете?
Макс
@ Джорджио См. Мое изменение для другой попытки разъяснения.
Макс
5

Семантика перемещения не обязательно является большим улучшением, когда вы возвращаете значение - и когда / если вы используете shared_ptr(или что-то подобное), вы, вероятно, преждевременно пессимизируете. В действительности, почти все достаточно современные компиляторы выполняют то, что называется оптимизацией возвращаемого значения (RVO) и оптимизацией именованного возврата (NRVO). Это означает, что когда вы возвращаете значение, а не копируете его вообщеони просто передают скрытый указатель / ссылку на то, где значение будет назначено после возврата, и функция использует его для создания значения, в котором оно должно закончиться. Стандарт C ++ включает в себя специальные положения, позволяющие это сделать, поэтому даже если (например) ваш конструктор копирования имеет видимые побочные эффекты, нет необходимости использовать конструктор копирования для возврата значения. Например:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

Основная идея здесь довольно проста: создать класс с достаточным количеством контента, который мы бы по возможности избегали копировать ( std::vectorмы заполняем 32767 случайных целых). У нас есть явная копия ctor, которая покажет нам, когда / если она будет скопирована. У нас также есть немного больше кода, чтобы сделать что-то со случайными значениями в объекте, так что оптимизатор не будет (по крайней мере легко) устранять все в классе только потому, что он ничего не делает.

Затем у нас есть некоторый код для возврата одного из этих объектов из функции, а затем мы используем суммирование, чтобы убедиться, что объект действительно создан, а не просто полностью проигнорирован. Когда мы запускаем его, по крайней мере, с самыми последними / современными компиляторами, мы обнаруживаем, что созданный нами конструктор копирования никогда не запускается вообще - и да, я уверен, что даже быстрое копирование с a shared_ptrпо-прежнему медленнее, чем без копирования совсем.

Перемещение позволяет вам делать значительное количество вещей, которые вы просто не можете сделать (напрямую) без них. Рассмотрим часть «слияния» внешнего вида слияния - скажем, у вас есть 8 файлов, которые вы собираетесь объединить вместе. В идеале вы хотели бы поместить все 8 из этих файлов в vector- но, поскольку vector( начиная с C ++ 03) необходимо иметь возможность копировать элементы, а ifstreams не может быть скопировано, вы застряли с некоторымиunique_ptr / shared_ptr, или что-то в этом порядке, чтобы иметь возможность поместить их в вектор. Обратите внимание, что даже если (например) мы reserveпоместим в пробел, vectorтак что мы уверены, что наши ifstreams никогда не будут скопированы, компилятор об этом не узнает, поэтому код не скомпилируется, даже если мы знаем, что конструктор копирования никогда не будет использовал в любом случае.

Хотя это все еще не может быть скопировано, в C ++ 11 ifstream может быть перемещено. В этом случае объекты, вероятно , никогда не будут перемещены, но тот факт, что они могут быть в случае необходимости, делает компилятор счастливым, поэтому мы можем помещать наши ifstreamобъекты vectorнапрямую, без каких-либо умных указателей.

Вектор, который действительно расширяется, является довольно приличным примером того времени, когда семантика перемещения действительно может быть / полезна. В этом случае RVO / NRVO не поможет, потому что мы не имеем дело с возвращаемым значением из функции (или чем-то очень похожим). У нас есть один вектор, содержащий несколько объектов, и мы хотим переместить эти объекты в новый, больший кусок памяти.

В C ++ 03 это было сделано путем создания копий объектов в новой памяти, а затем уничтожения старых объектов в старой памяти. Однако сделать все эти копии просто для того, чтобы выбросить старые, было пустой тратой времени. В C ++ 11 вы можете ожидать их перемещения. Как правило, это позволяет нам, по сути, делать поверхностную копию вместо (как правило, намного более медленной) глубокой копии. Другими словами, со строкой или вектором (только для пары примеров) мы просто копируем указатель (и) в объектах, а не копируем все данные, на которые ссылаются эти указатели.

Джерри Гроб
источник
Спасибо за очень подробное объяснение. Если я правильно понимаю, все ситуации, в которых движение вступает в игру, могут обрабатываться обычными указателями, но было бы небезопасно (сложно и подвержено ошибкам) ​​программировать все манипуляции с указателями каждый раз. Таким образом, вместо этого есть некоторый unique_ptr (или подобный механизм) под капотом, и семантика перемещения гарантирует, что в конце дня будет только некоторое копирование указателя и никакое копирование объекта.
Джорджио
@ Джорджио: Да, это в значительной степени правильно. Язык на самом деле не добавляет семантику перемещения; это добавляет rvalue ссылки. Ссылка на rvalue (очевидно, достаточно) может связываться с rvalue, и в этом случае вы знаете, что безопасно «украсть» его внутреннее представление данных и просто скопировать его указатели вместо того, чтобы делать глубокую копию.
Джерри Гроб
4

Рассмотреть возможность:

vector<string> v;

При добавлении строк в v он будет расширяться по мере необходимости, и при каждом перераспределении строки должны будут копироваться. С конструкторами перемещения это в основном не проблема.

Конечно, вы также можете сделать что-то вроде:

vector<unique_ptr<string>> v;

Но это будет хорошо работать только потому, что std::unique_ptr реализует конструктор перемещения.

Использование std::shared_ptrимеет смысл только в (редких) ситуациях, когда вы фактически имеете общую собственность.

Неманья Трифунович
источник
но что, если вместо того, stringчтобы иметь экземпляр Foo30 элементов данных? unique_ptrВерсия не будет более эффективной?
Вассилис
2

Возвращаемые значения - это то, где я чаще всего хотел бы передавать по значению вместо какой-либо ссылки. Было бы неплохо быстро вернуть объект «в стек» без значительного снижения производительности. С другой стороны, это не особенно сложно обойти (общие указатели просто так просты в использовании ...), поэтому я не уверен, что действительно стоит проделать дополнительную работу над моими объектами, чтобы иметь возможность это делать.

Майкл Кон
источник
Я также обычно использую умные указатели, чтобы обернуть объекты, возвращаемые функцией / методом.
Джорджио
1
@ Джорджио: Это определенно запутанно и медленно.
DeadMG
Современные компиляторы должны выполнять автоматическое перемещение, если вы возвращаете простой объект в стеке, поэтому нет необходимости в общих ptrs и т. Д.
Кристиан Северин