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&) | заменяет содержимое объекта копией | нужно корректно освободить старое и скопировать новое |
| Move-конструктор T(T&&) | переносит ресурс из источника | чтобы можно было “переезжать” без дорогой копии |
| Move-присваивание T& operator=(T&&) | заменяет ресурс переносом | нужно корректно освободить старое и забрать новое |
Важно: само наличие этих функций вы уже встречали ранее, но сегодня мы фиксируем практическую причинно-следственную связь. Если вы владеете ресурсом “голыми руками”, то компилятор не сможет угадать ваш смысл. Он честно скопирует поля — и честно сломает владение.
Признаки «ручного ресурса» в типе
Rule of Five нужен не потому, что «так принято в C++», а потому что у типа появляется ответственность: кто-то должен гарантировать корректное освобождение ресурса и корректное поведение при копировании/перемещении. Самый простой способ понять, нужен ли вам Rule of Five, — посмотреть, есть ли у типа ресурс, который нельзя просто побитово скопировать.
Давайте перечислим типовые “ручные ресурсы” без углубления в будущие темы.
| Ресурс | Как выглядит “ручка” | Чем освобождаем | Что ломается при копии по умолчанию |
|---|---|---|---|
| Динамическая память | T* + new/new[] | delete/delete[] | double-free / use-after-free |
| Файл (C-уровень) | FILE* | fclose() | два объекта попытаются закрыть один файл |
| Системный handle (концепт) | число/указатель-идентификатор | «close-функция» API | аналогично: два владельца одного handle |
Мы специально пока не лезем в более сложные ресурсы и архитектуры. Но даже этого достаточно: если внутри типа есть “ручка”, которую вы обязаны закрывать/освобождать вручную, значит, у типа есть явное владение.
И вот тут появляется развилка: либо вы реализуете глубокую копию (чтобы копия получала свой ресурс), либо вы запрещаете копирование и разрешаете только перемещение, либо вы делаете так, чтобы ресурс жил внутри 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 или запрет копирования
В реальном коде у вас почти всегда есть выбор. И хороший стиль modern 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) {}
};
Здесь не нужен ни деструктор, ни копирующий конструктор, ни move-операции: 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?» Если можно — делайте, и вы автоматически получаете корректные копии/перемещения без лишней боли.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ