Синтаксис си/с++

Правило пяти/нуля (Rule of Five/Zero)

Введение

Статьи 532 (конструкторы/деструкторы) и 546 (move-семантика) разбирали отдельные специальные методы класса — правило пяти систематизирует, какие именно специальные методы нужно рассматривать совместно при проектировании класса, управляющего ресурсом напрямую, а правило нуля показывает, как чаще всего избежать необходимости вообще писать любой из них вручную.

Концепция

Правило пяти утверждает, что если класс явно определяет хотя бы один из пяти специальных методов — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения (статья 546), оператор присваивания перемещением — это обычно сигнализирует, что класс управляет каким-то ресурсом напрямую, и в таком случае стоит явно рассмотреть и определить (или явно удалить через = delete) все пять методов согласованно, поскольку компилятор, сгенерированные по умолчанию версии некоторых из них могут оказаться некорректными для класса, управляющего ресурсом вручную (классическая проблема — компилятор по умолчанию сгенерировал бы поверхностное копирование указателя на ресурс, а не глубокое копирование самого ресурса). Правило нуля — более современная, предпочтительная рекомендация: вместо того чтобы реализовывать все пять методов вручную для класса, управляющего ресурсом, лучше делегировать это управление существующим, уже корректно реализующим правило пяти RAII-классам (умные указатели статьи 547, стандартные контейнеры), оставляя собственный класс полностью без явно определённых специальных методов, поскольку сгенерированные компилятором по умолчанию версии корректно работают, если все поля сами являются такими RAII-объектами, корректно управляющими своими собственными ресурсами.

Пример кода: Правило пяти/нуля (Rule of Five/Zero)

#include <iostream>
#include <cstring>

// Нарушение правила пяти — определён только деструктор, остальные оставлены сгенерированными по умолчанию
class BrokenBuffer {
public:
    BrokenBuffer(size_t size) : m_size(size), m_data(new char[size]) {}
    ~BrokenBuffer() { delete[] m_data; } // определён деструктор...

    // НЕ определены конструктор копирования / оператор= копирования —
    // компилятор СГЕНЕРИРУЕТ их по умолчанию, выполняющие ПОВЕРХНОСТНОЕ копирование указателя m_data!

private:
    size_t m_size;
    char *m_data;
};

void demonstrateBug()
{
    BrokenBuffer a(100);
    BrokenBuffer b = a; // ПОВЕРХНОСТНОЕ копирование — a.m_data и b.m_data указывают на ОДНУ И ТУ ЖЕ память!
} // ОБА деструктора (a и b) попытаются delete[] ОДНУ И ТУ ЖЕ память — ДВОЙНОЕ ОСВОБОЖДЕНИЕ!
// Правильное соблюдение правила пяти — явное определение ВСЕХ пяти методов
class CorrectBuffer {
public:
    CorrectBuffer(size_t size) : m_size(size), m_data(new char[size]) {}
    ~CorrectBuffer() { delete[] m_data; }

    CorrectBuffer(const CorrectBuffer &other) : m_size(other.m_size), m_data(new char[other.m_size])
    {
        memcpy(m_data, other.m_data, m_size); // ГЛУБОКОЕ копирование — отдельная память для каждого объекта
    }

    CorrectBuffer &operator=(const CorrectBuffer &other)
    {
        if (this != &other) { // защита от самоприсваивания
            delete[] m_data;
            m_size = other.m_size;
            m_data = new char[m_size];
            memcpy(m_data, other.m_data, m_size);
        }
        return *this;
    }

    CorrectBuffer(CorrectBuffer &&other) noexcept : m_size(other.m_size), m_data(other.m_data)
    {
        other.m_data = nullptr; // статья 546 — опустошение исходного объекта после перемещения
        other.m_size = 0;
    }

    CorrectBuffer &operator=(CorrectBuffer &&other) noexcept
    {
        if (this != &other) {
            delete[] m_data;
            m_data = other.m_data;
            m_size = other.m_size;
            other.m_data = nullptr;
            other.m_size = 0;
        }
        return *this;
    }

private:
    size_t m_size;
    char *m_data;
};
// Правило НУЛЯ — делегирование управления ресурсом существующим RAII-классам
#include <vector>
#include <string>

class ModernBuffer { // НИ ОДНОГО специального метода не определено вручную — и это ПРАВИЛЬНО!
public:
    ModernBuffer(size_t size) : m_data(size) {} // std::vector сам управляет памятью корректно

private:
    std::vector<char> m_data; // RAII-объект — компилятор сгенерированные копирование/перемещение
                                 // автоматически КОРРЕКТНЫ, поскольку std::vector сам их корректно реализует
};

Пояснения к коду

BrokenBuffer явно демонстрирует классическую опасность нарушения правила пяти — определение только деструктора без согласованного определения конструктора/оператора копирования оставляет компилятор генерировать их по умолчанию, что для класса с «сырым» указателем m_data приводит к поверхностному копированию (просто копированию значения указателя, а не выделению новой памяти и копированию реального содержимого), что и демонстрирует комментарий о двойном освобождении при уничтожении обоих объектов, теперь указывающих на одну и ту же память. CorrectBuffer показывает полное, согласованное определение всех пяти методов — каждый явно реализует корректную, ожидаемую семантику (глубокое копирование для копирующих операций, опустошение исходного объекта для перемещающих операций), что устраняет проблему BrokenBuffer, хотя достигается это ценой существенно большего объёма явно написанного, потенциально подверженного собственным ошибкам кода. ModernBuffer, делегирующий управление памятью std::vector<char> вместо «сырого» указателя, показывает практическое применение правила нуля — поскольку единственное поле само является корректно реализующим правило пяти RAII-объектом, сгенерированные компилятором по умолчанию версии всех пяти специальных методов для самого ModernBuffer автоматически корректны (компилятор просто вызывает соответствующие операции для поля m_data), и явное написание какого-либо из этих пяти методов вручную попросту не нужно.

Подводные камни

  • Определение только одного или нескольких из пяти специальных методов без рассмотрения остальных согласованно (как явно демонстрирует BrokenBuffer) — это одна из самых классических, серьёзных ошибок управления ресурсами в C++, и любое явное определение хотя бы одного из этих пяти методов должно служить сигналом для осознанной, явной проверки, действительно ли сгенерированные по умолчанию версии ОСТАЛЬНЫХ четырёх методов корректны для этого конкретного класса, а не молчаливым предположением об их автоматической корректности.
  • Предпочтение явного, «ручного» соблюдения правила пяти (как CorrectBuffer) вместо более простого делегирования через правило нуля (как ModernBuffer) без действительно веской причины — современная, рекомендуемая практика C++ настойчиво советует именно правило нуля как предпочтительный, менее подверженный ошибкам подход для подавляющего большинства классов, и явная, «ручная» реализация всех пяти методов (правило пяти) должна применяться только там, где это действительно необходимо (например, при написании самих низкоуровневых RAII-обёрток, подобных std::vector/std::unique_ptr, для которых правило нуля по определению неприменимо, поскольку именно они и являются тем фундаментальным «строительным материалом», на который правило нуля для остальных классов опирается).
  • Забытая защита от самоприсваивания (if (this != &other)) в операторе присваивания копированием, особенно важная для классов, управляющих ресурсом напрямую — без этой проверки выражение вида obj = obj; (самоприсваивание, иногда происходящее не явно, а через цепочку ссылок/указателей, не сразу очевидную в коде) могло бы освободить ресурс (delete[] m_data) перед попыткой скопировать данные ИЗ ЭТОГО ЖЕ, уже освобождённого ресурса, что является серьёзной ошибкой, и явная проверка идентичности адресов перед выполнением реальной логики присваивания — обязательная, не опциональная защита для таких операторов.
  • Отсутствие noexcept на move-конструкторе/move-операторе присваивания, аналогично подводному камню статьи 546 — это особенно важно именно в контексте полного, ручного соблюдения правила пяти, поскольку стандартные контейнеры (статья 542) могут принять решение не использовать предоставленные move-операции для оптимизации внутренних операций (например, при изменении размера std::vector элементов этого класса), если эти move-операции не явно гарантированно не выбрасывают исключений, что нивелирует часть пользы от их явной, тщательной реализации в рамках правила пяти.