Что такое Copy-and-Swap Idiom и почему это must-have в 2025 году
Представьте, что вы пишете класс для работы с динамической памятью. Всё идёт хорошо, пока не доходит до перегрузки оператора присваивания. Вы пишете operator=, проверяете на самоприсваивание, руками освобождаете старые ресурсы, выделяете новые… И в 90% случаев допускаете баг, который приведёт к утечке памяти или двойному удалению. Знакомо?
В C++ существует элегантное решение — copy-and-swap idiom. Это не просто модный трюк, а фундаментальная техника, которая позволяет писать безопасный, исключение-устойчивый и лаконичный код. В 2025 году, когда C++23 уже активно используется, а стандарт C++26 на горизонте, эта идиома остаётся золотым стандартом для классов, управляющих ресурсами.
В этой статье мы разберём copy-and-swap idiom от и до: начиная с проблемы, которую он решает, заканчивая практическими примерами и лучшими практиками 2025 года. Готовьте компилятор — будет много кода!
Проблема: почему наивный operator= — это зло
Давайте начнём с классического примера класса, который управляет динамическим массивом. Без идиомы copy-and-swap код часто выглядит так:
class DumbArray {
public:
DumbArray(size_t size)
: size_(size), data_(new int[size]) {}
~DumbArray() { delete[] data_; }
// Наивный оператор присваивания
DumbArray& operator=(const DumbArray& other) {
if (this == &other) return *this; // проверка на самоприсваивание
delete[] data_; // освобождаем старые ресурсы
size_ = other.size_;
data_ = new int[size_]; // выделяем новые
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
private:
size_t size_;
int* data_;
};
На первый взгляд всё логично. Но есть три проблемы:
- Отсутствие базовой гарантии исключений. Если
new int[size_]выброситstd::bad_alloc, объект останется в некорректном состоянии:data_будет указывать на удалённую память. - Дублирование логики. Копирование данных уже реализовано в конструкторе копирования. Почему мы пишем то же самое вручную?
- Проверка на самоприсваивание. Она замедляет код и легко забывается. К тому же, в 95% случаев самоприсваивания не происходит, а проверка всё равно выполняется.
И это ещё не всё. Если добавить в класс другие ресурсы (например, файловые дескрипторы или мьютексы), код станет не просто громоздким — он станет опасным. Именно здесь на сцену выходит copy-and-swap idiom.
Суть идиомы: три шага к идеальному operator=
Copy-and-swap idiom базируется на простой идее: не пиши логику копирования в операторе присваивания — используй конструктор копирования и функцию swap. Алгоритм выглядит так:
- Copy: создаём временную копию объекта с помощью конструктора копирования.
- Swap: обмениваем содержимое текущего объекта с временной копией.
- Destroy: временный объект уничтожается, освобождая старые ресурсы.
В коде это выглядит так:
class SmartArray {
public:
SmartArray(size_t size = 0)
: size_(size), data_(size ? new int[size] : nullptr) {}
// Конструктор копирования
SmartArray(const SmartArray& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
std::copy(other.data_, other.data_ + size_, data_);
}
// Деструктор
~SmartArray() { delete[] data_; }
// Дружественная функция swap
friend void swap(SmartArray& first, SmartArray& second) noexcept {
using std::swap;
swap(first.size_, second.size_);
swap(first.data_, second.data_);
}
// Оператор присваивания — всего 3 строки!
SmartArray& operator=(SmartArray other) noexcept { // Принимаем по значению!
swap(*this, other);
return *this;
}
private:
size_t size_;
int* data_;
};
Обратите внимание: operator= принимает аргумент по значению, а не по константной ссылке. Это ключевой момент. Когда вы пишете a = b, компилятор вызывает конструктор копирования для параметра other. Если же вы пишете a = std::move(b), компилятор использует конструктор перемещения (если он определён). Таким образом, одна функция работает и для копирующего, и для перемещающего присваивания!
Почему это работает: магия исключений и swap
Главное преимущество copy-and-swap — сильная гарантия исключений (strong exception safety). Если в процессе копирования произойдёт исключение (например, не хватит памяти), оригинальный объект останется нетронутым. Почему? Потому что:
- Конструктор копирования либо создаёт полностью валидную копию, либо выбрасывает исключение, не изменяя исходный объект.
- Функция
swapпомечена какnoexcept— она никогда не выбрасывает исключений. - Деструктор временного объекта также
noexceptпо умолчанию.
Таким образом, если исключение произошло на этапе копирования, operator= даже не начнётся. Если же копирование прошло успешно, swap гарантированно выполнится без ошибок, и старые ресурсы будут корректно освобождены при разрушении временного объекта.
Лучшие практики 2025: современные рекомендации
1. Правило пяти и copy-and-swap
В современном C++ (C++11 и новее) действует правило пяти: если вы определяете один из следующих методов, определите все пять:
- Деструктор
- Конструктор копирования
- Оператор присваивания копированием
- Конструктор перемещения
- Оператор присваивания перемещением
Copy-and-swap idiom упрощает эту задачу. Вам нужно определить деструктор, конструктор копирования, конструктор перемещения и функцию swap. А оба оператора присваивания (копирующий и перемещающий) реализуются через swap:
class ModernArray {
public:
// ... конструкторы, деструктор ...
friend void swap(ModernArray& first, ModernArray& second) noexcept { ... }
// Один оператор на оба случая
ModernArray& operator=(ModernArray other) noexcept {
swap(*this, other);
return *this;
}
};
Но будьте внимательны: если в вашем классе есть ресурсы, которые не должны копироваться (например, уника