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?» Якщо можна — так і робіть, і ви автоматично отримаєте коректні копії та переміщення без зайвого болю.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ