Поговорим об одной из интересных идиом в C++ — Copy-and-Swap. Если вы когда-нибудь писали класс, который управляет ресурсами (памятью, файлами или сокетами), вы наверняка сталкивались с головной болью под названием «перегрузка оператора присваивания».
Хочется сделать его надежным, безопасным и не облажаться с исключениями. Copy-and-Swap — это элегантный способ решить эти проблемы раз и навсегда.
Copy-and-Swap (или «копируй и обменяй») — это не просто трюк, а настоящий джентльменский набор для оператора operator=.
Идея до безобразия проста:
1. Копируем — создаем временную копию объекта, который присваиваем.
2. Обмениваем — меняем местами внутренности текущего объекта с этой копией.
3. Уничтожаем — копия умирает, унося с собой старые данные (те, что были в текущем объекте до присваивания).
Всё гениальное просто. А теперь давайте посмотрим, почему этот подход выигрывает по всем фронтам.
- Гарантия безопасности исключений (Strong Exception Safety): Если на этапе копирования вылетит исключение (например, не хватит памяти), исходный объект останется нетронутым. Он же не изменился, пока копия не создана!
- Чистота кода: Вы пишете меньше кода, а значит, меньше вероятность допустить ошибку.
- Move-семантика «из коробки»: Если передать в operator= временный объект (rvalue), компилятор сам подхватит move-конструктор, и копирования не произойдет. Халява!
Пример 1: Начнем с классики — самописной строки.
#include <cstring>
#include <algorithm>
class MyString {
private:
char* data_;
size_t size_;
public:
// Конструктор
MyString(const char* str = "")
: size_(strlen(str)), data_(new char[size_ + 1]) {
strcpy(data_, str);
}
// Деструктор
~MyString() {
delete[] data_;
}
// Копирующий конструктор
MyString(const MyString& other)
: MyString(other.data_) {} // Делегируем конструктору
// Move-конструктор
MyString(MyString&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// Внимание! Золото здесь 👇
MyString& operator=(MyString other) noexcept { // Принимаем по значению!
swap(other);
return *this;
}
// Метод swap — сердце идиомы
void swap(MyString& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
};
Передавая other по значению, мы говорим компилятору: «Слушай, создай мне копию, а если сможешь переместить — перемести». Дальше мы просто меняемся с этой копией данными. Выходя из функции, other умирает и прихватывает с собой нашу старую память. Красота!
Пример 2: Владелец ресурсов
Теперь представим, что у нас есть массив целых чисел. Та же история, только ресурсов побольше.
class ResourceHolder {
private:
int* data_;
size_t count_;
public:
ResourceHolder(size_t n = 0)
: count_(n), data_(new int[n]()) {} // Value-initialization
~ResourceHolder() {
delete[] data_;
}
ResourceHolder(const ResourceHolder& other)
: count_(other.count_), data_(new int[other.count_]) {
std::copy(other.data_, other.data_ + other.count_, data_);
}
ResourceHolder(ResourceHolder&& other) noexcept
: data_(other.data_), count_(other.count_) {
other.data_ = nullptr;
other.count_ = 0;
}
// Оператор присваивания — наш старый друг
ResourceHolder& operator=(ResourceHolder other) noexcept {
swap(other);
return *this;
}
void swap(ResourceHolder& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(count_, other.count_);
}
};
Пример 3: Stl-like как std::vector
Давайте напишем простенький вектор, чтобы закрепить.
template<typename T>
class SimpleVector {
private:
T* data_;
size_t size_;
size_t capacity_;
public:
SimpleVector(size_t n = 0)
: size_(n), capacity_(n), data_(n ? new T[n] : nullptr) {}
~SimpleVector() {
delete[] data_;
}
SimpleVector(const SimpleVector& other)
: size_(other.size_), capacity_(other.capacity_),
data_(other.capacity_ ? new T[other.capacity_] : nullptr) {
for (size_t i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
SimpleVector(SimpleVector&& other) noexcept
: data_(other.data_), size_(other.size_), capacity_(other.capacity_) {
other.data_ = nullptr;
other.size_ = other.capacity_ = 0;
}
// И снова он!
SimpleVector& operator=(SimpleVector other) noexcept {
swap(other);
return *this;
}
void swap(SimpleVector& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
swap(capacity_, other.capacity_);
}
};
Секрет идиомы copy-and-swap — в передаче аргумента по значению. Раньше (в старых учебниках) часто писали так:
MyString& operator=(const MyString& other) {
// 1. Проверка на самоприсваивание (this != &other) — костыль!
// 2. Удалить старые данные
// 3. Выделить новую память и скопировать
// 4. Profit? Нет, страшно и больно.
}
Этот подход требует проверки на самоприсваивание и ручного управления памятью. Ошибка в порядке действий — и вы получите утечку или двойное удаление. Copy-and-swap элегантно обходит все эти грабли.
Пара моментов к сведению все же есть:
1. Производительность. В некоторых случаях (очень большие объекты, которые не поддерживают move-семантику) лишнее копирование может быть дорогим. Но в 99% случаев это оверхед, которым можно пренебречь ради безопасности и простоты.
2. Самоприсваивание. Идиома корректно обрабатывает a = a, хоть и делает лишнюю работу (копирование самого себя). Это плата за простоту, и обычно она невелика.
Подытожим:
Copy-and-Swap — это не просто шаблон проектирования, это философия безопасного управления ресурсами в C++. Если ваш класс управляет памятью, файловым дескриптором или любым другим ресурсом, который нужно освобождать — используйте эту идиому. Она:
- Делает код красивым и понятным.
- Защищает от исключений.
- Бесплатно подключает move-семантику.