Правило трёх, пяти и нуля в C++: простое практическое руководство с примерами

Правило трёх, пяти и нуля в C++: простое практическое руководство с примерами

Правило трёх, пяти и нуля в 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 ctorn";
        }
    
        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 assignn";
            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 ctorn";
        }
        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 assignn";
            return *this;
        }
    
        // Перемещающий конструктор и оператор (важно: noexcept)
        Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
            other.size_ = 0; other.data_ = nullptr;
            std::cout << "move ctorn";
        }
        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 assignn";
            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++ с Нуля до Гуру — посмотреть программу и начать обучение.

    Источник

    НЕТ КОММЕНТАРИЕВ

    Оставить комментарий