1. Навіщо потрібен Rule of Zero
Коли ви починаєте писати власні типи, дуже хочеться «додати солідності»: написати деструктор, реалізувати копіювання, щоб «усе було під контролем». Це відчуття зрозуміле: здається, що якщо код написано вручну, то він точніший і надійніший. Але в C++ це, на жаль, часто працює навпаки: що більше ручного керування ресурсами, то легше випадково закласти міну.
Rule of Zero (правило нуля) — це практична стратегія: якщо ваш тип не володіє ресурсами вручну, то, як правило, ви не пишете ані деструктор, ані операції копіювання, ані оператор присвоювання. «Нуль» означає: нуль самописних спецфункцій, бо вони або не потрібні, або створюють більше ризиків, ніж користі.
У стандарті C++ правила копіювання та повʼязані з ними вимоги настільки насичені деталями, що навіть у редакторських матеріалах ви можете натрапити на згадки на кшталт [class.copy.ctor] (копіювальний конструктор). Це лише натяк на те, що тема непроста, а діяти «на око» небезпечно.
Що вважається ресурсом
Коли кажуть «ресурс», новачки часто уявляють лише динамічну памʼять (new). Але в реальному програмуванні ресурс — це будь-що, що треба захопити, а потім звільнити або закрити. Навіть якщо це не памʼять, логіка все одно схожа: «взяв → використав → повернув/закрив».
Найпростіша ментальна модель така: ресурс — це те, що не можна без наслідків просто скопіювати «як число». int можна копіювати скільки завгодно — і нічого не зламається. А от «вказівник на виділену памʼять», «унікальний дескриптор чогось», «володіння обʼєктом через unique_ptr» — це вже історія про володіння.
Щоб відчути різницю, порівняймо «значення» і «ручку на ресурс»:
| Що зберігається в полі | Приклад | Можна копіювати «як є»? | Ризик під час копіювання |
|---|---|---|---|
| Значення | |
Так | Майже немає |
| Обʼєкт-обгортка-власник (RAII) | |
Так (якщо тип можна копіювати) | Зазвичай ні: усе коректно реалізовано |
| «Ручка» без власника | |
Технічно так | Часто так: подвійне звільнення, витоки, висячі вказівники |
Rule of Zero насамперед каже: не зберігайте «ручки» як форму володіння, а зберігайте власників.
2. Копіювання за полями та «value-like» типи
Тепер зробімо паузу й зафіксуймо, що означає «member-wise» (копіювання за полями). Уявіть, що у вас є структура, яка складається з інших «розумних» типів. Якщо вони вміють коректно копіюватися й знищуватися, то ваш тип теж поводитиметься добре без жодних ручних спецфункцій.
Невеликий приклад вдалого типу (у нашому навчальному застосунку TaskBoard — менеджері задач):
#include <string>
#include <vector>
struct Task {
std::string title;
std::vector<std::string> tags;
};
Тут Task схожий на коробку, усередині якої вже є дві «самокеровані» речі: рядок і вектор. Рядок сам виділяє й звільняє памʼять, вектор — так само. Тому під час копіювання Task копіюватиме вміст, а не «адреси на нього».
Перевірмо, що копія незалежна:
#include <iostream>
#include <string>
#include <vector>
struct Task {
std::string title;
std::vector<std::string> tags;
};
int main() {
Task a{"Read C++", {"study", "cpp"}};
Task b = a; // копіювання під час створення
b.title = "Read C++ harder";
std::cout << a.title << '\n'; // Read C++
}
Ми змінюємо b, а a лишається без змін. Це і є поведінка «як значення», і саме її ми хочемо бачити в більшості прикладних моделей даних.
4. Практика: як зберігати «нуль» спецфункцій
Сирі вказівники як джерело проблем
Дуже типова історія під час вивчення C++: ви вирішили «зробити швидше» або «спростити» й додали до структури поле із сирим вказівником. У цей момент компілятор і далі виконує «member-wise» копіювання, але тепер копіює вже адресу, а не дані. І якщо цей вказівник для вас означає володіння, то копіювання перетворюється на лотерею з призом «Undefined Behavior».
Поганий, але повчальний приклад:
#include <cstddef>
struct BadBuffer {
std::size_t size{};
int* data{}; // припустимо, це "володіння"
};
Що станеться, якщо написати BadBuffer b = a;? Скопіюються size і адреса data. Тобто тепер два обʼєкти «володіють» однією й тією самою памʼяттю. Якщо десь зʼявиться ручний delete[], ви майже гарантовано отримаєте або double-free, або use-after-free.
Rule of Zero пропонує не «лікувати симптоми», тобто не дописувати пʼять спецфункцій, а змінити дизайн: зберігати ресурс у полі-власнику.
Rule of Zero простими словами
Rule of Zero — це підхід до проєктування типів: якщо тип сам по собі не займається ручним керуванням ресурсом, то йому зазвичай не потрібні самописні деструктори, копіювання чи присвоювання. Натомість тип має складатися з полів, які вже коректно керують своїми ресурсами (RAII). Тоді поведінка «за замовчуванням» стає вашим союзником, а не джерелом сюрпризів.
Тут важливе слово — «проєктуємо»: це не магія компілятора, а звичка правильно будувати тип. Як у побуті: можна щоразу самому лагодити пральну машину паяльником, а можна купити нормальну машину й не чіпати її нутрощі без потреби.
TaskBoard: розширюємо модель Task без ручного керування памʼяттю
Уявімо, що в TaskBoard ми хочемо поліпшити модель задачі: додати опис і вкладення (нехай це будуть рядки — імена файлів або посилання). Головне — зробити це так, щоб тип залишався «value-like», а копіювання лишалося безпечним.
Розширімо Task:
#include <string>
#include <vector>
struct Task {
std::string title;
std::string description;
std::vector<std::string> attachments;
};
Ось і все: жодної ручної роботи з памʼяттю, жодного new, жодного деструктора. Це і є Rule of Zero в дії: ми додаємо можливості, але не додаємо мін.
Щоб побачити користь, скопіюймо список задач (наприклад, як «чернетку змін»):
#include <vector>
#include <string>
struct Task {
std::string title;
std::string description;
};
int main() {
std::vector<Task> original{{"Buy milk", "2 liters"}};
std::vector<Task> draft = original; // копія "за значеннями"
}
draft — незалежна копія. Ви можете змінювати її — наприклад, сортувати, фільтрувати або видаляти елементи, — не зачіпаючи original. Така поведінка дуже зручна в прикладному коді, коли ви хочете безпечно експериментувати з даними.
Рефакторинг буфера: char* → std::vector
Тепер зробімо найпоказовіший рефакторинг: замінимо небезпечний «буфер» на нормальний контейнер-власник. Нехай у нашому застосунку зʼявиться «нотатка» з буфером тексту (так, рядок теж підійшов би, але зараз для нас важливий саме приклад контейнера).
Поганий варіант виглядав би так:
#include <cstddef>
struct NoteBad {
std::size_t size{};
char* text{}; // "володіння" без правил
};
Натомість робимо так:
#include <vector>
struct Note {
std::vector<char> text; // володіє памʼяттю сама
};
І все. Щойно ви замінили поле на std::vector<char>, автоматично отримали коректні:
- знищення (вектор звільнить памʼять),
- копіювання (вектор скопіює вміст),
- присвоювання (вектор коректно замінить старі дані новими).
І це якраз той самий «нуль» ручного коду.
Типи, які не копіюються: unique_ptr — і це нормально
Зараз важливий момент, який багатьох спочатку дратує: ви зробили все «за правилами», а тип раптом перестав копіюватися. Наприклад, ви додали поле std::unique_ptr. Але це не баг і не покарання — це чесне відображення моделі володіння.
Уявімо, що в TaskBoard ми хочемо зберігати «унікальну чернетку» нотатки: копіювати її не можна, бо власник має бути лише один.
#include <memory>
#include <string>
struct Draft {
std::unique_ptr<std::string> text;
};
Тепер такий код не скомпілюється (і це добре):
#include <memory>
#include <string>
struct Draft {
std::unique_ptr<std::string> text;
};
int main() {
Draft a{std::make_unique<std::string>("hello")};
// Draft b = a; // помилка компіляції: unique_ptr не копіюється
}
Rule of Zero тут не порушується. Навпаки, він спрацював ідеально: тип поводиться саме так, як диктують його поля. unique_ptr виражає унікальне володіння, отже копіювання заборонено на рівні мови. Це вбудований захист, а не незручність.
До речі, навіть у матеріалах стандарту й редакторських звітах видно, скільки уваги приділяють різним тонкощам, повʼязаним із unique_ptr (наприклад, коректності конструкторів та гарантіям). Це ще один сигнал: стандартним RAII-типам краще довіряти, ніж намагатися вручну «зібрати такий самий, але простіший».
Чек-лист: чи «пахне» тип Rule of Zero
Зараз буде не «набір правил, висічених у камені», а зручна перевірка на здоровий глузд. Коли ви додаєте поле в структуру, поставте собі запитання: це поле — «значення», RAII-власник чи «ручка»?
Якщо це значення (int, std::string, std::vector, std::optional, std::unique_ptr), ви рухаєтеся в правильному напрямку. Якщо це «ручка» (T*, FILE*, «сирий дескриптор») і водночас саме через неї ваш тип чимось володіє, то у вас майже напевно назрівають ручні спецфункції та повʼязані з ними ризики.
Rule of Zero — це вміння вчасно сказати собі: «Стоп. Я зараз не зобовʼязаний писати деструктор. Я зобовʼязаний вибрати правильне поле».
5. Типові помилки під час застосування Rule of Zero
Помилка № 1: «Я напишу деструктор, просто щоб вивести лог — нічого страшного».
Проблема в тому, що щойно ви починаєте додавати ручні спецфункції «трохи», тип перестає бути простим. Навіть якщо ви не чіпаєте ресурси, ви вже берете на себе відповідальність за життєвий цикл. У навчальних прикладах це видається нешкідливим, але в реальному проєкті швидко перетворюється на хаос: один розробник додав деструктор заради std::cout, інший — копіювання «про всяк випадок», третій — ще щось, і ось у вас тип, поведінку якого важко передбачити.
Помилка № 2: зберігати «володіння» в сирому вказівнику й сподіватися, що «якось само».
Компілятор не вміє читати думки. Якщо в полі лежить T*, то під час копіювання він копіює адресу. Якщо ви при цьому десь звільняєте памʼять вручну, то майже неминуче отримаєте подвійне звільнення або висячий вказівник. Rule of Zero тут порушується не тому, що «ми не написали деструктор», а тому, що дизайн поля від самого початку не виражає володіння.
Помилка № 3: намагатися «обійти» заборону копіювання замість того, щоб прийняти модель володіння.
Коли тип не копіюється через std::unique_ptr, хтось починає шукати «трюк», як усе ж його скопіювати (наприклад, зберігати T* замість unique_ptr). Зазвичай це крок назад. Якщо володіння унікальне, нехай воно таким і лишається. Значно корисніше навчитися проєктувати функції так, щоб вони приймали такі обʼєкти за посиланням (const T& для читання, T& для змін), ніж ламати модель даних.
Помилка № 4: додавати std::shared_ptr «щоб тип знову можна було копіювати».
shared_ptr — це не кнопка «увімкнути копіювання». Це інша семантика володіння: спільна. Якщо ви ставите shared_ptr лише для того, щоб компілятор перестав лаятися, то зазвичай отримуєте складнішу модель володіння і дорожчі операції, а інколи ще й логічні витоки (цикли володіння). Якщо вам потрібне унікальне володіння, unique_ptr чесніший; якщо потрібне значення, то найчастіше достатньо std::string/std::vector.
Помилка № 5: «Rule of Zero означає: ніколи не писати жодних функцій».
Rule of Zero не забороняє додавати методи до вашого типу. Він говорить про керування ресурсами та спецфункції життєвого циклу. Методи на кшталт addTag(), print(), isDone() — будь ласка. Але якщо ви ловите себе на думці «мені треба написати деструктор, щоб усе працювало», це сильний сигнал, що дизайн полів потребує перегляду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ