У каждого класса есть шесть специальных функций-членов, которые компилятор может сгенерировать сам:
- конструктор по умолчанию
T(); - деструктор
~T(); - копирующий конструктор
T(const T&); - копирующее присваивание
T& operator=(const T&); - перемещающий конструктор
T(T&&); - перемещающее присваивание
T& operator=(T&&).
Сгенерированные версии работают почленно: копируют, перемещают и уничтожают каждый член и базу. Для int, std::string, std::vector это ровно то, что нужно. Для сырого указателя, которым класс владеет, это катастрофа: скопируется адрес, и два объекта удалят одну память.
Когда компилятор их не создаёт
Короткая версия таблицы, которую стоит помнить:
- Объявили любой конструктор: конструктора по умолчанию не будет (верните его
T() = default;). - Объявили деструктор, копирующий конструктор или копирующее присваивание: перемещающие операции не генерируются. Перемещение такого объекта молча превращается в копирование.
- Объявили перемещающий конструктор или перемещающее присваивание: копирующие операции становятся удалёнными.
- Член-ссылка или
const-член: копирующее и перемещающее присваивание удаляются (ссылку нельзя перепривязать).
Генерация копирования при объявленном деструкторе сохранена ради совместимости, но объявлена устаревшей.
Правило трёх
Если классу понадобился свой деструктор, копирующий конструктор или копирующее присваивание, почти наверняка нужны все три: класс вручную управляет ресурсом.
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& o) : size_(o.size_), data_(new int[o.size_]) {
std::copy(o.data_, o.data_ + size_, data_);
}
Buffer& operator=(Buffer o) noexcept { // copy-and-swap: o уже копия
swap(o);
return *this;
}
void swap(Buffer& o) noexcept { std::swap(size_, o.size_); std::swap(data_, o.data_); }
private:
std::size_t size_;
int* data_;
};
Правило пяти
С C++11 к трём добавляются перемещающий конструктор и перемещающее присваивание. Без них перемещение Buffer будет копированием, а std::vector<Buffer> при росте станет копировать каждый буфер.
Buffer(Buffer&& o) noexcept : size_(std::exchange(o.size_, 0)), data_(std::exchange(o.data_, nullptr)) {}
Перемещающие операции помечайте noexcept: std::vector использует перемещение при реаллокации, только если оно не бросает (через std::move_if_noexcept), иначе копирует ради строгой гарантии.
Правило нуля
Лучший класс не объявляет ни одной из пяти функций. Владение делегируется членам, которые уже умеют себя копировать, перемещать и освобождать:
class Buffer {
std::vector<int> data_; // всё сгенерируется правильно
};
Если ресурс экзотический (дескриптор файла, хендл ОС), напишите для него одну маленькую RAII-обёртку с правилом пяти, а все остальные классы пусть следуют правилу нуля. Если копирование бессмысленно, запретите его явно: T(const T&) = delete;.
Самоприсваивание и исключения
Наивное присваивание «удалить своё, скопировать чужое» ломается на a = a (читает уже удалённую память) и при исключении из new (объект остаётся с висячим указателем). Идиома copy-and-swap решает обе проблемы: сначала строим копию (может бросить, но наш объект не тронут), потом обмениваемся noexcept-обменом. Это строгая гарантия исключений ценой лишней аллокации.
Частые ошибки
- Объявить пустой деструктор
~T() {}«для красоты» и потерять перемещение. - Забыть
noexceptу перемещения и получить копирование при росте вектора. - Владеть сырым указателем без правила трёх.
- Не обнулить источник в перемещающем конструкторе: двойное удаление.
Что спросят на собесе
- Какие функции генерирует компилятор и когда перестаёт.
- Правила трёх, пяти и нуля своими словами.
- Зачем
noexceptна перемещении. - Copy-and-swap: что даёт и чем платим.
- Что будет с перемещением, если объявить деструктор.