Ответ 1
Обзор
Зачем нам нужна идиома copy-and-swap?
Любой класс, который управляет ресурсом (оболочка, как интеллектуальный указатель), должен реализовать The Three Three. Хотя цели и реализация конструктора-копии и деструктора являются простыми, оператор копирования-назначения, пожалуй, самый нюансный и сложный. Как это сделать? Какие подводные камни нужно избегать?
Идиома копирования и свопинга является решением и элегантно помогает оператору присваивания в достижении двух вещей: избегая дублирования кода и обеспечивая a надежная гарантия исключения.
Как это работает?
Концептуально, он работает с использованием функциональности copy-constructor для создания локальной копии данных, затем берет скопированные данные с помощью функции swap
, заменяя старые данные с новыми данными. Затем временная копия уничтожает, беря с собой старые данные. Мы оставляем копию новых данных.
Для использования идиомы копирования и свопинга нам нужны три вещи: рабочий экземпляр-конструктор, рабочий деструктор (оба являются основой любой оболочки, поэтому должны быть завершены в любом случае) и swap
функция.
Функция подкачки - это функция, не выполняющая металирование, которая обменивает два объекта класса, член для члена. Возможно, у нас возникнет соблазн использовать std::swap
вместо того, чтобы предоставлять свои собственные, но это было бы невозможно; std::swap
использует экземпляр-конструктор и оператор присваивания копий в своей реализации, и мы в конечном итоге попытаемся определить оператор присваивания в терминах самого себя!
(Не только это, но и неквалифицированные вызовы swap
будут использовать наш пользовательский оператор свопинга, пропуская ненужную конструкцию и уничтожение нашего класса, который повлечет за собой std::swap
.)
Подробное объяснение
Цель
Рассмотрим конкретный случай. Мы хотим управлять в противном случае бесполезным классом динамическим массивом. Начнем с рабочего конструктора, конструктора копирования и деструктора:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
Этот класс почти успешно управляет массивом, но для корректной работы ему требуется operator=
.
Неудачное решение
Вот как может выглядеть наивная реализация:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
И мы говорим, что мы закончили; теперь он управляет массивом без утечек. Однако он страдает от трех проблем, помеченных последовательно в коде как (n)
.
-
Первый - это тест самонаведения. Эта проверка служит двум целям: это простой способ помешать нам запускать ненужный код для самостоятельного назначения и защищает нас от тонких ошибок (например, удаление массива только для его копирования и копирования). Но во всех остальных случаях он просто замедляет работу программы и действует как шум в коде; самообучение редко происходит, поэтому большую часть времени эта проверка является отходами. Было бы лучше, если бы оператор мог нормально работать без него.
-
Во-вторых, он предоставляет только базовую гарантию исключения. Если
new int[mSize]
не работает,*this
будет изменен. (А именно, размер неправильный, и данные ушли!) Для надежной гарантии исключения это должно быть чем-то вроде:dumb_array& operator=(const dumb_array& other) { if (this != &other) // (1) { // get the new data ready before we replace the old std::size_t newSize = other.mSize; int* newArray = newSize ? new int[newSize]() : nullptr; // (3) std::copy(other.mArray, other.mArray + newSize, newArray); // (3) // replace the old data (all are non-throwing) delete [] mArray; mSize = newSize; mArray = newArray; } return *this; }
-
Код расширился! Это приводит нас к третьей проблеме: дублирование кода. Наш оператор назначения эффективно дублирует весь код, который мы уже писали в другом месте, и это ужасная вещь.
В нашем случае ядро его состоит только из двух строк (выделение и копия), но с более сложными ресурсами этот раздутый код может быть довольно сложным. Мы должны стремиться никогда не повторять себя.
(Можно подумать: если этот код необходим для правильного управления одним ресурсом, что, если мой класс управляет более чем одним? Хотя это может показаться действительной проблемой, и в действительности это требует нетривиального try
/catch
, это не проблема. Это потому, что класс должен управлять только одним ресурсом!)
Успешное решение
Как уже упоминалось, идиома "копирование и своп" исправит все эти проблемы. Но прямо сейчас у нас есть все требования, кроме одного: a swap
. В то время как правило из трех успешно влечет за собой существование нашего копировального конструктора, оператора присваивания и деструктора, его действительно следует называть "Большой тройкой и половиной": в любое время, когда ваш класс управляет ресурсом, также имеет смысл предоставить swap
.
Нам нужно добавить функциональность свопа к нашему классу, и мы делаем это следующим образом:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
(Здесьобъясняет, почему public friend swap
.) Теперь мы можем не только обменять наши 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;
}
Мы теряем важную возможность оптимизации. Не только это, но этот выбор имеет решающее значение в С++ 11, о чем будет сказано ниже. (В общем, замечательно полезно руководство: если вы собираетесь сделать что-то в функции, пусть компилятор сделает это в списке параметров. ‡)
В любом случае, этот метод получения нашего ресурса является ключом к устранению дублирования кода: мы используем код из конструктора-копии для создания копии и никогда не должны повторять его. Теперь, когда копия сделана, мы готовы к обмену.
Обратите внимание, что при вводе функции все новые данные уже выделены, скопированы и готовы к использованию. Это то, что дает нам сильную гарантию исключения бесплатно: мы даже не войдем в функцию, если построение копии не удастся, и поэтому невозможно изменить состояние *this
. (Что мы делали вручную раньше, для надежной гарантии исключения, компилятор делает для нас сейчас, как добрый.)
В этот момент мы свободны от дома, потому что swap
не бросает. Мы свопим наши текущие данные с скопированными данными, безопасно изменяя наше состояние, а старые данные попадают во временное. Затем старые данные выводятся, когда функция возвращается. (Где после окончания области параметров и ее деструктор вызывается.)
Поскольку идиома не повторяет никакого кода, мы не можем вводить ошибки в операторе. Обратите внимание, что это означает, что мы избавляемся от необходимости проверки самоопределения, допускающей единую равномерную реализацию operator=
. (Кроме того, у нас больше нет штрафа за производительность при несобственных присвоениях.)
И это идиома копирования и свопинга.
Как насчет С++ 11?
Следующая версия С++, С++ 11, делает одно очень важное изменение в том, как мы управляем ресурсами: теперь правило три теперь Правило четырех (и половина). Зачем? Поскольку нам нужно не только копировать-строить наш ресурс, нам нужно также переместить-построить его.
К счастью для нас это легко:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other)
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Что здесь происходит? Вспомните цель move-construction: взять ресурсы из другого экземпляра класса, оставив его в состоянии, гарантированном быть назначаемым и разрушаемым.
Итак, что мы сделали, просто: инициализируйте с помощью конструктора по умолчанию (функция С++ 11), затем замените other
; мы знаем, что построенный по умолчанию экземпляр нашего класса можно безопасно назначить и уничтожить, поэтому мы знаем, что other
сможет сделать то же самое после замены.
(Обратите внимание, что некоторые компиляторы не поддерживают делегирование конструктора, в этом случае мы должны вручную создать класс по умолчанию. Это несчастливая, но, к счастью, тривиальная задача.)
Почему это работает?
Это единственное изменение, которое нам нужно внести в наш класс, так почему он работает? Помните о важном решении, которое мы сделали, чтобы сделать параметр значением, а не ссылкой:
dumb_array& operator=(dumb_array other); // (1)
Теперь, если other
инициализируется значением r, он будет построен по ходу движения. Отлично. Точно так же С++ 03 позволяет нам повторно использовать нашу функциональность для копирования-конструктора, принимая аргумент по-значению, С++ 11 будет автоматически выбирать конструктор move, когда это необходимо. (И, конечно, как упоминалось в ранее связанной статье, копирование/перемещение значения можно просто полностью исключить.)
И так заканчивается идиома копирования и свопинга.
Сноски
* Почему мы устанавливаем mArray
на null? Поскольку, если какой-либо дополнительный код в операторе бросает, может быть вызван деструктор dumb_array
; и если это произойдет без установки его нулевого значения, мы попытаемся удалить уже удалённую память! Мы избегаем этого, установив его равным нулю, так как удаление null - это не операция.
† Есть и другие утверждения, которые мы должны специализировать std::swap
для нашего типа, предоставлять внутри класса swap
свободную функцию swap
и т.д. Но это все не нужно: любое правильное использование swap
будет проходить через неквалифицированный вызов, и наша функция будет найдена через ADL. Одна функция будет делать.
‡ Причина проста: если у вас есть ресурс для себя, вы можете поменять его и/или переместить (С++ 11) в любом месте, где оно должно быть. И, сделав копию в списке параметров, вы максимизируете оптимизацию.