JavaRush /Курси /C++ SELF /Rule of Five: краще, ніж Rule of Zero

Rule of Five: краще, ніж Rule of Zero

C++ SELF
Рівень 45 , Лекція 3
Відкрита

1. Додали деструктор — зламалося копіювання

На цьому етапі багато студентів уперше стикаються з «ефектом доміно»: ви додаєте в тип деструктор, щоб звільнити ресурс, і раптом зʼясовується, що звичайне копіювання обʼєкта починає ламати програму. Причина банальна, але прикра: копіювання за замовчуванням копіює поля, а якщо серед полів є «ручка» ресурсу (наприклад, T*), то копіюється не сам ресурс, а лише адреса.

Змоделюймо типову ситуацію. Ви вирішили зберігати динамічний масив — неважливо навіщо, — чесно звільнили його в деструкторі, і наче все зробили правильно.


#include <cstddef>

struct IntBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit IntBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~IntBuffer() { delete[] data; }
};

А тепер — один рядок, який виглядає цілком невинно:

int main() {
    IntBuffer a{3};
    IntBuffer b = a; // копія за замовчуванням: size копіюється, data копіюється (адреса!)
}

Що відбувається насправді? Обидва обʼєкти вважають себе власниками того самого масиву. Коли програма вийде з main, спочатку зруйнується b і виконає delete[], потім зруйнується a і спробує виконати delete[] ще раз. Це називається double-free. У кращому разі програма аварійно завершиться. У гіршому — ви отримаєте дивні «примарні баги», які проявляться не відразу.

Висновок цього розділу простий і неприємний: щойно ви робите тип власником ресурсу, копіювання за замовчуванням найчастіше стає неправильним за змістом.

2. Rule of Five: що це і чому він зʼявляється

Rule of Five звучить як назва бойовика категорії B, але насправді йдеться про дисципліну: якщо ваш тип вручну володіє ресурсом, вам майже завжди потрібно узгоджено визначити або заборонити набір із пʼяти спеціальних функцій-членів. Робимо ми це не заради «краси коду», а щоб не підірвати власну програму подвійним звільненням і висячими вказівниками.

Ось ці пʼять «спецфункцій»:

Спецфункція Що робить по суті Чому важлива для ресурсу
Деструктор ~T() звільняє ресурс без нього буде витік
Копіювальний конструктор T(const T&) створює копію обʼєкта для ресурсу потрібна глибока копія або заборона копіювання
Оператор копіювального присвоювання T& operator=(const T&) замінює вміст обʼєкта копією потрібно коректно звільнити старе й скопіювати нове
Конструктор переміщення T(T&&) переносить ресурс із джерела дає змогу «переїжджати» без дорогої копії
Оператор присвоювання переміщення T& operator=(T&&) замінює ресурс через переміщення потрібно коректно звільнити старе й забрати нове

Важливо: сам факт існування цих функцій ви вже зустрічали раніше, але сьогодні ми фіксуємо практичний причинно-наслідковий звʼязок. Якщо ви володієте ресурсом «голими руками», компілятор не зможе вгадати ваш задум. Він чесно скопіює поля — і так само чесно зламає модель володіння.

Ознаки «ручного ресурсу» в типі

Rule of Five потрібен не тому, що «так заведено в C++», а тому, що тип отримує відповідальність: хтось має гарантувати коректне звільнення ресурсу та правильну поведінку під час копіювання або переміщення. Найпростіший спосіб зрозуміти, чи потрібен вам Rule of Five, — подивитися, чи є в типу ресурс, який не можна просто побітово скопіювати.

Перелічімо типові «ручні ресурси» без заглиблення в майбутні теми.

Ресурс Як виглядає «ручка» Чим звільняємо Що ламається під час копіювання за замовчуванням
Динамічна памʼять T* + new/new[] delete/delete[] double-free / use-after-free
Файл (рівень C) FILE* fclose() два обʼєкти спробують закрити один файл
Системний дескриптор (умовно) число або вказівник-ідентифікатор «close-функція» API аналогічно: два власники одного дескриптора

Ми навмисно поки не заглиблюємося в складніші ресурси й архітектурні варіанти. Але й цього достатньо: якщо всередині типу є «ручка», яку ви зобовʼязані закривати або звільняти вручну, отже, тип має явне володіння.

І ось тут зʼявляється розвилка: або ви реалізуєте глибоку копію, щоб копія отримувала свій ресурс, або забороняєте копіювання й дозволяєте лише переміщення, або робите так, щоб ресурс жив усередині RAII-поля й усе розвʼязувалося через Rule of Zero.

Чому «написати лише деструктор» майже завжди гірше, ніж здається

Коли новачок пише перший деструктор, він зазвичай відчуває гордість рівня «я приборкав памʼять». Проблема в тому, що один деструктор рідко буває «однією маленькою деталлю». Він змінює сам зміст типу: тепер у вас є володіння, а володіння завжди тягне за собою питання «що означає копія?».

Навіть якщо на рівні логіки ви взагалі не планували копіювати цей обʼєкт, копіювання може статися «дорогою»: ви повернули обʼєкт із функції, поклали його в контейнер, передали за значенням або випадково написали auto x = y;. І раптом ви вже у світі, де два обʼєкти дивляться на один ресурс.

Ще неприємніше те, що деякі автоматичні оптимізації й «переїзди» обʼєктів залежать від того, які спецфункції доступні та які контракти вони задають. Наприклад, стандартна бібліотека дуже любить, коли переміщення не кидає винятків; у unique_ptr це ретельно підтримується.

Тому головна думка цього розділу така: деструктор — це не «ще одна функція», а оголошення світу, у якому ви відповідаєте за ресурс. А якщо вже відповідаєте, доведеться відповісти й на питання про копіювання та переміщення.

Як вибрати підхід без містики

Зазвичай студенти намагаються запамʼятати Rule of Five як «магічну формулу», але краще тримати в голові просту схему вибору. Зараз ваша мета — не навчитися «героїчно писати спецфункції», а вчасно навчитися не писати їх.

Нижче — логіка ухвалення рішення у вигляді блок-схеми.

flowchart TD
    A[Ви проєктуєте власний тип] --> B{Є ручний ресурс? new/delete, FILE*, handle}
    B -- ні --> C["Rule of Zero: RAII-поля (string, vector, unique_ptr), спецфункції не пишемо"]
    B -- так --> D{Чи потрібна копія за змістом?}
    D -- ні --> E["Забороняємо копіювання: =delete (про переміщення подумаємо пізніше)"]
    D -- так --> F[Rule of Five: продумуємо глибоку копію й переміщення]

Зверніть увагу на зміст: Rule of Five зʼявляється не тому, що ви знайшли в книжці слово «пʼятірка», а тому, що чесно відповіли «так» на питання: «У мене є ручний ресурс».

Якщо ви робите так, щоб «ручного ресурсу» не було, або якщо він загорнутий у RAII-поле, тоді Rule of Zero автоматично робить ваш код простішим, безпечнішим і зазвичай навіть швидшим у розробці.

3. Три стратегії: Rule of Zero, Rule of Five або заборона копіювання

У реальному коді у вас майже завжди є вибір. І добрий стиль сучасного C++ — це не «всюди писати Rule of Five», а навпаки: робити так, щоб Rule of Five був рідкісним винятком. Розгляньмо три стратегії без фанатизму.

Rule of Zero: нехай володіє стандартна бібліотека

Rule of Zero — це підхід, коли ви зберігаєте ресурс усередині RAII-поля: std::string, std::vector, std::unique_ptr, потік файлу тощо. Тоді компілятор може сам згенерувати коректні спецфункції, тому що кожне поле вже вміє правильно копіюватися й переміщатися.

Приклад: нам потрібен «буфер чисел». Замість new[] беремо std::vector<int>.

#include <vector>
#include <cstddef>

struct IntBuffer {
    std::vector<int> data;              // RAII: сам керує памʼяттю
    explicit IntBuffer(std::size_t n) : data(n, 0) {}
};

Тут не потрібні ні деструктор, ні копіювальний конструктор, ні операції переміщення: std::vector усе зробить сам.

Rule of Five: так, я справді вручну володію ресурсом

Це ситуація, коли ви з якоїсь причини не можете або не хочете зберігати ресурс у стандартному власнику. У навчальних цілях ми робимо це, щоб зрозуміти механіку. У реальному житті таке має бути рідкістю й зазвичай потребує вагомої причини.

Важливо: у цій лекції ми не реалізуємо всі операції повністю — це детально розбиратиметься на наступних лекціях, — але вчимося бачити сам «тригер»: якщо володіємо вручну, отже, маємо продумати всю пʼятірку.

#include <cstddef>

struct RawBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit RawBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~RawBuffer() { delete[] data; }

    RawBuffer(const RawBuffer& other);                // знадобиться
    RawBuffer& operator=(const RawBuffer& other);     // знадобиться
    RawBuffer(RawBuffer&& other) noexcept;            // знадобиться
    RawBuffer& operator=(RawBuffer&& other) noexcept; // знадобиться
};

Найважливіший момент: щойно ви написали ~RawBuffer() і всередині delete[], ви майже неминуче приходите до «пʼятірки» — або у вигляді реалізацій, або у вигляді заборон.

Заборона копіювання: володіння унікальне, копії не буде

Іноді копіювання за змістом узагалі не потрібне або навіть шкідливе. Наприклад, ви хочете, щоб обʼєкт був єдиним власником ресурсу. Тоді найчесніше — сказати це компілятору прямо.

#include <cstddef>

struct RawBuffer {
    std::size_t size{};
    int* data{nullptr};

    explicit RawBuffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~RawBuffer() { delete[] data; }

    RawBuffer(const RawBuffer&) = delete;
    RawBuffer& operator=(const RawBuffer&) = delete;
};

Це не «ліниве рішення». Це цілком нормальний дизайн-контракт: «володіння унікальне». За духом це схоже на std::unique_ptr: він теж не копіюється.

4. Приклад із консольного застосунку: TaskBox та історія команд

Щоб тема не здавалася надто абстрактною, привʼяжімо її до нашого навчального консольного застосунку. Нехай це буде простий «TaskBox»: ми читаємо команди, додаємо задачі, виводимо список. Досі ми спиралися на std::string і std::vector, і це було правильно: вони вже працюють за RAII.

Почнімо зі стану застосунку: список задач і історія останніх введених команд.

#include <string>
#include <vector>

struct AppState {
    std::vector<std::string> tasks;
    std::vector<std::string> history; // Rule of Zero: копіюється коректно
};

Такий стан можна спокійно копіювати, хоч це й не завжди потрібно: std::vector<std::string> сам виконає глибоке копіювання.

Тепер уявімо, що хтось вирішив «оптимізувати» історію: «Навіщо зберігати багато рядків? Давайте все склеїмо в один великий char*». І саме тут Rule of Five стає реальною потребою.

#include <cstddef>
#include <cstring>

struct HistoryBlob {
    std::size_t size{};
    char* data{nullptr};

    explicit HistoryBlob(const char* text) {
        size = std::strlen(text);
        data = new char[size + 1]{};
        std::strcpy(data, text);
    }

    ~HistoryBlob() { delete[] data; }
};

Виглядає працездатно. А тепер додаймо це до стану:

#include <vector>
#include <string>

struct AppState {
    std::vector<std::string> tasks;
    HistoryBlob history{"init"}; // володіє памʼяттю вручну
};

І ось тут ми потрапляємо просто в центр теми: тепер AppState небезпечно копіювати, бо всередині є HistoryBlob із «голим» володінням. Компілятор згенерує копіювання AppState, яке скопіює history.data як адресу. Далі сюжет ви вже знаєте: два деструктори, один вказівник.

Тобто вибір стратегії — не абстракція. Він буквально відповідає на питання: «Чи можна безпечно написати AppState b = a;

Якщо ви бачите, що поле вашого типу — std::vector<std::string>, то копіювання безпечне. Якщо ж поле — char* із delete[] у деструкторі, тоді копіювання за замовчуванням перетворюється на міну.

5. Типові помилки під час вибору між Rule of Zero і Rule of Five

Помилка № 1: «Я написав деструктор, а більше нічого не змінював — воно ж компілюється».
Це найпідступніша помилка, бо компілятор справді може мовчки скомпілювати код, а проблема проявиться пізніше: під час копіювання обʼєкта, під час повернення з функції або під час перестановок усередині контейнера. Якщо у вас є delete/delete[] у деструкторі, то майже напевно потрібно або заборонити копіювання, або реалізувати глибоку копію, або переробити поля на RAII-типи.

Помилка № 2: зберігати володіння в «голому» вказівнику, коли можна зберігати його в std::vector/std::string.
Новачки часто пишуть char*, бо «так ближче до заліза», хоча задача — просто зберігати текст. У підсумку ви отримуєте Rule of Five там, де міг би бути Rule of Zero. Сучасний C++ якраз і цінний тим, що в прикладному коді ви рідко зобовʼязані вручну керувати памʼяттю.

Помилка № 3: переплутати «скопіювати обʼєкт» і «скопіювати адресу».
Копіювання T* — це не копіювання даних, а лише копіювання адреси, за якою ці дані лежать. Якщо два власники отримали одну адресу, вони не стали «дружньою командою» — вони стали конкурентами за право першими викликати delete.

Помилка № 4: намагатися «лагодити поверхневу копію» точково, а не системно.
Іноді роблять так: «Ну, я в одному місці обережно не копіюю». Але це слабка позиція: код зростає, зʼявляються нові функції, контейнери, повернення за значенням. Надійніше, коли сам тип гарантує коректність: або він коректно копіюється й робить глибоку копію, або чесно не копіюється (=delete), або взагалі не володіє ресурсом вручну, тобто живе за Rule of Zero.

Помилка № 5: сприймати Rule of Five як «правило, яке треба застосовувати завжди».
Це майже протилежна крайність. Rule of Five — це набір інструментів на випадок, коли ви справді володієте ресурсом вручну. У звичайному прикладному коді ваше перше запитання має бути таким: «А чи можна зробити так, щоб володів std::vector/std::string/unique_ptr?» Якщо можна — так і робіть, і ви автоматично отримаєте коректні копії та переміщення без зайвого болю.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ