Что такое идиома копирования и обмена (copy-and-swap)

Добавлено 23 июля 2021 в 00:20

Обзор

Зачем нам нужна идиома копирования и обмена?

Любой класс, который управляет ресурсом (обертка, как, например, умный указатель), должен реализовывать «правило трех». В то время как цели и реализация конструктора копирования и деструктора просты, оператор присваивания копированием имеет свои нюансы и сложности. Как он должен быть выполнен? Каких подводных камней нужно избегать?

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

Как это работает?

Концептуально данная идиома работает, используя функциональные возможности конструктора копирования для создания локальной копии данных, затем берет скопированные данные с помощью функции обмена swap, заменяя старые данные новыми данными. Затем временная копия разрушается, забирая с собой старые данные. И у нас остается копия новых данных.

Чтобы использовать идиому копирования и обмена, нам нужны три вещи: рабочий конструктор копирования, рабочий деструктор (оба являются основой любой обертки, поэтому в любом случае должны быть реализованы) и функция обмена swap.

Функция обмена – это функция, не вызывающая выброса исключений, которая меняет местами два объекта класса, почленно. У нас может возникнуть соблазн использовать std::swap вместо предоставления собственной реализации, но это невозможно; std::swap в своей реализации использует конструктор копирования и оператор присваивания копированием, и в конечном итоге мы попытаемся определить оператор присваивания на основе самого себя!

Нашу пользовательскую функцию swap будет использовать не только это, но и неквалифицированные вызовы swap, пропуская ненужное построение и разрушение объектов нашего класса, которое повлечет за собой std::swap.

Подробное объяснение

Цель

Рассмотрим конкретный случай. Мы хотим управлять динамическим массивом в бесполезном для других случаев классе. Начнем с рабочего конструктора, конструктора копирования и деструктора:

#include <algorithm> // std::copy
#include <cstddef>   // std::size_t

class dumb_array
{
public:
    // конструктор по умолчанию
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // конструктор копирования
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr)
    {
        // Обратите внимание, что он не выбрасывает исключения
        // из-за используемых типов данных.
        // Однако в более общем случае исключениям необходимо
        // уделить больше внимания.
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // деструктор
    ~dumb_array()
    {
        delete[] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

Этот класс почти успешно управляет массивом, но для правильной работы ему необходим operator=.

Неудачное решение

Вот как может выглядеть наивная реализация:

dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // избавляемся от старых данных...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) (i)

        // ... и вставляем новые
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}
  1. Почему мы устанавливаем для mArray значение null? Потому что, если какой-либо следующий код в операторе выдает исключение, может быть вызван деструктор dumb_array; и если это произойдет, не установив для него значение null, мы попытаемся удалить память, которая уже была удалена! Мы избегаем этого, установив для него значение null, поскольку удаление null ничего не делает.

И мы говорим, что закончили; этот класс теперь управляет массивом без утечек памяти. Однако он страдает от трех проблем, последовательно отмеченных в коде как (n).

  1. Проверка на самоприсваивание. Данная проверка служит двум целям: это простой способ предотвратить запуск ненужного кода при самоприсваивании и защищает нас от мелких ошибок (таких как удаление массива только для того, чтобы попытаться скопировать его). Но во всех остальных случаях это просто служит для замедления программы и действует как шум в коде; самоприсваивание происходит редко, поэтому в большинстве случаев эта проверка является пустой тратой времени. Было бы лучше, если бы оператор мог нормально работать и без нее.
  2. Это обеспечивает только базовую гарантию безопасности исключений. Если new int[mSize] завершится неудачей, *this будет изменен (а именно будет некорректным значение размера и пропадут данные). Для строгой гарантии безопасности исключений это должно быть что-то вроде этого:
    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // получаем новые данные перед заменой старых
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr;    // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // заменяем старые данные (исключения не выбрасываются)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
  3. Код расширился! Это приводит нас к третьей проблеме: дублирование кода.

Наш оператор присваивания эффективно дублирует весь код, который мы уже написали где-то еще, и это ужасно.

В нашем случае его ядро – это всего две строки (размещение и копирование), но с более сложными ресурсами это раздувание кода может стать серьезной проблемой. Мы должны стремиться никогда не повторяться.

Можно задаться вопросом: если для правильного управления одним ресурсом требуется столько кода, что, если мой класс управляет более несколькими?

Хотя это может показаться серьезной проблемой и действительно требует нетривиальных условий try/catch, это не проблема.

Это потому, что класс должен управлять только одним ресурсом!

Удачное решение

Как уже упоминалось, идиома копирования и обмена исправит все эти проблемы. Но прямо сейчас у нас есть всё необходимое, кроме одного: функции обмена swap. Хотя правило трех успешно влечет за собой существование нашего конструктора копирования, оператора присваивания и деструктора, его на самом деле следует называть «правило трех с половиной»: каждый раз, когда ваш класс управляет ресурсом, также имеет смысл предоставить функцию обмена swap.

Итак, нам нужно добавить в наш класс функцию обмена, и сделаем это следующим образом:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // в нашем случае это не обязательно, но рекомендуется
        using std::swap;

        // меняя местами члены двух объектов,
        // два объекта эффективно меняются местами
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

Теперь мы можем не только поменять местами наши dumb_array, но и в целом обмен может быть более эффективным; этот код просто меняет местами указатели и значения размеров, а не размещает и копирует целые массивы. Помимо этого бонуса в функциональности и эффективности, теперь мы готовы реализовать идиому копирования и обмена.

Без лишних слов, вот наш оператор присваивания:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

Вот и всё! Одним махом можно элегантно решить сразу все три проблемы.

Почему это работает?

Сначала мы замечаем важный выбор: аргумент параметра берется по значению. Хотя с таким же успехом можно было бы сделать следующее (как и многие простые реализации этой идиомы):

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

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

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

Обратите внимание, что при входе в функцию все новые данные уже размещены, скопированы и готовы к использованию. Это то, что дает нам строгую гарантию безопасности исключений бесплатно: если построение копии завершится неудачно, мы даже не войдем в функцию, и, следовательно, будет невозможно изменить состояние *this. (То, что мы раньше делали вручную для строгой гарантии безопасности исключений, теперь делает за нас компилятор; как любезно с его стороны.)

На данный момент мы не привязаны к чему-либо, потому что swap не выбрасывает исключений. Мы меняем наши текущие данные на скопированные, безопасно изменяя свое состояние, а старые данные помещаются во временные. После возврата из функции старые данные удаляются (где заканчивается область видимости параметра и вызывается его деструктор).

Поскольку идиома не повторяет код, мы не можем внести ошибки в оператор. Обратите внимание, что это означает, что мы избавляемся от необходимости проверки на самоприсваивание, позволяя использовать единую унифицированную реализацию operator= (кроме того, у нас больше нет дополнительных затрат для присваиваний, выполняемых не самим себе).

Это и есть идиома копирования и обмена.

А как насчет C++11?

C++11 вносит одно очень важное изменение в то, как мы управляем ресурсами: правило трех теперь является правилом четырех (с половиной). Почему? Потому что нам нужно не только иметь возможность создавать наш ресурс копированием, нам нужно также создавать его перемещением.

К счастью для нас, это легко:

class dumb_array
{
public:
    // ...

    // конструктор перемещения
    dumb_array(dumb_array&& other) noexcept // (i)
        : dumb_array() // инициализировать через конструктор по умолчанию, только C++11
    {
        swap(*this, other);
    }

    // ...
};
  1. Конструктор перемещения, как правило, должен быть noexcept, иначе какой-либо код (например, логика изменения размера std::vector) будет использовать конструктор копирования, даже если имеет смысл перемещение. Конечно, отмечайте его как noexcept только в том случае, если код внутри не генерирует исключения.

Что тут происходит? Вспомните цель конструктора перемещения: взять ресурсы из другого экземпляра класса, оставив его в состоянии, которое гарантированно может быть присвоено и разрушаемо.

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

Почему это работает?

Это единственное изменение, которое нам нужно внести в наш класс, так почему это работает? Вспомните важное решение, которое мы приняли, чтобы сделать параметр значением, а не ссылкой:

dumb_array& operator=(dumb_array other); // (1)

Теперь, если other инициализируется с помощью r-значения, он будет создан с перемещением. Идеально. Точно так же, как C++03 позволяет нам повторно использовать наш функционал конструктора копирования, принимая аргумент по значению, C++11 автоматически выбирает конструктор перемещения, когда это необходимо.

На этом всё с идиомой копирования и обмена.

Теги

C++ / CppИдиома копирования и обмена / copy-and-swapИдиомы C++Конструктор копирования

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

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