Правило пяти и правило нуля в C++: разбор на классе-обёртке
Класс с сырым указателем компилируется, проходит тесты и падает с double free в проде. Разбираем, почему компилятор генерирует неправильные операции и когда владеющий ресурсом класс должен объявить все пять.

Класс держит сырой указатель на буфер, всё компилируется, юнит-тесты зелёные. В продакшене под нагрузкой процесс падает с free(): double free detected in tcache 2. Причина — компилятор молча сгенерировал копирующий конструктор, который скопировал указатель, а не буфер. Два объекта теперь владеют одной областью памяти, и оба вызовут delete[]. Это классическая ловушка, от которой защищают правило пяти и правило нуля.
Разбираем, как компилятор решает, какие операции сгенерировать за вас, почему их автоматическая версия ломает владеющий ресурсом класс, и в каком случае вообще не нужно писать ни одной из пяти. Примеры на C++17 и новее, поведение = default и генерации специальных функций описано по стандарту, начиная с C++11.
Пять специальных функций-членов
Когда класс управляет ресурсом (памятью из new, файловым дескриптором, мьютексом, соединением), компилятор не знает, как этот ресурс копировать, перемещать и освобождать. Он делает предположение по умолчанию — почленное копирование и перемещение — и для сырого указателя это предположение неверно.
Пять функций, о которых идёт речь:
- деструктор —
~T(); - копирующий конструктор —
T(const T&); - копирующее присваивание —
T& operator=(const T&); - перемещающий конструктор —
T(T&&); - перемещающее присваивание —
T& operator=(T&&).
Правило пяти звучит так: если вам понадобилось объявить хотя бы одну из этих функций явно, скорее всего, нужно осознанно определить (или явно запретить) все пять. Правило нуля — противоположный подход: постройте класс так, чтобы ни одну из пяти писать не пришлось, отдав управление ресурсом членам, которые уже умеют это делать сами.
Почему сгенерированные операции ломают класс
Возьмём наивную обёртку над динамическим массивом. Она компилируется и работает, пока объект не копируют.
// C++17. Опасный класс: объявлен только деструктор.
#include <cstddef>
#include <algorithm>
class Buffer {
public:
explicit Buffer(std::size_t size)
: size_(size), data_(new int[size]) {}
~Buffer() { delete[] data_; } // единственная объявленная функция
std::size_t size() const { return size_; }
private:
std::size_t size_;
int* data_;
};
void process(Buffer b); // принимает по значению
int main() {
Buffer original(1000);
process(original); // здесь создаётся копия: копируется указатель data_
return 0;
// при выходе из process деструктор вызвал delete[] data_,
// при выходе из main деструктор вызовет delete[] на том же адресе
}Компилятор увидел деструктор, но по-прежнему сгенерировал копирующий конструктор по умолчанию (в C++17 объявление деструктора делает генерацию копирования устаревшей, но она всё ещё происходит — это одна из самых коварных ловушек языка). Сгенерированная копия делает data_ = other.data_: два объекта, один буфер, двойное освобождение.
Компилятор не читает ваши намерения. Он читает объявления. Объявили деструктор — значит, у класса есть нетривиальное владение, и почленное копирование указателя почти наверняка ошибка. Правило пяти — это способ не оставить компилятору места для неверной догадки.
Правильная версия по правилу пяти
Теперь определяем все пять осознанно. Деструктор освобождает, копирование выделяет новый буфер и переносит данные, перемещение забирает указатель у источника и обнуляет его.
// C++17. Полная реализация по правилу пяти.
#include <cstddef>
#include <algorithm>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t size)
: size_(size), data_(new int[size]{}) {}
// 1. Деструктор
~Buffer() { delete[] data_; }
// 2. Копирующий конструктор: выделяем свой буфер
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
// 3. Копирующее присваивание через copy-and-swap
Buffer& operator=(Buffer other) { // принимаем по значению — копия уже создана
swap(*this, other);
return *this;
}
// 4. Перемещающий конструктор: забираем ресурс, источник обнуляем
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr; // чтобы деструктор источника не освободил чужое
}
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.size_, b.size_);
swap(a.data_, b.data_);
}
private:
std::size_t size_;
int* data_;
};Идиома copy-and-swap закрывает и копирующее, и перемещающее присваивание одной функцией: параметр Buffer other принимается по значению, поэтому вызывается либо копирующий, либо перемещающий конструктор в зависимости от аргумента, а дальше происходит обмен. Отдельное operator=(Buffer&&) в этом варианте не нужно — но это осознанное решение, а не забывчивость.
Почему noexcept у перемещения важен
Пометка noexcept на перемещающих операциях — не косметика. std::vector при реаллокации выбирает между перемещением и копированием элементов: если перемещающий конструктор не помечен noexcept, вектор из соображений строгой гарантии безопасности при исключениях будет копировать элементы, а не перемещать. Обёртка с потенциально бросающим перемещением молча теряет производительность внутри контейнеров.
Правило нуля: не писать ничего
Лучший владеющий класс — тот, что не владеет ресурсом напрямую. Если заменить сырой указатель на std::vector или std::unique_ptr, все пять функций компилятор сгенерирует правильно, потому что члены уже знают, как себя копировать, перемещать и уничтожать.
// C++17. Тот же смысл, но без единой из пяти функций.
#include <vector>
#include <cstddef>
class Buffer {
public:
explicit Buffer(std::size_t size) : data_(size) {}
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_;
// деструктор, копирование и перемещение сгенерированы верно:
// они делегируют работу std::vector
};Здесь копирование глубокое, перемещение забирает буфер, деструктор освобождает — и всё это бесплатно и правильно. Правило нуля — не поблажка для ленивых, а прямое следствие правила пяти: раз соблюдение пяти правил трудно и легко ошибиться, вынесите владение в компонент, который уже прошёл через эти пять правил за вас.
Когда какой подход
| Ситуация | Что делать |
|---|---|
| Класс агрегирует стандартные типы (vector, string, unique_ptr, shared_ptr) | Правило нуля: не объявлять ни одной из пяти |
| Класс — обёртка над C-ресурсом (FILE*, дескриптор сокета, HANDLE) | Правило пяти либо unique_ptr с кастомным делитером |
| Копирование должно быть запрещено (мьютекс, уникальное соединение) | Копирующие функции = delete, перемещающие = default или свои |
| Нужна только семантика перемещения без копий | Копирование = delete, перемещение определить явно |
| Полиморфный базовый класс | Виртуальный деструктор + правило пяти (часто копирование = delete) |
Кастомный делитер на unique_ptr часто дешевле собственной пятёрки. Для FILE* достаточно объявить тип удаления, и класс снова подпадает под правило нуля:
// C++17. Обёртка над FILE* без единой из пяти функций.
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "rb")); // закроется автоматически
}Типичные ошибки
- Объявлен только деструктор, копирование забыто. Компилятор сгенерирует копирование, оно скопирует указатель — double free. Тот самый случай из начала статьи.
- Перемещающий конструктор не обнуляет источник. Тогда деструктор источника освободит уже переданный буфер.
- Забыли
noexceptна перемещении. Контейнеры перестанут перемещать элементы и начнут копировать. - Объявили копирование, но не объявили перемещение. Если явно объявлен копирующий конструктор или присваивание, перемещающие операции не генерируются вовсе. Класс станет только копируемым, и все «перемещения» тихо превратятся в копии.
- Смешали правило нуля и ручной деструктор. Если члены —
vectorиstring, но вы дописали пустой~T() {}, вы подавили генерацию перемещения. Уберите деструктор.
Что делать дальше
Пройдитесь grep-ом по своим классам с сырыми указателями и деструкторами: каждый такой класс — кандидат либо на все пять функций, либо на переписывание под правило нуля. Включите предупреждения компилятора: -Wall -Wextra у GCC и Clang укажут на подозрительные случаи, а -Weffc++ отдельно ругается на классы с указателями без объявленной пятёрки. Для проверки под нагрузкой прогоните сборку под AddressSanitizer (-fsanitize=address) — именно он ловит double free и use-after-free, которые компилятор пропускает. Инвалидация после перемещения (в каком состоянии остаётся источник) — отдельная тема, здесь мы гарантировали только то, что деструктор источника безопасен.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: cppreference: The rule of three/five/zero, cppreference: Copy assignment operator
Частые вопросы
У меня C++11, а не C++17. Примеры сработают?
Если я объявил только деструктор, компилятор точно сгенерирует копирование?
Что произойдёт, если объявить копирование, но не объявить перемещение?
Правило нуля всегда лучше правила пяти?
Не сработало: класс с vector внутри всё равно копируется вместо перемещения. В чём дело?
Зачем copy-and-swap, если можно написать два отдельных operator=?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.