Синтаксис си/с++

Move-семантика: rvalue-ссылки, std::move, move-конструкторы

Введение

До этой статьи все примеры с копированием объектов (про конструктор копирования концептуально) предполагали полное, «глубокое» копирование данных — move-семантика (C++11) предоставляет альтернативу: эффективную «передачу» внутренних ресурсов объекта без их реального копирования, когда исходный объект больше не нужен после этой операции, и в этой статье разберём rvalue-ссылки, std::move, и move-конструкторы, реализующие эту оптимизацию.

Концепция

Rvalue-ссылка (T&&) — особый вид ссылки, способный связываться именно с временными значениями (rvalues — выражениями, не имеющими постоянного, именуемого «адреса» в обычном понимании, как результат вызова функции или явное временное значение), в отличие от обычной ссылки (T&, технически lvalue-ссылки), связывающейся с уже существующими, поименованными переменными. std::move() не выполняет реального перемещения данных сам по себе — это просто явное приведение типа, превращающее обычную lvalue-ссылку в rvalue-ссылку, сигнализирующее компилятору и коду, обрабатывающему этот объект далее, что исходный объект больше не нужен в его текущем состоянии и его внутренние ресурсы можно безопасно «украсть» (переместить) вместо дорогого, полного копирования. Move-конструктор (и move-оператор присваивания) — специальная перегрузка, принимающая rvalue-ссылку (T&&) на объект того же класса, реализующая эффективную передачу владения внутренними ресурсами (указателями на динамически выделенную память) от исходного объекта к новому, оставляя исходный объект в валидном, но обычно «опустошённом» состоянии, вместо дорогого копирования всего содержимого.

Пример кода

#include <iostream>
#include <cstring>

class StringBuffer {
public:
    StringBuffer(const char *text)
    {
        m_size = strlen(text) + 1;
        m_data = new char[m_size];
        strcpy(m_data, text);
        std::cout << "Конструктор: выделена память для "" << text << """ << std::endl;
    }

    // Конструктор копирования — ДОРОГОЕ, полное копирование данных
    StringBuffer(const StringBuffer &other) : m_size(other.m_size)
    {
        m_data = new char[m_size];
        strcpy(m_data, other.m_data); // РЕАЛЬНОЕ копирование всех байт содержимого
        std::cout << "Конструктор КОПИРОВАНИЯ — дорогое полное копирование" << std::endl;
    }

    // Move-конструктор — ДЁШЕВАЯ передача владения, без копирования данных
    StringBuffer(StringBuffer &&other) noexcept : m_data(other.m_data), m_size(other.m_size)
    {
        other.m_data = nullptr; // исходный объект "опустошается" — он больше НЕ владеет этой памятью
        other.m_size = 0;
        std::cout << "Конструктор ПЕРЕМЕЩЕНИЯ — дёшево, без копирования данных" << std::endl;
    }

    ~StringBuffer() { delete[] m_data; } // delete[] безопасен даже для m_data == nullptr

private:
    char *m_data;
    size_t m_size;
};

StringBuffer createBuffer()
{
    return StringBuffer("Временный буфер"); // возвращаемое временное значение — rvalue
}

int main()
{
    StringBuffer original("Оригинал"); // обычный конструктор

    StringBuffer copy = original;             // вызовет конструктор КОПИРОВАНИЯ — original используется дальше
    StringBuffer moved = std::move(original); // вызовет конструктор ПЕРЕМЕЩЕНИЯ — explicit "украдём" данные

    StringBuffer fromFunction = createBuffer(); // компилятор обычно использует ПЕРЕМЕЩЕНИЕ для временного значения

    return 0;
}

Пояснения к коду

Сравнение конструктора копирования (StringBuffer(const StringBuffer &other)) и move-конструктора (StringBuffer(StringBuffer &&other)) явно показывает суть оптимизации — копирование выполняет реальное выделение новой памяти и побайтовое копирование всего содержимого (strcpy), тогда как перемещение просто «перенимает» уже существующий указатель m_data от исходного объекта, оставляя его в опустошённом, безопасном для последующего уничтожения состоянии (nullptr), что избегает дорогостоящего реального копирования данных целиком. std::move(original) явно сигнализирует компилятору выбрать move-конструктор вместо обычного конструктора копирования для moved — без этого явного std::move() компилятор использовал бы более безопасный по умолчанию конструктор копирования, поскольку original сам по себе является обычной, «поименованной» переменной (lvalue), а не временным значением, для которого компилятор автоматически выбрал бы move-семантику самостоятельно, без необходимости в явном std::move(). createBuffer(), возвращающая временный объект, демонстрирует случай, где компилятор обычно автоматически применяет move-семантику (или, в современных компиляторах, ещё более эффективную оптимизацию — elision копирования вовсе) без необходимости в явном std::move(), поскольку временное значение, возвращаемое функцией, само по себе является rvalue, для которого move-конструктор выбирается естественно.

Подводные камни

  • Использование объекта после std::move() без понимания, что он находится в «опустошённом», не полностью определённом для конкретной логики использования состоянии — после std::move(original) объект original всё ещё технически валиден (его деструктор корректно вызовется, например), но его конкретное содержимое после перемещения, как правило, не предназначено для дальнейшего значимого использования (как видно из примера, где m_data объекта original становится nullptr), и обращение к перемещённому объекту, ожидая его прежнего, до-перемещения содержимого, является логической ошибкой (хотя не обязательно неопределённым поведением, если move-конструктор корректно реализован), способной привести к неожиданному поведению программы.
  • Отсутствие noexcept на move-конструкторе — некоторые операции стандартной библиотеки (в частности, std::vector при необходимости перевыделения внутреннего буфера) предпочитают использовать move-семантику только если move-конструктор гарантированно не выбрасывает исключений (помечен как noexcept), и без этой пометки стандартная библиотека может «осторожно» откатиться к использованию более медленного конструктора копирования вместо ожидаемого, более эффективного move-конструктора именно из соображений безопасности (избежание ситуации, где исключение во время перемещения части элементов оставило бы контейнер в несогласованном, наполовину перемещённом состоянии).
  • Реализация move-конструктора без корректного «опустошения» исходного объекта, что приводит к ситуации, когда и исходный, и новый объект «считают» себя владельцами одного и того же ресурса (как указателя на динамически выделенную память) — это классическая ошибка, приводящая к двойному освобождению (double free) того же ресурса при последующем уничтожении обоих объектов (исходного, не опустошённого, и нового, получившего тот же указатель), что является серьёзной, потенциально приводящей к краху или повреждению памяти ошибкой.
  • Излишнее, не оправданное использование std::move() для объектов, которые в дальнейшем коде всё равно используются по их обычному, неперемещённому значениюstd::move() явно сигнализирует о намерении «отказаться» от дальнейшего значимого использования исходного объекта в его текущем виде, и применение его к объекту, который сразу же после этого используется обычным образом (ожидая его прежнего содержимого), является логической ошибкой использования move-семантики, подрывающей сам смысл этой оптимизации и потенциально приводящей к использованию уже «опустошённого» объекта там, где разработчик не предполагал такого эффекта.