JavaRush /Курсы /C++ SELF /Rule of Zero — проектируем тип без управления ресурсами

Rule of Zero — проектируем тип без управления ресурсами

C++ SELF
44 уровень , 4 лекция
Открыта

1. Зачем нужен Rule of Zero

Когда вы начинаете писать свои типы, очень хочется «добавить солидности»: написать деструктор, написать копирование, «чтобы всё было под контролем». Это ощущение понятно: кажется, что если код написан руками, то он точнее и надёжнее. В C++ это, увы, часто наоборот — чем больше ручного управления ресурсами, тем легче случайно собрать мину.

Rule of Zero (правило нуля) — это практическая стратегия: если ваш тип не владеет ресурсами вручную, то, как правило, вы не пишете ни деструктор, ни копирование, ни присваивание. «Ноль» означает: ноль самописных спец-функций, потому что они либо не нужны, либо создают больше рисков, чем пользы.

В стандарте C++ разделы про копирование и связанные правила настолько богаты деталями, что даже в редакторских правках вы можете встретить упоминания вроде [class.copy.ctor] (копирующий конструктор) — это намёк, что тема не из простых, и «делать на глазок» опасно.

Что считается ресурсом

Когда говорят «ресурс», новички часто представляют только динамическую память (new). Но в реальном программировании ресурс — это любая штука, которую надо захватить и потом освободить или закрыть. Даже если это не память, логика всё равно похожа: «взял → использовал → вернул/закрыл».

Простейшая mental-модель такая: ресурс — это то, что нельзя просто скопировать “как число” без последствий. int можно копировать сколько угодно — и ничего не сломается. А вот «указатель на выделенную память», «уникальный дескриптор чего-то», «владение объектом через unique_ptr» — уже история про владение.

Чтобы почувствовать разницу, давайте сравним “значение” и “ручку на ресурс”:

Что хранится в поле Пример Можно копировать “как есть”? Риск при копировании
Значение
int, double
Да Почти нет
Владельческий объект-обёртка (RAII)
std::string, std::vector<int>
Да (если тип копируемый) Обычно нет: всё корректно реализовано
“Ручка” без владельца
int*, char*
Технически да Часто да: двойное освобождение, утечки, висячие указатели

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() — пожалуйста. Но если вы ловите себя на мысли “мне нужно написать деструктор, чтобы всё работало” — это сильный сигнал, что дизайн полей требует пересмотра.

1
Задача
C++ SELF, 44 уровень, 4 лекция
Недоступна
Клон профиля
Клон профиля
1
Задача
C++ SELF, 44 уровень, 4 лекция
Недоступна
Текстовый буфер
Текстовый буфер
1
Задача
C++ SELF, 44 уровень, 4 лекция
Недоступна
Черновик перенос
Черновик перенос
1
Задача
C++ SELF, 44 уровень, 4 лекция
Недоступна
Снимок документа
Снимок документа
1
Опрос
shared_ptr/weak_ptr, 44 уровень, 4 лекция
Недоступен
shared_ptr/weak_ptr
shared_ptr/weak_ptr
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ