1. Зачем нужен Rule of Zero
Когда вы начинаете писать свои типы, очень хочется «добавить солидности»: написать деструктор, написать копирование, «чтобы всё было под контролем». Это ощущение понятно: кажется, что если код написан руками, то он точнее и надёжнее. В C++ это, увы, часто наоборот — чем больше ручного управления ресурсами, тем легче случайно собрать мину.
Rule of Zero (правило нуля) — это практическая стратегия: если ваш тип не владеет ресурсами вручную, то, как правило, вы не пишете ни деструктор, ни копирование, ни присваивание. «Ноль» означает: ноль самописных спец-функций, потому что они либо не нужны, либо создают больше рисков, чем пользы.
В стандарте C++ разделы про копирование и связанные правила настолько богаты деталями, что даже в редакторских правках вы можете встретить упоминания вроде [class.copy.ctor] (копирующий конструктор) — это намёк, что тема не из простых, и «делать на глазок» опасно.
Что считается ресурсом
Когда говорят «ресурс», новички часто представляют только динамическую память (new). Но в реальном программировании ресурс — это любая штука, которую надо захватить и потом освободить или закрыть. Даже если это не память, логика всё равно похожа: «взял → использовал → вернул/закрыл».
Простейшая mental-модель такая: ресурс — это то, что нельзя просто скопировать “как число” без последствий. 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() — пожалуйста. Но если вы ловите себя на мысли “мне нужно написать деструктор, чтобы всё работало” — это сильный сигнал, что дизайн полей требует пересмотра.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ