Огляд
Навіщо нам потрібна ідіома копіювання та заміни?
Будь-який клас, який керує ресурсом ( обгортка , як розумний вказівник), повинен реалізувати Велику трійку . Незважаючи на те, що цілі та реалізація конструктора копій та деструктора є чіткими, оператор присвоєння копій, мабуть, є найбільш нюансованим та складним. Як це робити? Яких підводних каменів потрібно уникати?
Копіювання і заміни ідіома це рішення, і елегантно допомагає оператору присвоювання в досягненні двох цілей: у уникнення дублювання коду , і забезпечуючи надійну гарантію виключення .
Як це працює?
Концептуально він працює, використовуючи функціонал конструктора копіювання для створення локальної копії даних, потім приймає скопійовані дані swap
функцією, замінюючи старі дані новими даними. Потім тимчасова копія знищується, забираючи з собою старі дані. Нам залишається копія нових даних.
Для використання ідіоми копіювання та заміни нам потрібні три речі: робочий конструктор копій, робочий деструктор (обидва є основою будь-якої обгортки, тому в будь-якому випадку має бути завершеною) та swap
функція.
Функція swap - це функція, що не кидає, яка обміняє два об'єкти класу, member для члена. Ми можемо спокуситись використовувати, std::swap
а не надавати своє власне, але це було б неможливо; std::swap
використовує конструктор копій та оператор присвоєння копії в межах своєї реалізації, і ми в кінцевому підсумку намагаємося визначити оператора призначення з точки зору самого себе!
(Мало того, але некваліфіковані дзвінки 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
функцію .
Нам потрібно додати функціонал 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=
. (Крім того, у нас більше не передбачено покарання за невиконання завдань.)
І це ідіома копіювання та заміни.
А що з 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
він ініціалізується з rvalue, він буде побудований з переміщенням . Ідеально. Таким же чином, C ++ 03 дозволимо повторно використовувати нашу функцію конструктора копіювання, взявши за значенням аргумент, C ++ 11 автоматично також підбере конструктор переміщення, коли це доречно. (І, звичайно, як уже згадувалося в раніше зв'язаній статті, копіювання / переміщення значення може просто спливати.)
І так завершується ідіомою копіювання та заміни.
Виноски
* Чому ми ставимо mArray
на нуль? Тому що, якщо будь-який поданий код в операторі кидає, деструктор dumb_array
може бути викликаний; і якщо це станеться без встановлення його на нуль, ми намагаємось видалити пам'ять, яка вже була видалена! Ми уникаємо цього, встановивши його на null, оскільки видалення null - це недіяльність.
† Є й інші твердження, що ми повинні спеціалізуватися std::swap
на нашому типі, надавати в класі swap
поряд вільну функцію swap
тощо. Але це все зайве: будь-яке правильне використання swap
буде через некваліфікований дзвінок, і наша функція буде знайдено через ADL . Одну функцію буде виконувати.
‡ Причина проста: як тільки ви матимете ресурс для себе, ви можете його поміняти та / або перемістити (C ++ 11) у будь-яке місце, де це потрібно. І, роблячи копію в списку параметрів, ви максимально оптимізуєте.
†Kl Конструктор переміщення, як правило, має бути noexcept
, інакше деякий код (наприклад, std::vector
зміна логіки) використовуватиме конструктор копіювання навіть тоді, коли переміщення матиме сенс. Звичайно, позначайте його лише за винятком випадків, якщо код всередині не викидає винятків.