обзор
Зачем нам нужен способ копирования и обмена?
Любой класс, который управляет ресурсом ( обертка , как умный указатель), должен реализовать Большую тройку . В то время как цели и реализация конструктора и деструктора копирования просты, оператор присвоения копии, пожалуй, самый нюансированный и сложный. Как это должно быть сделано? Какие подводные камни следует избегать?
Копирования и замены идиома это решение, и элегантно помогает оператору присваивания в достижении двух целей: во избежание дублирования кода , и обеспечивая надежную гарантию исключения .
Как это работает?
Концептуально , он работает с использованием функциональности конструктора копирования для создания локальной копии данных, а затем берет скопированные данные с помощью 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) noexcept ††
: 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 автоматически выбирает конструктор перемещения, когда это уместно. (И, конечно, как упоминалось в ранее связанной статье, копирование / перемещение значения может быть просто полностью исключено.)
И так завершает идиому копирования и обмена.
Сноски
* Почему мы устанавливаем mArray
в ноль? Потому что, если какой-либо дополнительный код в операторе выдает, dumb_array
может быть вызван деструктор ; и если это происходит без установки значения null, мы пытаемся удалить уже удаленную память! Мы избегаем этого, устанавливая его в null, так как удаление null - это не операция.
† Существуют и другие утверждения, что мы должны специализироваться std::swap
для нашего типа, предоставлять в своем классе swap
наряду со свободной функцией swap
и т. Д. Но все это не нужно: любое правильное использование swap
будет осуществляться через неквалифицированный вызов, и наша функция будет нашел через ADL . Одна функция будет делать.
‡ Причина проста: если у вас есть ресурс для себя, вы можете поменять его и / или переместить (C ++ 11) куда угодно. А сделав копию в списке параметров, вы максимально оптимизируете.
†† Обычно конструктор перемещения должен быть таким noexcept
, в противном случае некоторый код (например, std::vector
логика изменения размера) будет использовать конструктор копирования, даже если перемещение имело бы смысл. Конечно, отметьте его только для случаев, когда код внутри не генерирует исключения.