M.3 – Конструкторы перемещения и присваивание перемещением

Добавлено 18 сентября 2021 в 10:45

В уроке «M.1 – Введение в умные указатели и семантику перемещения» мы рассмотрели std::auto_ptr, обсудили необходимость семантики перемещения и рассмотрели некоторые недостатки, которые возникают, когда функции, разработанные для семантики копирования (конструкторы копирования и операторы присваивания копированием) переопределяются для реализации семантики перемещения.

В этом уроке мы более подробно рассмотрим, как C++11 решает эти проблемы с помощью конструкторов перемещения и присваивания перемещением.

Конструкторы копирования и присваивание копированием

Во-первых, давайте сделаем обзор семантики копирования.

Конструкторы копирования используются для инициализации класса путем создания копии объекта того же класса. Присваивание копированием используется для копирования одного объекта класса в другой существующий объект класса. По умолчанию, если конструктор копирования и оператор присваивания копированием не указаны явно, C++ предоставляет их. Эти предоставляемые компилятором функции создают поверхностные копии, что может вызывать проблемы для классов, динамически выделяющих память. Таким образом, классы, которые имеют дело с динамической памятью, должны переопределять эти функции для создания глубоких копий.

Возвращаясь к нашему примеру класса умного указателя Auto_ptr из первого урока этой главы, давайте рассмотрим версию, которая реализует конструктор копирования и оператор присваивания копированием, которые делают глубокие копии, и пример программы, которая их проверяет:

template<class T>
class Auto_ptr3
{
    T* m_ptr;
public:
    Auto_ptr3(T* ptr = nullptr)
        :m_ptr(ptr)
    {
    }

    ~Auto_ptr3()
    {
        delete m_ptr;
    }

    // Конструктор копирования
    // Выполняем глубокое копирование a.m_ptr в m_ptr
    Auto_ptr3(const Auto_ptr3& a)
    {
        m_ptr = new T;
        *m_ptr = *a.m_ptr;
    }

    // Присваивание копированием
    // Выполняем глубокое копирование a.m_ptr в m_ptr
    Auto_ptr3& operator=(const Auto_ptr3& a)
    {
        // Обнаружение самоприсваивания
        if (&a == this)
            return *this;

        // Освобождаем любые ресурсы, которые уже храним
        delete m_ptr;

        // Копируем ресурс
        m_ptr = new T;
        *m_ptr = *a.m_ptr;

        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr3<Resource> generateResource()
{
    Auto_ptr3<Resource> res(new Resource);
    return res; // это возвращаемое значение вызовет конструктор копирования
}

int main()
{
    Auto_ptr3<Resource> mainres;
    mainres = generateResource(); // это присваивание вызовет присваивание копированием

    return 0;
}

В этой программе мы используем функцию с именем generateResource() для создания умного указателя, инкапсулирующего ресурс, который затем передается обратно в функцию main(). Затем функция main() присваивает его существующему объекту Auto_ptr3.

Когда эта программа запускается, она печатает:

Resource acquired
Resource acquired
Resource destroyed
Resource acquired
Resource destroyed
Resource destroyed

(Примечание: вы можете получить только 4 сообщения, если ваш компилятор исключает копирование возвращаемого значения из функции generateResource())

Для такой простой программы происходит слишком много созданий и уничтожений объектов Resource! Что тут происходит?

Давайте рассмотрим подробнее. В этой программе выполняется 6 ключевых шагов (по одному для каждого напечатанного сообщения):

  1. Внутри generateResource() создается локальная переменная res, которая инициализируется динамически размещаемым объектом Resource, что приводит к первому сообщению "Resource acquired".
  2. res возвращается обратно в main() по значению. Здесь мы возвращаем по значению, потому что res – это локальная переменная, она ​​не может быть возвращена по адресу или по ссылке, потому что будет уничтожена при завершении generateResource(). Таким образом, res копируется конструктором во временный объект. Поскольку наш конструктор копирования выполняет глубокое копирование, здесь выделяется новый Resource, что приводит ко второму сообщению "Resource acquired".
  3. res выходит за пределы области видимости, уничтожая первоначально созданный Resource, что приводит к первому сообщению "Resource destroyed".
  4. Этот временный объект присваивается mainres путем присваивания копированием. Поскольку наше присваивание копированием также выполняет глубокое копирование, размещается новый Resource, вызывая еще одно сообщение "Resource acquired".
  5. Выражение присваивания завершается, и временный объект выходит за пределы области действия выражения и уничтожается, вызывая сообщение "Resource destroyed".
  6. В конце main() переменная mainres выходит из области видимости, и отображается наше последнее сообщение "Resource destroyed".

Короче говоря, поскольку мы вызываем конструктор копирования один раз, чтобы скопировать res во временный объект, и один раз присваивание копированием для копирования временного объекта в mainres, в итоге мы размещаем и уничтожаем в общей сложности 3 отдельных объекта.

Неэффективно, но, по крайней мере, не дает сбоев!

Однако с семантикой перемещения мы можем добиться большего.

Конструкторы перемещения и присваивание перемещением

C++11 определяет две новые функции, обслуживающие семантику перемещения: конструктор перемещения и оператор присваивания перемещением. В то время как цель конструктора копирования и присваивания копированием – выполнить копирование одного объекта в другой, цель конструктора перемещения и присваивания перемещением – передать владение ресурсами от одного объекта к другому (что обычно намного дешевле, чем создание копии).

Определение конструктора перемещения и присваивания перемещением работают аналогично их аналогам для копирования. Однако в то время как копирующие версии этих функций принимают в качестве параметра константную lvalue-ссылку, перемещающие версии этих функций используют в качестве параметра неконстантные rvalue-ссылки.

Вот тот же класс Auto_ptr3, что и выше, с добавленными конструктором перемещения и оператором присваивания перемещением. Для сравнения мы оставили выполняющие глубокое копирование конструктор копирования и оператор присваивания копированием.

#include <iostream>

template<class T>
class Auto_ptr4
{
    T* m_ptr;
public:
    Auto_ptr4(T* ptr = nullptr)
        :m_ptr(ptr)
    {
    }

    ~Auto_ptr4()
    {
        delete m_ptr;
    }

    // Конструктор копирования
    // Выполняем глубокое копирование a.m_ptr в m_ptr
    Auto_ptr4(const Auto_ptr4& a)
    {
        m_ptr = new T;
        *m_ptr = *a.m_ptr;
    }

    // Конструктор перемещения
    // Передача владения a.m_ptr в m_ptr
    Auto_ptr4(Auto_ptr4&& a) noexcept
        : m_ptr(a.m_ptr)
    {
        a.m_ptr = nullptr; // поговорим об этой строке подробнее ниже
    }

    // Присваивание копированием
    // Выполняем глубокое копирование a.m_ptr в m_pt
    Auto_ptr4& operator=(const Auto_ptr4& a)
    {
        // Обнаружение самоприсваивания
        if (&a == this)
            return *this;

        // Освобождаем любые ресурсы, которые уже храним
        delete m_ptr;

        // Копируем ресурс
        m_ptr = new T;
        *m_ptr = *a.m_ptr;

        return *this;
    }

    // Присваивание перемещением
    // Передача владения a.m_ptr в m_ptr
    Auto_ptr4& operator=(Auto_ptr4&& a) noexcept
    {
        // Обнаружение самоприсваивания
        if (&a == this)
            return *this;

        // Освобождаем любые ресурсы, которые уже храним
        delete m_ptr;

        // Передаем владение a.m_ptr в m_ptr
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr; // поговорим об этой строке подробнее ниже

        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr4<Resource> generateResource()
{
    Auto_ptr4<Resource> res(new Resource);
    return res; // это возвращаемое значение вызовет конструктор перемещения
}

int main()
{
    Auto_ptr4<Resource> mainres;
    mainres = generateResource(); // это присваивание вызовет присваивание перемещением

    return 0;
}

Конструктор перемещения и оператор присваивания перемещением просты. Вместо того, чтобы выполнять глубокое копирование исходного объект (а) в неявный объект this, мы просто перемещаем (крадем) ресурсы исходного объекта. Это включает в себя поверхностное копирование указателя исходного объекта в неявный объект this с последующей установкой для указателя исходного объекта значения nullptr.

При запуске эта программа печатает:

Resource acquired
Resource destroyed

Это намного лучше!

Ход программы точно такой же, как и раньше. Однако вместо вызова конструктора копирования и оператора присваивания копированием эта программа вызывает конструктор перемещения и оператор присваивания перемещением. Рассмотрим немного подробнее:

  1. Внутри generateResource() создается локальная переменная res, которая инициализируется динамически размещаемым объектом Resource, что приводит к первому сообщению "Resource acquired".
  2. res возвращается обратно в main() по значению. res перемещается конструктором во временный объект, передавая динамически созданный объект, хранящийся в res, во временный объект. О том, почему это происходит, мы поговорим ниже.
  3. res выходит из области видимости. Поскольку res больше не управляет указателем (он был перемещен во временный объект), здесь ничего интересного не происходит.
  4. Временный объект перемещается присваиванием в mainres. Это переносит динамически созданный объект, хранящийся во временном объекте, в mainres.
  5. Выражение присваивания завершается, временный объект выходит за пределы области действия выражения и уничтожается. Однако, поскольку временный объект больше не управляет указателем (он был перемещен в mainres), здесь также не происходит ничего интересного.
  6. В конце main() переменная mainres выходит из области видимости, и отображается наше последнее сообщение "Resource destroyed".

Поэтому вместо того, чтобы копировать наш объект Resource дважды (один раз для конструктора копирования и один раз для присваивания копированием), мы дважды перемещаем его. Это более эффективно, поскольку объект Resource создается и уничтожается только один раз, а не три раза.

Когда вызываются конструктор перемещения и присваивание перемещением?

Конструктор перемещения и присваивание перемещением вызываются, когда эти функции определены, а аргументом для построения или присваивания является r-значение. Чаще всего это r-значение будет литералом или временным значением.

В большинстве случаев конструктор перемещения и оператор присваивания перемещением не предоставляются по умолчанию, если в классе нет определенных конструкторов копирования, присваивания копированием, присваивания перемещением или деструкторов. Однако дефолтные конструктор перемещения и присваивание перемещением делают то же самое, что и дефолтные конструктор копирования и присваивание копированием (делать копии, а не перемещают).

Правило


Если вам нужен конструктор перемещения и присваивание перемещением, выполняющее перемещения, вам нужно будет написать их самостоятельно.

Ключевой момент в семантике перемещения

Теперь у вас достаточно контекста для понимания ключевой идеи семантики перемещения.

Если мы создаем объект или выполняем присваивание, в котором аргументом является l-значение, единственное разумное, что мы можем сделать, – это скопировать l-значение. Мы не можем предположить, что изменение l-значения безопасно, потому что позже в программе оно может быть снова использовано. Если у нас есть выражение a = b, мы не можем ожидать каких-либо изменений b.

Однако, если мы создаем объект или выполняем присваивание, в котором аргументом является r-значение, тогда мы знаем, что r-значение – это всего лишь временный объект какого-то типа. Вместо того, чтобы копировать его (что может быть дорогостоящим), мы можем просто передать его ресурсы (что дешево) объекту, который мы создаем или которому выполняем присваивание. Это безопасно, потому что временный объект в любом случае будет уничтожен в конце выражения, поэтому мы знаем, что он больше никогда не будет использоваться!

C++11, через rvalue-ссылки, дает нам возможность обеспечивать различное поведение, когда аргументом является r-значение или l-значение, что позволяет нам принимать более разумные и эффективные решения о том, как должны вести себя наши объекты.

Функции перемещения должны всегда оставлять оба объекта в четко определенном состоянии.

В приведенных выше примерах и конструктор перемещения, и функции присваивания перемещением устанавливают a.m_ptr в значение nullptr. Это может показаться лишним – в конце концов, если a – временное r-значение, зачем беспокоиться о выполнении «очистки», если параметр a всё равно будет уничтожен?

Ответ прост: когда a выходит за пределы области видимости, вызывается деструктор a, и a.m_ptr удаляется. Если в этот момент a.m_ptr всё еще указывает на тот же объект, что и m_ptr, тогда m_ptr останется висячим указателем. Когда объект, содержащий m_ptr, в конечном итоге будет использован (или уничтожен), мы получим неопределенное поведение.

Кроме того, в следующем уроке мы увидим случаи, когда a может быть l-значением. В таком случае a не будет уничтожен немедленно, и его можно будет запросить еще до того, как истечет время его жизни.

Автоматические l-значения, возвращаемые по значению, могут быть перемещены вместо копирования

В функции generateResource() в примере выше с Auto_ptr4, когда переменная res возвращается по значению, она перемещается, а не копируется, даже если res является l-значением. В спецификации C++ есть специальное правило, согласно которому автоматические объекты, возвращаемые функцией по значению, можно перемещать, даже если они являются l-значениями. Это имеет смысл, так как res всё равно будет уничтожен в конце функции! С таким же успехом мы могли бы забрать его ресурсы, вместо того, чтобы выполнять дорогостоящее и ненужное копирование.

Хотя компилятор может перемещать возвращаемые l-значения, в некоторых случаях он может добиться еще большего, просто полностью исключив копирование (что позволяет вовсе избежать необходимости выполнять копирование или перемещение). В таком случае не будут вызываться ни конструктор копирования, ни конструктор перемещения.

Отключение копирования

В приведенном выше классе Auto_ptr4 мы оставили для сравнения конструктор копирования и оператор присваивания. Но в классах с поддержкой перемещения иногда желательно удалить функции конструктора копирования и присваивания копированием, чтобы гарантировать, что копии не будут созданы. В случае с нашим классом Auto_ptr мы не хотим копировать наш шаблонный объект T – потому что это дорого, и класс T может даже не поддерживать копирование!

Вот версия Auto_ptr, которая поддерживает семантику перемещения, но не поддерживает семантику копирования:

#include <iostream>

template<class T>
class Auto_ptr5
{
    T* m_ptr;
public:
    Auto_ptr5(T* ptr = nullptr)
        :m_ptr(ptr)
    {
    }

    ~Auto_ptr5()
    {
        delete m_ptr;
    }

    // Конструктор копирования - копирование запрещено!
    Auto_ptr5(const Auto_ptr5& a) = delete;

    // Конструктор перемещения
    // Передача владения a.m_ptr в m_ptr
    Auto_ptr5(Auto_ptr5&& a) noexcept
        : m_ptr(a.m_ptr)
    {
        a.m_ptr = nullptr;
    }

    // Присваивание копированием - копирование запрещено!
    Auto_ptr5& operator=(const Auto_ptr5& a) = delete;

    // Присваивание перемещением
    // Передача владения a.m_ptr в m_ptr
    Auto_ptr5& operator=(Auto_ptr5&& a) noexcept
    {
        // Обнаружение самоприсваивания
        if (&a == this)
            return *this;

        // Освобождаем любые ресурсы, которые уже храним
        delete m_ptr;

        // Передаем владение a.m_ptr в m_ptr
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr;

        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr; }
};

Если бы вы попытались передать функции l-значение Auto_ptr5 по значению, компилятор пожаловался бы, что конструктор копирования, необходимый для инициализации аргумента функции, был удален. Это хорошо, потому что мы, вероятно, всё равно должны передавать Auto_ptr5 по константной lvalue-ссылке!

Auto_ptr5 – это (наконец) хороший класс умных указателей. И на самом деле стандартная библиотека содержит класс, очень похожий на этот (и который вы должны использовать вместо этого), с именем std::unique_ptr. Подробнее об std::unique_ptr мы поговорим в этой главе позже.

Еще один пример

Давайте посмотрим на другой класс, который использует динамическую память: простой динамический шаблонный массив. Этот класс содержит конструктор копирования и оператор присваивания копированием, выполняющие глубокое копирование.

#include <iostream>

template <class T>
class DynamicArray
{
private:
    T* m_array;
    int m_length;

public:
    DynamicArray(int length)
        : m_array(new T[length]), m_length(length)
    {
    }

    ~DynamicArray()
    {
        delete[] m_array;
    }

    // Конструктор копирования
    DynamicArray(const DynamicArray &arr)
        : m_length(arr.m_length)
    {
        m_array = new T[m_length];
        for (int i = 0; i < m_length; ++i)
            m_array[i] = arr.m_array[i];
    }

    // Присваивание копированием
    DynamicArray& operator=(const DynamicArray &arr)
    {
        if (&arr == this)
            return *this;

        delete[] m_array;

        m_length = arr.m_length;
        m_array = new T[m_length];

        for (int i = 0; i < m_length; ++i)
            m_array[i] = arr.m_array[i];

        return *this;
    }

    int getLength() const { return m_length; }
    T& operator[](int index) { return m_array[index]; }
    const T& operator[](int index) const { return m_array[index]; }

};

Теперь давайте, используем этот класс в программе, чтобы показать, как работает этот класс, когда мы размещаем миллион целых чисел в куче. Мы собираемся использовать класс Timer, который мы разработали в уроке «12.18 – Определение времени выполнения кода». Мы будем использовать его, чтобы измерить скорость выполнения нашего кода и показать вам разницу в производительности между копированием и перемещением.

#include <iostream>
#include <chrono> // для функций std::chrono

// Использует показанный выше класс DynamicArray

class Timer
{
private:
    // Псевдонимы типа, чтобы упростить доступ к вложенному типу
    using clock_t = std::chrono::high_resolution_clock;
    using second_t = std::chrono::duration<double, std::ratio<1> >;

    std::chrono::time_point<clock_t> m_beg;

public:
    Timer() : m_beg(clock_t::now())
    {
    }

    void reset()
    {
        m_beg = clock_t::now();
    }

    double elapsed() const
    {
        return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
    }
};

// Возвращаем копию arr со всеми удвоенными значениями
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
    DynamicArray<int> dbl(arr.getLength());
    for (int i = 0; i < arr.getLength(); ++i)
        dbl[i] = arr[i] * 2;

    return dbl;
}

int main()
{
    Timer t;

    DynamicArray<int> arr(1000000);

    for (int i = 0; i < arr.getLength(); i++)
        arr[i] = i;

    arr = cloneArrayAndDouble(arr);

    std::cout << t.elapsed();
}

На одной из машин автора в режиме релиза эта программа выполнилась за 0,00825559 секунды.

Теперь давайте снова запустим эту же программу, заменив конструктор копирования и присваивание копированием конструктором перемещения и присваиванием перемещением.

#include <iostream>
#include <chrono> // для функций std::chrono

template <class T>
class DynamicArray
{
private:
    T* m_array;
    int m_length;

public:
    DynamicArray(int length)
        : m_array(new T[length]), m_length(length)
    {
    }

    ~DynamicArray()
    {
        delete[] m_array;
    }

    // Конструктор копирования
    DynamicArray(const DynamicArray &arr) = delete;

    // Присваивание копированием
    DynamicArray& operator=(const DynamicArray &arr) = delete;

    // Конструктор перемещения
    DynamicArray(DynamicArray &&arr) noexcept
        : m_length(arr.m_length), m_array(arr.m_array)
    {
        arr.m_length = 0;
        arr.m_array = nullptr;
    }

    // Присваивание перемещением
    DynamicArray& operator=(DynamicArray &&arr) noexcept
    {
        if (&arr == this)
            return *this;

        delete[] m_array;

        m_length = arr.m_length;
        m_array = arr.m_array;
        arr.m_length = 0;
        arr.m_array = nullptr;

        return *this;
    }

    int getLength() const { return m_length; }
    T& operator[](int index) { return m_array[index]; }
    const T& operator[](int index) const { return m_array[index]; }

};


class Timer
{
private:
    // Псевдонимы типа, чтобы упростить доступ к вложенному типу
    using clock_t = std::chrono::high_resolution_clock;
    using second_t = std::chrono::duration<double, std::ratio<1> >;

    std::chrono::time_point<clock_t> m_beg;

public:
    Timer() : m_beg(clock_t::now())
    {
    }

    void reset()
    {
        m_beg = clock_t::now();
    }

    double elapsed() const
    {
        return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
    }
};

// Возвращаем копию arr со всеми удвоенными значениями
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
    DynamicArray<int> dbl(arr.getLength());
    for (int i = 0; i < arr.getLength(); ++i)
        dbl[i] = arr[i] * 2;

    return dbl;
}

int main()
{
    Timer t;

    DynamicArray<int> arr(1000000);

    for (int i = 0; i < arr.getLength(); i++)
        arr[i] = i;

    arr = cloneArrayAndDouble(arr);

    std::cout << t.elapsed();
}

На той же машине эта программа была выполнена за 0,0056 секунды.

Сравним время выполнения этих двух программ, 0,0056 / 0,00825559 = 67,8%. Версия с перемещением была почти на 33% быстрее!

Теги

C++ / Cppl-value / l-значениеLearnCppr-value / r-значениеstd::unique_ptrДля начинающихКонструктор / Constructor / ctor (программирование)Конструктор перемещенияОбучениеОператор присваиванияПрограммированиеСемантика перемещенияУмные указатели

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.