Правило трёх, пяти и нуля в C++: простое практическое руководство с примерами
Запрос: правило трёх в C++, правило пяти в C++, правило нуля в C++ — понятное руководство с примерами и лучшими практиками.
Зачем нужно правило трёх, пяти и нуля в C++
Если ваш класс владеет ресурсом (динамическая память, файл, мьютекс), нужно явно управлять жизненным циклом. Отсюда три варианта:
- Правило трёх: если вы определяете один из этих членов — деструктор, конструктор копирования, оператор присваивания копированием — скорее всего, должны определить все три.
- Правило пяти: в C++11+ добавились перемещающие конструктор и оператор присваивания. Если вы пишете особые члены, подумайте и о перемещении.
- Правило нуля: лучший вариант — ничего не писать. Делегируйте владение RAII-типа (например, std::vector или std::unique_ptr), и компилятор сгенерирует корректные операции сам.
Пример: Правило трёх — глубокое копирование ресурса
Класс владеет динамическим массивом. Реализуем деструктор, конструктор копирования и оператор присваивания копированием.
#include <iostream>
#include <algorithm>
#include <cstddef>
class Buffer {
std::size_t size_{};
int* data_{};
public:
explicit Buffer(std::size_t n)
: size_(n), data_(n ? new int[n] : nullptr) {
if (data_) std::fill(data_, data_ + size_, 0);
}
~Buffer() {
delete[] data_;
}
Buffer(const Buffer& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
if (size_) std::copy(other.data_, other.data_ + size_, data_);
std::cout << "copy ctor\n";
}
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this; // самоприсваивание
int* newData = other.size_ ? new int[other.size_] : nullptr;
if (other.size_) std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
std::cout << "copy assign\n";
return *this;
}
int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }
std::size_t size() const { return size_; }
};
Теперь копирование создаёт независимую копию массива, а утечек нет.
Правило пяти: добавляем перемещение и ускоряем контейнеры
Перемещение позволяет "забирать" ресурс у временного объекта без копирования. Особенно важно для производительности std::vector и других контейнеров при реаллокациях. Не забывайте про noexcept — тогда контейнеры выберут перемещение вместо копирования.
#include <vector>
#include <iostream>
class Buffer {
std::size_t size_{};
int* data_{};
public:
explicit Buffer(std::size_t n)
: size_(n), data_(n ? new int[n] : nullptr) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
if (size_) std::copy(other.data_, other.data_ + size_, data_);
std::cout << "copy ctor\n";
}
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
int* newData = other.size_ ? new int[other.size_] : nullptr;
if (other.size_) std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
std::cout << "copy assign\n";
return *this;
}
// Перемещающий конструктор и оператор (важно: noexcept)
Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
other.size_ = 0; other.data_ = nullptr;
std::cout << "move ctor\n";
}
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0; other.data_ = nullptr;
std::cout << "move assign\n";
return *this;
}
};
int main() {
std::vector<Buffer> v;
v.push_back(Buffer(10)); // move ctor
v.push_back(Buffer(20)); // move ctor + при росте емкости: move старых элементов
v.push_back(Buffer(30)); // ещё перемещения
}
Если убрать noexcept у перемещающих операций, многие контейнеры будут копировать элементы при перераспределении памяти, что медленнее.
Альтернатива: copy-and-swap для простоты и надёжности
Идиома copy-and-swap упрощает оператор присваивания: принимаем параметр по значению (копия или перемещение), а затем меняем содержимое местами. Это исключает дублирование кода и даёт строгую гарантию безопасности при исключениях.
#include <utility>
class BufferCS {
std::size_t size_{};
int* data_{};
public:
BufferCS() = default;
explicit BufferCS(std::size_t n) : size_(n), data_(n ? new int[n] : nullptr) {}
~BufferCS() { delete[] data_; }
BufferCS(const BufferCS& other)
: size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) {
if (size_) std::copy(other.data_, other.data_ + size_, data_);
}
BufferCS(BufferCS&& other) noexcept : size_(other.size_), data_(other.data_) {
other.size_ = 0; other.data_ = nullptr;
}
friend void swap(BufferCS& a, BufferCS& b) noexcept {
using std::swap;
swap(a.size_, b.size_);
swap(a.data_, b.data_);
}
BufferCS& operator=(BufferCS other) { // копия или перемещение сюда
swap(*this, other);
return *this;
}
};
Когда выбирать правило нуля: используем RAII-типы
Если можно, не управляйте сырыми ресурсами вручную. Делегируйте ответственность готовым RAII-обёрткам — и ваши классы будут автоматически корректно копироваться и перемещаться.
#include <vector>
class SafeBuffer {
std::vector<int> data_;
public:
explicit SafeBuffer(std::size_t n, int value = 0) : data_(n, value) {}
int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }
std::size_t size() const { return data_.size(); }
// Никаких специальных членов: правило нуля!
};
Там, где владение эксклюзивное, используйте std::unique_ptr — копирование запретите, перемещение разрешите.
#include <memory>
#include <cstddef>
class UniqueBuf {
std::unique_ptr<int[]> data_;
std::size_t size_{};
public:
explicit UniqueBuf(std::size_t n)
: data_(n ? std::make_unique<int[]>(n) : nullptr), size_(n) {}
UniqueBuf(const UniqueBuf&) = delete;
UniqueBuf& operator=(const UniqueBuf&) = delete;
UniqueBuf(UniqueBuf&&) noexcept = default;
UniqueBuf& operator=(UniqueBuf&&) noexcept = default;
};
Частые ошибки и советы
- Нет noexcept у перемещения: контейнеры будут копировать — добавляйте noexcept, если это безопасно.
- Копирование вместо перемещения из const: перемещающий конструктор не принимает const rvalue. Код
std::move(constObj)вызовет копирование. - Необработанное самоприсваивание: или защищайтесь if (this == &rhs), или используйте copy-and-swap.
- Дублирование логики: вынесите общую логику в функцию, либо примените copy-and-swap.
- Пытаться управлять сырой памятью без нужды: сначала подумайте о правиле нуля и RAII-типах.
- =default и =delete: явно указывайте намерения. Упростит код и диагностику.
Мини-чеклист по правилам 3/5/0
- Класс владеет ресурсом напрямую? Реализуйте правило пяти (или как минимум трёх).
- Можно доверить ресурс std::vector/std::string/std::unique_ptr? Выбирайте правило нуля.
- Эксклюзивное владение? Скопировать нельзя: пометьте копирование как =delete, перемещение =default noexcept.
- Оператор присваивания громоздкий? Рассмотрите copy-and-swap.
- Проверьте производительность контейнеров: есть ли noexcept у перемещения?
Практический тест: почему noexcept важен для std::vector
Соберите пример с/без noexcept и посмотрите в вывод: при увеличении ёмкости vector будет либо перемещать элементы (move ctor), либо копировать (copy ctor). На больших объектах разница значительна.
Что почитать и куда двигаться дальше
Освоив правило трёх, пяти и нуля, вы избежите утечек и получите предсказуемую производительность. Дальше рекомендую углубиться в семантику перемещения, исключения, PIMPL и проектирование API классов.
Хотите системно и последовательно прокачать C++ с практикой? Загляните в курс Программирование на C++ с Нуля до Гуру — посмотреть программу и начать обучение.
-
Создано 28.01.2026 17:04:37
-
Михаил Русаков

Комментарии (0):
Для добавления комментариев надо войти в систему.
Если Вы ещё не зарегистрированы на сайте, то сначала зарегистрируйтесь.