специфика C++ — управление ресурсами без сборщика мусораC++-specific — resource management without a garbage collector

Rule of 0 / 3 / 5

Компилятор генерирует пять специальных функций как набор — но неявная генерация останавливается неравномерно, стоит объявить хотя бы одну из них вручную. Это правило существует не как стилистическая рекомендация, а как способ не наткнуться на ту самую особенность компилятора.The compiler generates five special member functions as a set — but implicit generation stops unevenly the moment even one of them is declared by hand. This rule exists not as a stylistic suggestion, but as a way to avoid tripping over that exact compiler quirk.

фундаментfoundational частый вопросcommon interview question

Пятёрка специальных функцийThe five special member functions

Компилятор способен неявно сгенерировать пять функций: деструктор, copy-конструктор, copy-присваивание, move-конструктор, move-присваивание. По умолчанию, если класс не объявляет ничего из этого списка, все пять генерируются автоматически с почленной (member-wise) семантикой.The compiler is capable of implicitly generating five functions: the destructor, copy constructor, copy assignment, move constructor, move assignment. By default, if a class declares none of these, all five are generated automatically with member-wise semantics.

class T {
    ~T();                          // 1. деструктор// 1. destructor
    T(const T&);                  // 2. copy-конструктор// 2. copy constructor
    T& operator=(const T&);       // 3. copy-присваивание// 3. copy assignment
    T(T&&);                       // 4. move-конструктор// 4. move constructor
    T& operator=(T&&);            // 5. move-присваивание// 5. move assignment
};

Rule of 0: если класс не владеет ресурсом напрямую — не объявлять ничегоRule of 0: if the class doesn't own a resource directly — declare nothing

Если все поля класса — типы, уже корректно реализующие свою собственную семантику копирования/перемещения/разрушения (std::string, std::vector, std::unique_ptr, другие классы, следующие этому же правилу), почленная генерация всех пяти функций компилятором автоматически корректна — руками не нужно писать ничего из пятёрки вообще.If every field of a class is a type that already correctly implements its own copy/move/destruction semantics (std::string, std::vector, std::unique_ptr, other classes following this same rule), the compiler's member-wise generation of all five functions is automatically correct — none of the five needs to be written by hand at all.

class Person {
    std::string name;               // сам умеет копироваться/перемещаться/разрушаться правильно// already knows how to copy/move/destroy itself correctly
    std::vector<int> scores;
    std::unique_ptr<Address> address;
    // ни деструктор, ни copy/move — не объявлены. Всё генерируется правильно само.// no destructor, no copy/move — declared. Everything is generated correctly on its own.
};

Это не просто "меньше кода" — это меньше поверхности для ошибки. Каждая написанная руками специальная функция — код, который нужно поддерживать при добавлении новых полей; забытое поле в ручном copy-конструкторе компилируется без единого предупреждения и тихо не копируется.This isn't just "less code" — it's less surface for error. Every hand-written special function is code that must be kept in sync when new fields are added; a forgotten field in a hand-written copy constructor compiles with zero warnings and is silently not copied.

Rule of 5: объявил одну — осмысли все пятьRule of 5: declare one — think through all five

Если класс владеет ресурсом напрямую (сырой указатель, файловый дескриптор, любой хендл ОС) и поэтому вынужден объявить хотя бы одну из пятёрки (обычно деструктор — для освобождения ресурса), нужно явно решить судьбу всех остальных четырёх: реализовать их корректно, объявить = default, если умолчание всё же годится, или запретить через = delete.If a class owns a resource directly (a raw pointer, a file descriptor, any OS handle) and is therefore forced to declare at least one of the five (usually the destructor — to release the resource), the fate of all four others must be explicitly decided: implement them correctly, declare them = default if the default is actually fine, or forbid them via = delete.

class Buffer {
    int* data;
    size_t size;
public:
    Buffer(size_t n) : data(new int[n]), size(n) {}
    ~Buffer() { delete[] data; }                       // объявлен — Rule of 5 требует осмыслить остальные 4// declared — Rule of 5 demands thinking through the other 4

    Buffer(const Buffer& other) : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);   // глубокое копирование// deep copy
    }
    Buffer& operator=(const Buffer& other) { // ... аналогично, с защитой от самоприсваивания ...// ... similarly, guarded against self-assignment ...
        return *this;
    }
    Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr; other.size = 0;                 // перемещение — забрать ресурс, обнулить источник// move — steal the resource, null the source
    }
    Buffer& operator=(Buffer&& other) noexcept { // ... аналогично move-конструктору ...// ... similarly to the move constructor ...
        return *this;
    }
};

Ключевая грабля: деструктор подавляет неявные move-операцииThe key trap: a destructor suppresses the implicit move operations

Объявление ЛЮБОЙ из пятёрки функций (даже одного деструктора, даже пустого ~T() = default;) отключает неявную генерацию move-конструктора и move-присваивания компилятором — это единственная асимметрия в правилах генерации, и именно она делает Rule of 5 необходимым, а не просто хорошим стилем.Declaring ANY of the five (even a single destructor, even an empty ~T() = default;) disables the compiler's implicit generation of the move constructor and move assignment — this is the one asymmetry in the generation rules, and it's exactly what makes the Rule of 5 necessary rather than just good style.

class Widget {
    std::vector<int> data;
public:
    ~Widget() { log("destroyed"); }   // объявлен деструктор — move-операции больше НЕ генерируются неявно// destructor declared — move operations are NO LONGER generated implicitly
};

Widget a;
Widget b = std::move(a);   // компилируется, но вызывает COPY-конструктор, не move — data копируется целиком// compiles, but calls the COPY constructor, not move — data is copied wholesale

Код в примере выше компилируется без единого предупреждения и работает "корректно" в смысле результата, но незаметно теряет всю выгоду от перемещения — std::move(a) лишь превращает a в rvalue-ссылку, а какой конструктор фактически вызовется (move или copy), решает перегрузка на основе того, что реально СГЕНЕРИРОВАНО, а не того, что написал программист в вызывающем коде.The code in the example above compiles with zero warnings and works "correctly" in terms of the result, but silently loses the entire benefit of moving — std::move(a) only turns a into an rvalue reference; which constructor actually gets called (move or copy) is decided by overload resolution based on what's actually been GENERATED, not on what the programmer wrote at the call site.

Исправление — либо явно объявить (или запросить = default) move-конструктор/присваивание вместе с деструктором, либо, что чаще правильнее, вообще не объявлять деструктор вручную и следовать Rule of 0 (переложить владение ресурсом на член-обёртку вроде unique_ptr).The fix is either to explicitly declare (or request = default for) the move constructor/assignment alongside the destructor, or, more often the right call, to not declare a destructor by hand at all and follow the Rule of 0 instead (delegate resource ownership to a wrapper member like unique_ptr).

ГраблиGotchas

= default после объявления деструктора не восстанавливает автоматически то же поведениеafter declaring a destructor doesn't automatically restore the same behavior

Написание MyClass(MyClass&&) = default; явно ЗАПРАШИВАЕТ у компилятора сгенерировать move-конструктор с обычной (почленной) семантикой — это разрешено и работает, даже если деструктор объявлен, но требует явной строки кода на каждую операцию, которую раньше компилятор генерировал молча.Writing MyClass(MyClass&&) = default; explicitly ASKS the compiler to generate the move constructor with ordinary (member-wise) semantics — this is allowed and works even with a destructor declared, but it requires an explicit line of code for each operation the compiler used to generate silently.

Объявление одного из copy-конструктора/присваивания тоже подавляет move (но не наоборот)Declaring one of copy constructor/assignment also suppresses move (but not the reverse)

Правило про подавление касается не только деструктора: объявление ЛЮБОЙ функции копирования (copy-конструктора или copy-присваивания) тоже отключает неявную генерацию move-операций — по той же логике "класс явно выразил нестандартное отношение к идентичности объекта". Обратное правило асимметрично: объявление move-операций делает copy-операции = deleted неявно (а не просто не генерируемыми) — компилятор предполагает, что раз автор позаботился о перемещении, копирование, скорее всего, либо не нужно, либо небезопасно.The suppression rule isn't just about the destructor: declaring EITHER copying function (copy constructor or copy assignment) also disables implicit generation of the move operations — under the same logic that "the class has explicitly expressed a non-default relationship with object identity." The reverse rule is asymmetric: declaring the move operations makes the copy operations implicitly = deleted (not merely ungenerated) — the compiler assumes that since the author bothered with moving, copying is likely either unneeded or unsafe.

noexcept на move-операциях — не формальность, а условие их реального использованияon move operations isn't a formality — it's a condition for their actual use

Многие стандартные алгоритмы (в частности, реаллокация std::vector) используют move-конструктор только если он помечен noexcept — иначе, из соображений строгой гарантии безопасности исключений, используется copy-конструктор, даже если move-конструктор технически определён. Забытый noexcept на move-конструкторе — тихая потеря производительности без единой ошибки компиляции.Many standard algorithms (in particular, std::vector reallocation) use the move constructor only if it's marked noexcept — otherwise, for the sake of the strong exception-safety guarantee, the copy constructor is used instead, even if a move constructor is technically defined. A forgotten noexcept on a move constructor is a silent performance loss with zero compile errors.