Что такое идиома копирования и обмена (copy-and-swap)
Обзор
Зачем нам нужна идиома копирования и обмена?
Любой класс, который управляет ресурсом (обертка, как, например, умный указатель), должен реализовывать «правило трех». В то время как цели и реализация конструктора копирования и деструктора просты, оператор присваивания копированием имеет свои нюансы и сложности. Как он должен быть выполнен? Каких подводных камней нужно избегать?
Идиома копирования и обмена – это решение, которое элегантно помогает оператору присваивания достичь двух вещей: избежать дублирования кода и обеспечить строгую гарантию безопасности исключений.
Как это работает?
Концептуально данная идиома работает, используя функциональные возможности конструктора копирования для создания локальной копии данных, затем берет скопированные данные с помощью функции обмена 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;
}
- Почему мы устанавливаем для
mArray
значениеnull
? Потому что, если какой-либо следующий код в операторе выдает исключение, может быть вызван деструкторdumb_array
; и если это произойдет, не установив для него значениеnull
, мы попытаемся удалить память, которая уже была удалена! Мы избегаем этого, установив для него значениеnull
, поскольку удалениеnull
ничего не делает.
И мы говорим, что закончили; этот класс теперь управляет массивом без утечек памяти. Однако он страдает от трех проблем, последовательно отмеченных в коде как (n).
- Проверка на самоприсваивание. Данная проверка служит двум целям: это простой способ предотвратить запуск ненужного кода при самоприсваивании и защищает нас от мелких ошибок (таких как удаление массива только для того, чтобы попытаться скопировать его). Но во всех остальных случаях это просто служит для замедления программы и действует как шум в коде; самоприсваивание происходит редко, поэтому в большинстве случаев эта проверка является пустой тратой времени. Было бы лучше, если бы оператор мог нормально работать и без нее.
- Это обеспечивает только базовую гарантию безопасности исключений. Если
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; }
- Код расширился! Это приводит нас к третьей проблеме: дублирование кода.
Наш оператор присваивания эффективно дублирует весь код, который мы уже написали где-то еще, и это ужасно.
В нашем случае его ядро – это всего две строки (размещение и копирование), но с более сложными ресурсами это раздувание кода может стать серьезной проблемой. Мы должны стремиться никогда не повторяться.
Можно задаться вопросом: если для правильного управления одним ресурсом требуется столько кода, что, если мой класс управляет более несколькими?
Хотя это может показаться серьезной проблемой и действительно требует нетривиальных условий 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);
}
// ...
};
- Конструктор перемещения, как правило, должен быть
noexcept
, иначе какой-либо код (например, логика изменения размераstd::vector
) будет использовать конструктор копирования, даже если имеет смысл перемещение. Конечно, отмечайте его какnoexcept
только в том случае, если код внутри не генерирует исключения.
Что тут происходит? Вспомните цель конструктора перемещения: взять ресурсы из другого экземпляра класса, оставив его в состоянии, которое гарантированно может быть присвоено и разрушаемо.
Итак, то, что мы сделали, очень просто: инициализируем с помощью конструктора по умолчанию (функция C++11), а затем меняем местами с other
; мы знаем, что созданный по умолчанию экземпляр нашего класса может быть безопасно присвоен и уничтожен, поэтому мы знаем, что с other
можно сделать то же самое после замены.
Почему это работает?
Это единственное изменение, которое нам нужно внести в наш класс, так почему это работает? Вспомните важное решение, которое мы приняли, чтобы сделать параметр значением, а не ссылкой:
dumb_array& operator=(dumb_array other); // (1)
Теперь, если other
инициализируется с помощью r-значения, он будет создан с перемещением. Идеально. Точно так же, как C++03 позволяет нам повторно использовать наш функционал конструктора копирования, принимая аргумент по значению, C++11 автоматически выбирает конструктор перемещения, когда это необходимо.
На этом всё с идиомой копирования и обмена.