Обзор
Зачем нам нужен способ копирования и замены?
Любой класс, который управляет ресурсом ( оболочка , как интеллектуальный указатель), должен реализовать Большая тройка . В то время как цели и реализация конструктора и деструктора копирования просты, оператор присвоения копии, пожалуй, самый нюансированный и сложный. Как это должно быть сделано? Каких ловушек следует избегать?
Идиома копирования и обмена является решением, которое элегантно помогает оператору присваивания достичь двух целей: избежать дублирования кода и обеспечить гарантированное исключение исключения .
Как это работает?
Концептуально , он работает, используя функциональность конструктора копирования для создания локальной копии данных, затем берет скопированные данные с помощью функции 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
, это не проблема, потому что класс должен управлять только одним ресурсом !)
Удачное решение
Как уже упоминалось, идиома копирования и обмена исправит все эти проблемы. Но сейчас у нас есть все требования, кроме одного: функция 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;
}
Мы теряем важную возможность оптимизации . Не только это, но и этот выбор является критическим в C ++ 11, который будет обсуждаться позже (В общем, замечательно полезный совет: если вы собираетесь сделать копию чего-либо в функции, пусть компилятор сделает это в списке параметров. ‡)
В любом случае, этот метод получения нашего ресурса является ключом к устранению дублирования кода: мы используем код из конструктора копирования для создания копии, и нам никогда не потребуется повторять ее часть. Теперь, когда копия сделана, мы готовы поменяться местами.
Обратите внимание, что при входе в функцию все новые данные уже распределены, скопированы и готовы к использованию. Это то, что дает нам полную гарантию исключения бесплатно: мы даже не войдем в функцию, если построение копии не удастся, и поэтому невозможно изменить состояние *this
. (То, что мы делали раньше вручную для гарантии исключений, компилятор делает для нас сейчас; как мило.)
На данный момент мы свободны от дома, потому что swap
не является броском. Мы заменяем наши текущие данные на скопированные, безопасно изменяя наше состояние, и старые данные помещаются во временные. Старые данные затем освобождаются, когда функция возвращается. (Где заканчивается область действия параметра и вызывается его деструктор.)
Поскольку идиома не повторяет код, мы не можем вносить ошибки в оператор. Обратите внимание, что это означает, что мы избавляемся от необходимости проверки самоназначения, позволяющей единую реализацию operator=
. (Кроме того, у нас больше нет штрафа за невыполнение заданий.)
И это идиома копирования и обмена.
А как насчет C ++ 11?
Следующая версия C ++, C ++ 11, вносит одно очень важное изменение в то, как мы управляем ресурсами: Правило трех теперь Правило четырех (с половиной). Зачем? Поскольку мы не только должны иметь возможность копировать-конструировать наш ресурс, нам нужно также перемещать-конструировать его .
К счастью для нас, это легко:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other)
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Что здесь происходит? Вспомните цель построения перемещения: взять ресурсы из другого экземпляра класса, оставив его в состоянии, которое может быть назначено и уничтожено.
То, что мы сделали, очень просто: инициализируйте с помощью конструктора по умолчанию (функция C ++ 11), затем поменяйте местами с other
; мы знаем, что созданный по умолчанию экземпляр нашего класса можно безопасно назначать и уничтожать, поэтому мы знаем, что other
сможет сделать то же самое после замены.
(Обратите внимание, что некоторые компиляторы не поддерживают делегирование конструктора; в этом случае мы должны вручную создать класс по умолчанию. Это неудачная, но, к счастью, тривиальная задача.)
Почему это работает?
Это единственное изменение, которое мы должны внести в наш класс, так почему это работает? Помните, какое важное решение мы приняли, чтобы сделать параметр значением, а не ссылкой:
dumb_array& operator=(dumb_array other); // (1)
Теперь, если other
инициализируется значением r, будет построено с ходом . Отлично. Таким же образом C ++ 03 позволяет нам повторно использовать нашу функциональность конструктора копирования, принимая аргумент по значению, C ++ 11 автоматически *1157* выберет конструктор перемещения в случае необходимости. (И, конечно, как упоминалось в ранее связанной статье, копирование / перемещение значения может быть просто полностью исключено.)
На этом завершается идиома копирования и обмена.
Сноска
* Почему мы устанавливаем mArray
в ноль? Потому что, если какой-либо дополнительный код в операторе выдает, может быть вызван деструктор dumb_array
; и если это происходит без установки значения null, мы пытаемся удалить уже удаленную память! Мы избегаем этого, устанавливая значение null, поскольку удаление null - это операция без операции.
† Существуют и другие утверждения, что нам следует специализировать std::swap
для нашего типа, предоставить в классе swap
рядом со свободной функцией swap
и т. Д. Но это все не нужно: любое правильное использование swap
будет осуществляться через неквалифицированный вызов, а наша функция будет найдена через ADL . Подойдет одна функция.
‡ Причина проста: если у вас есть ресурс для себя, вы можете поменять его и / или переместить (C ++ 11) куда угодно. А сделав копию в списке параметров, вы максимально оптимизируете.