1. delete в прикладном коде — символ проблемы
Если вы новичок, delete часто кажется чем-то вроде «взрослой магии»: мол, вот сейчас я вручную управляю памятью, значит я настоящий программист на C++. На практике всё наоборот: чем чаще в прикладном коде мелькает delete, тем выше шанс, что программа будет падать «в другой части», «иногда», «после пятого запуска», «только на компе преподавателя». И это не потому, что C++ вредный, а потому что ручное управление ресурсом требует железной дисциплины и очень чётких контрактов владения.
Важно уточнить термин «прикладной код». Это ваш код бизнес-логики: обработка команд, хранение задач, расчёт статистики, форматирование вывода, работа с моделями данных. Это не низкоуровневый кусок стандартной библиотеки и не отдельная «системная» подсистема, которая специально занимается ресурсами. В прикладном коде вы хотите думать о задачах, а не о том, кто должен был вызвать delete и не забыл ли он это сделать.
Интересный факт на полях: требования к объявлениям operator delete менялись между стандартами, и в современном C++ он должен быть noexcept. Это лишь подчёркивает мысль: даже «внутренности языка» живут по строгим контрактам — в прикладном коде такие контракты руками поддерживать тяжело и рискованно.
Правило дня: если вы пишете delete, остановитесь
Это правило звучит чуть пафосно, но оно очень практичное: когда рука тянется написать delete, полезно сделать микро-паузу и мысленно задать два вопроса.
Первый: «Почему у меня вообще есть голый T*, который нужно освобождать вручную?»
Второй: «Почему освобождение ресурса не находится в одном единственном месте, которое гарантированно сработает?»
Проблема delete редко в том, что вы «не помните синтаксис». Проблема в том, что delete — это операция завершения времени жизни и освобождения памяти, и она должна произойти строго один раз. Если вы делаете это «по дороге» в бизнес-логике, вы превращаете бизнес-логику в мини-диспетчера памяти, который должен помнить слишком много.
Кстати, стандарт различает «удаление одного объекта» и «удаление массива» как разные формы выражений удаления. И вот представьте: вы пишете программу «менеджер задач», а вместе с этим обязаны держать в голове, какая именно форма удаления сейчас должна сработать. Это лишняя нагрузка на мозг, которая не делает программу полезнее.
2. Стратегии: как жить без delete
Не выделяйте память вручную: std::vector вместо new[]
Очень типичная история новичка выглядит так: «Мне нужен список задач, но размер заранее неизвестен — значит, надо new». Это логика понятная, но в современном C++ она обычно неверная по выбору инструмента. Если вам нужен «массив переменного размера», то почти всегда это std::vector, а не new[].
Давайте продолжим наше учебное консольное приложение — пусть это будет TaskBook: простая программа, которая хранит задачи и печатает их. Раньше мы бы спокойно держали задачи в std::vector<Task>, и это уже правильный modern C++ путь. Но специально смоделируем «плохой поворот»: кто-то решил хранить задачи в виде указателей, потому что «так гибче».
Плохой вариант: std::vector<Task*> и ручной delete
Сразу важное замечание: пример ниже — иллюстрация дизайна, а не то, что мы хотим повторять.
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
int main() {
std::vector<Task*> tasks;
tasks.push_back(new Task{1, "Почитать про RAII"});
// ... а потом нужно где-то всё удалить ...
}
Проблема даже не в том, что здесь нет delete (пока). Проблема в том, что вы обязаны его где-то написать, и обязаны сделать это правильно: удалить каждый элемент ровно один раз, не забыть ни одного, не удалить чужое, не удалить дважды.
Хороший вариант: std::vector<Task> — владение по значению
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
int main() {
std::vector<Task> tasks;
tasks.push_back(Task{1, "Почитать про RAII"});
}
Здесь нет ни new, ни delete. И это не «упрощение для новичков», а именно стиль современного C++: контейнер владеет памятью, сам расширяется, сам освобождает всё в деструкторе.
Чтобы мысль была ещё более приземлённой, полезно держать маленькую таблицу «что я хотел» → «что в modern C++ обычно беру»:
| Что вам нужно в задаче | Новичковая идея | Modern C++ решение |
|---|---|---|
| Массив переменной длины | |
|
| Строка переменной длины | |
|
| «Может быть значение, а может нет» | -1, nullptr, «магия» | |
| Временный буфер символов | char* + delete[] | std::vector<char> или std::string |
Сделайте владение очевидным: «сырые» указатели не должны владеть
Одна из главных причин, почему delete так опасен в прикладном коде — по типу T* обычно непонятно, кто владелец. Указатель — это всего лишь адрес. Он прекрасно подходит для «посмотри туда» или «может быть там что-то есть», но он ужасен как «я отвечаю за освобождение».
Нам полезно договориться о простом (и очень практичном) разделении смыслов.
Ссылка T& обычно означает: «объект точно существует, он не null, я просто работаю с ним».
Указатель T* обычно означает: «объект может отсутствовать (nullptr), я работаю с ним, но я не обязательно владелец».
И очень важно: по умолчанию параметр типа T* не должен означать «забери владение и удали».
Давайте посмотрим на пример из TaskBook: мы хотим найти задачу по id. Если хранение — по значению (std::vector<Task>), то можно вернуть указатель на элемент как «невладеющую ссылку», то есть «посмотри, вот она, если нашлась».
#include <vector>
#include <string>
struct Task { int id{}; std::string title; };
Task* find_task_by_id(std::vector<Task>& tasks, int id) {
for (auto& t : tasks) {
if (t.id == id) return &t;
}
return nullptr;
}
Здесь Task* — это не про владение, а про «может не найтись». И это нормально. Проблема начинается, когда в коде кто-то решит: «О, Task*, значит надо удалить». Нет! Этот указатель указывает на элемент внутри std::vector, и удалять его нельзя (и не нужно).
Чтобы закрепить контракт, можно сделать функцию печати, которая ничего не удаляет и просто уважает nullable-параметр:
#include <iostream>
void print_task(const Task* t) {
if (t == nullptr) {
std::cout << "Task not found\n";
return;
}
std::cout << t->id << ": " << t->title << '\n';
}
Обратите внимание на стиль: мы прямо показываем, что nullptr — допустимый сценарий. Это и есть «nullable-дизайн» без владения.
Возвращайте результаты по значению
Новички часто начинают писать функции, которые возвращают указатель, потому что «ну это же C++, значит так надо». На практике возвращать T* из функции почти всегда означает, что вы создаёте контракт «угадай, кто потом делает delete».
Сравним два подхода.
Плохой контракт: функция создаёт new и возвращает T*
#include <string>
struct Task { int id{}; std::string title; };
Task* create_task_bad(int id, const std::string& title) {
return new Task{id, title}; // по типу не видно, кто должен delete
}
Если вы видите такой код в прикладном проекте — это почти гарантия утечки или double-free в будущем. Просто потому, что через неделю вы забудете, кто кому что должен.
Хороший контракт: функция возвращает объект по значению
#include <string>
struct Task { int id{}; std::string title; };
Task create_task(int id, const std::string& title) {
return Task{id, title};
}
Теперь в коде использования всё становится спокойнее:
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.push_back(create_task(1, "Сделать чай"));
}
Никаких new, никаких delete. Владение живёт там, где живут данные: в tasks.
Прячьте delete в RAII-владельца и не выпускайте наружу
Бывают случаи, когда вы по учебным причинам или из-за особого формата задачи всё-таки вынуждены выделять что-то через new. Например, вы пишете учебный буфер, или вынуждены взаимодействовать с API, который отдаёт «сырой» ресурс. В рамках сегодняшнего дня мы не устраиваем каталог всех возможных готовых владельцев, а держим одну мысль: если уж delete появился, он должен быть не в бизнес-логике, а внутри владельца.
Сделаем маленькую RAII-обёртку для массива int, чтобы почувствовать принцип. Да, в реальной жизни вы бы чаще взяли std::vector<int>, но нам важно увидеть сам приём.
struct IntBuffer {
int* data{nullptr};
~IntBuffer() {
delete[] data;
}
};
Теперь в прикладном коде вы можете пользоваться буфером без явного delete[]:
#include <iostream>
int main() {
IntBuffer buf{new int[3]{10, 20, 30}};
std::cout << buf.data[1] << '\n'; // 20
}
Заметьте, как это меняет «психологию кода». Вы больше не думаете «где бы мне не забыть delete[]». Вы думаете «буфер живёт столько, сколько живёт объект buf». Это и есть RAII.
Тут есть тонкий момент, который мы пока аккуратно проговариваем словами, без углубления в будущие темы: такие владельцы нельзя бездумно копировать, иначе вы получите два владельца одного адреса и снова попадёте в double-free. Поэтому на текущем этапе важно простое правило: RAII-владельца держим локально и передаём по ссылке, не пытаясь его копировать «как число».
Держите одну точку ответственности: delete не должен жить в ветках
Даже если вы временно пишете код с ручной памятью (например, в учебных экспериментах), есть очень показательная диагностика: если у вас delete стоит в нескольких местах функции, особенно в разных ветках if и перед разными return, то код уже стал хрупким.
Сравним два стиля.
Хрупкий стиль: delete в каждой ветке
#include <iostream>
int main() {
int* p = new int{5};
bool ok = false;
if (!ok) {
delete p;
return 0;
}
std::cout << *p << '\n';
delete p;
}
Формально это можно написать корректно, но это упражнение на внимательность, а не на здравый смысл. Любая новая ветка — новый шанс забыть.
Спокойный стиль: освобождение делает владелец в деструкторе
#include <iostream>
struct IntOwner {
int* p{nullptr};
~IntOwner() { delete p; }
};
int main() {
IntOwner x{new int{5}};
bool ok = false;
if (!ok) return 0;
std::cout << *x.p << '\n';
}
Теперь ветки if могут множиться как кролики в тёплой теплице, а освобождение не размазывается: оно всё равно произойдёт один раз в одном месте — в деструкторе владельца.
Мини-схема решения без delete
Когда вы проектируете кусок кода и вдруг поймали себя на мысли «а где бы мне тут написать delete», полезно иметь короткую «блок-схему» в голове. Она не волшебная, но хорошо возвращает в modern C++ рельсы.
flowchart TD
A["Нужен ресурс / память переменного размера"] --> B{"Можно хранить по значению?"}
B -->|Да| C["Используем std::string / std::vector / struct"]
B -->|Нет| D{"Можно спрятать ресурс в RAII-владельца?"}
D -->|Да| E["Пишем маленький объект-владелец с деструктором"]
D -->|Нет| F["Пересматриваем дизайн: где граница ответственности?"]
C --> G["В прикладном коде нет delete"]
E --> G
Смысл здесь не в том, чтобы «запомнить mermaid». Смысл в дисциплине: первый вопрос всегда про значение и контейнеры, второй — про RAII и одну точку ответственности, и только в самом конце вы признаёте, что дизайн требует пересмотра.
4. Типичные ошибки
Ошибка №1: «Я использую new, потому что размер неизвестен».
Это одна из самых распространённых причин появления delete там, где ему не место. Неизвестный размер — это не повод идти в new[], это повод взять std::vector. Вектор как раз и существует, чтобы вы не писали код «выделил → скопировал → не забыл удалить старое». В прикладном коде динамический массив почти всегда должен быть вектором.
Ошибка №2: возвращать T* из функции как «результат», не объясняя владение.
Такой интерфейс заставляет вызывающего угадывать: «мне нужно это удалять или нет?». Если нужно — где написано, когда и чем? Если не нужно — почему тогда указатель? Гораздо безопаснее возвращать по значению (T, std::string, std::vector<T>) или использовать std::optional<T> там, где результат может отсутствовать.
Ошибка №3: хранить «владельческие» указатели в контейнере без чёткого правила очистки.
std::vector<T*> сам по себе не плохой тип, но он почти всегда плохая идея для новичка в прикладной задаче: вы обязаны написать очистку, обязаны помнить про неё при всех сценариях выхода и обязаны не допустить двойного освобождения. В учебных приложениях почти всегда можно заменить на std::vector<T> и вычеркнуть весь класс проблем.
Ошибка №4: считать, что «после delete указатель стал nullptr».
После delete p; переменная p остаётся хранить старый адрес (просто этот адрес больше не указывает на живой объект). Это dangling pointer. Обнулять p = nullptr; полезно как дисциплина, но в modern C++ правильнее не доводить до ситуации, где вы вручную обнуляете указатели по всему коду, а хранить ресурс внутри RAII-владельца или контейнера.
Ошибка №5: размазывать delete по веткам и «чинить» утечки добавлением ещё одного delete в новом месте.
Это типичный путь к double-free: сначала вы забыли удалить в одной ветке, потом «добавили удаление», но не заметили, что в другой ветке удаление уже было. Если вы ловите себя на таком ремонте, правильный шаг — остановиться и перенести освобождение в деструктор владельца, чтобы оно было в одной точке ответственности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ