1. Введение
Когда программист впервые видит new и delete, у него в голове возникает опасная мысль: «О, теперь я могу создавать объекты когда хочу и где хочу! Я — властелин памяти!» Это чувство быстро проходит, когда программа начинает падать не там, где ошибка, а «где-то в районе std::cout» (классика жанра). Причина проста: ручное управление памятью — это контракт, который очень легко нарушить. Сегодня разберём три самых популярных способа нарушить этот контракт.
Чтобы держать ориентир, зафиксируем «золотой инвариант» ручной памяти (то, что должно быть истинно всегда):
Каждый ресурс, полученный через `new` / `new[]`, должен быть освобождён ровно один раз
соответствующим `delete` / `delete[]` и только после того, как он больше не используется.
Если инвариант нарушили, получаем один из трёх героев этой лекции.
Карта местности: три ошибки и чем они отличаются
Перед тем как нырять в примеры, полезно на минуту сделать «табличку здравого смысла», чтобы не смешивать понятия. Эти баги часто путают, потому что итог похож: «всё сломалось». Но ломается оно по-разному.
| Баг | Что нарушили | Типичный симптом | Почему коварно |
|---|---|---|---|
|
ресурс не освободили | программа «толстеет» по памяти, иногда падает через долгое время | может не проявляться на маленьких тестах |
|
ресурс освободили дважды | аварийное завершение, падение аллокатора, «heap corruption» | ошибка часто далеко от места падения |
|
ресурс использовали после освобождения | «мусорные данные», редкие падения, странные значения | иногда «работает», пока память не переиспользовали |
Дальше мы будем идти именно в таком порядке: сначала утечки (самые «тихие»), потом double-free (самые громкие), потом use-after-free (самые мистические).
2. Leak: память утекла, потому что вы её «потеряли»
Утечка памяти — это ситуация, когда вы выделили память, но не освободили. Важно: память не обязана утечь «сразу навсегда» в смысле ОС — когда процесс завершится, ОС всё заберёт. Но пока программа работает, утечка означает рост потребления памяти и, иногда, деградацию скорости из-за аллокаций и фрагментации.
Самый неприятный момент в том, что утечка часто выглядит «невинно»: программа выводит правильные числа, тесты проходят, а проблема проявляется только в долгом запуске или под нагрузкой. То есть именно там, где вы меньше всего хотите сюрпризы.
Самый простой leak: забыли delete
Начнём с «учебного» примера. Он банален, но именно так мозг новичка привыкает: выделили — забыли.
#include <iostream>
int main() {
int* p = new int{10};
std::cout << *p << '\n'; // 10
// delete p; // забыли => leak
}
Проблема здесь не в том, что программа обязательно упадёт. Проблема в том, что вы нарушили контракт: ресурс живёт дольше, чем нужно, и вы его уже не контролируете.
Leak через ранний return: «вышел из функции и забыл закрыть дверь»
Это уже ближе к реальной жизни: логика ветвится, и на одной из веток вы выходите из функции, не освободив ресурс.
#include <string>
int* parse_positive_or_null(const std::string& s) {
int* p = new int{0};
if (s.empty()) {
return nullptr; // leak: p не освобождён
}
*p = 42; // допустим, распарсили
return p;
}
Здесь важно почувствовать: проблема не в return как таком. Проблема в том, что delete оказался «размазан» по веткам (а на одной ветке его вообще нет). Чем больше веток, тем выше шанс ошибиться. И это одна из причин, почему ручная память плохо масштабируется по сложности кода.
Leak через перезапись указателя: «потеряли последний адрес»
Очень «популярная» ошибка: вы выделили память, потом присвоили указателю новый адрес — и всё, первый адрес потерян, освободить его уже нечем (у вас больше нет ссылки на него).
int main() {
int* p = new int{1};
p = new int{2}; // leak: адрес на {1} потерян
delete p; // освобождаем только {2}
p = nullptr;
}
Это особенно часто происходит в коде, который «переиспользует переменную», думая, что так экономнее. В итоге экономится только количество строк, а не память и не нервы.
Leak в цикле: «по чуть-чуть, но каждый раз»
Утечки становятся действительно опасными, когда попадают в повторяющийся участок кода. Тогда вы получаете эффект «капает-кaпает — и потоп».
#include <iostream>
int main() {
for (int i = 0; i < 3; ++i) {
int* p = new int{i};
std::cout << *p << ' '; // 0 1 2
// delete p; // забыли => leak на каждой итерации
}
std::cout << '\n';
}
На трёх итерациях никто не заметит. На трёх миллионах — заметит даже ваш ноутбук, который начнёт «подозрительно шуметь вентиляторами» и изображать взлёт.
4. Double-free: ресурс освобождён дважды (и это уже UB)
double-free звучит как «ну я два раза убрался в комнате, что плохого?». В памяти это работает наоборот: второй delete пытается освободить ресурс, который уже освобождён. А дальше начинается Undefined Behavior: программа может упасть сразу, может упасть позже, может «просто» повредить внутренние структуры аллокатора и устроить падение вообще в другом месте.
Самая частая причина double-free у новичков — непонимание разницы между «копированием указателя» и «копированием объекта». Указатель — это всего лишь адрес. Скопировали адрес — получили два имени для одного и того же ресурса. Если оба имени считают себя владельцами, они оба попытаются сделать delete.
Два указателя на один new: «два владельца одного холодильника»
int main() {
int* p = new int{5};
int* q = p; // q и p указывают на один и тот же объект
delete p;
// delete q; // UB: double-free (не выполняем)
q = nullptr;
}
Ключевая мысль: копирование T* копирует адрес, а не делает второй объект.
Double-free из-за «удобной» функции, которая вдруг решила удалить
Иногда код ломается из-за плохого контракта функций. Допустим, вы передали указатель в функцию «поработать», а она решила, что раз указатель пришёл — значит она должна его удалить. Вы же, в свою очередь, считали, что удаляете вы. Итог — два delete.
#include <iostream>
void print_and_delete(int* p) {
std::cout << *p << '\n'; // допустим, печатаем
delete p; // и вдруг удаляем
}
int main() {
int* p = new int{7};
print_and_delete(p);
// delete p; // UB: double-free (не выполняем)
p = nullptr;
}
Этот пример важен не тем, что «так делать нельзя» (хотя да, нельзя), а тем, что показывает: владение должно быть однозначным. По типу int* непонятно, кто владелец. И это одна из фундаментальных проблем «сырого» API.
Double-free из-за «освободили и забыли обнулить»: повторный delete по старому адресу
Иногда второй delete происходит не потому, что кто-то копировал указатель, а потому что код «переиспользовал переменную» и забыл, что там уже был delete.
int main() {
int* p = new int{1};
delete p;
// ... много кода ...
// delete p; // UB: второй delete того же адреса (не выполняем)
p = nullptr;
}
Да, обнуление p = nullptr; не лечит архитектуру владения. Но оно часто помогает превратить «тихий UB» в более явную ошибку: если позже кто-то сделает delete p, то delete nullptr безопасен и ничего не сломает.
5. Use-after-free: объект уже умер, а вы всё ещё с ним разговариваете
Use-after-free — это когда вы освободили память, но продолжили использовать указатель, который на неё указывает. Технически после delete указатель становится dangling (висячим): он всё ещё хранит адрес, но по этому адресу больше нет «живого» объекта с корректным временем жизни.
Коварство тут в том, что часто кажется: «Ну адрес-то тот же. Значит, данные там же». Но язык C++ не обещает ничего: после delete этот участок памяти может быть немедленно отдан под другие нужды, а может «лежать» какое-то время. Поэтому use-after-free иногда «работает» (что хуже, чем падать сразу).
Прямой use-after-free: разыменовали после delete
#include <iostream>
int main() {
int* p = new int{99};
delete p;
// std::cout << *p << '\n'; // UB: use-after-free (не выполняем)
p = nullptr;
}
Если вам повезёт, программа упадёт. Если не повезёт, вы получите какое-то число и решите, что всё нормально. А потом это «нормально» станет багом на проде.
Use-after-free через «сохранённый адрес»: указатель ушёл гулять
Более жизненная версия: вы сохранили адрес куда-то ещё, потом освободили, а потом использовали «сохранённую копию».
#include <iostream>
int main() {
int* p = new int{10};
int* alias = p; // второй "ярлык" на тот же адрес
delete p;
// std::cout << *alias << '\n'; // UB: use-after-free (не выполняем)
alias = nullptr;
}
Обратите внимание на психологический момент: вы могли честно обнулить p = nullptr;, но alias всё равно останется dangling. Поэтому «обнулить указатель» — это не универсальный щит, если адрес копировался.
Use-after-free, который выглядит как «рандом»: память переиспользовали под новый new
Очень поучительный эффект: вы удалили объект, а потом где-то сделали ещё один new. Аллокатор может вернуть тот же самый адрес, и тогда ваш старый dangling-указатель внезапно «указывает» на новый объект. Вы читаете через него и получаете данные от другого объекта. Это уже почти сюжет для детектива.
#include <iostream>
int main() {
int* p = new int{1};
delete p;
int* q = new int{777}; // иногда может получить тот же адрес, что и p
// std::cout << *p << '\n'; // UB: use-after-free (не выполняем)
delete q;
}
Почему это страшно: если бы программа падала, вы бы быстро нашли ошибку. А когда она «работает, но неправильно», вы ловите фантомные баги.
Почему эти баги проявляются странно: короткая модель аллокатора
Очень хочется верить, что ошибка должна ломать программу сразу и честно. Но реальность такая: менеджер памяти (аллокатор) — это сложная система, которая переиспользует освобождённые блоки, ведёт списки свободных участков, объединяет и делит блоки. Поэтому delete — это не «стереть память нулями», а «вернуть блок в хозяйство».
Полезно держать в голове простую схему жизненного цикла:
flowchart LR
A[new выделил блок] --> B[объект живёт]
B --> C[delete завершил время жизни]
C --> D[блок может быть переиспользован]
D --> E[новый new может вернуть тот же адрес]
Из этой схемы следуют два неприятных эффекта. Во-первых, use-after-free иногда даёт «правдоподобные» данные, потому что блок ещё не переиспользован. Во-вторых, double-free может не упасть на втором delete сразу, а «испортить внутренности» и упасть позже при совершенно другом new или delete.
6. Мини‑кейс: TaskBoard и legacy‑заметки на char*
Чтобы не изучать ошибки в вакууме, представим, что у нас есть простое консольное приложение TaskBoard (условное название), где задачи живут в std::vector, а названия — в std::string. Всё хорошо… пока кто-то не решил добавить к задаче «заметку в стиле C», потому что «так быстрее» или «хочу потренировать new[]».
Мы сделаем маленькую функцию, которая выделяет C-строку под заметку и возвращает char*. Это не рекомендация, это учебный пример для демонстрации рисков.
Выделяем память под C-строку
#include <cstring>
#include <string>
char* make_note_cstr(const std::string& note) {
char* p = new char[note.size() + 1];
std::memcpy(p, note.c_str(), note.size() + 1); // включая '\0'
return p; // ВНИМАНИЕ: кто будет delete[]?
}
И вот здесь начинается классическая проблема дизайна: по возвращаемому типу char* вообще не видно, кто владелец и когда освобождать. А значит, все три наших бага становятся «естественными» сценариями.
Leak: получили заметку и забыли освободить
#include <iostream>
#include <string>
int main() {
char* note = make_note_cstr("Call mom");
std::cout << note << '\n'; // Call mom
// delete[] note; // забыли => leak
}
Один забытый delete[] в одном месте кажется мелочью. Но если заметки создаются часто (например, при каждом обновлении задачи), утечка становится системной.
Double-free: два модуля решили, что они «владельцы заметки»
#include <string>
void store_note_somewhere(char* note) {
// ... сохранили ...
delete[] note; // "мы подумали, что это наша ответственность"
}
int main() {
char* note = make_note_cstr("Buy milk");
store_note_somewhere(note);
// delete[] note; // UB: double-free (не выполняем)
}
Эта ситуация особенно часто возникает в командной разработке, когда один человек пишет функцию, другой — вызывает, и контракт владения нигде не прописан (или прописан в комментарии, который никто не читает — тоже реалистично).
Use-after-free: заметку удалили, но указатель остался в задаче
Представим, что где-то в задаче хранится char* note;, и при обновлении заметки вы делаете delete[] note, но забываете обновить поле или забываете, что на него ещё кто-то ссылается.
#include <iostream>
int main() {
char* note = make_note_cstr("Old note");
delete[] note;
// std::cout << note << '\n'; // UB: use-after-free (не выполняем)
note = nullptr;
}
Здесь снова мораль та же: если вы вручную управляете памятью, вы обязаны вручную обеспечивать корректность времени жизни во всех местах, куда «утёк» адрес.
7. Типичные ошибки
Ошибка №1: “Давайте просто вернём T* из функции, а там разберутся”.
Возврат сырого указателя на динамический объект почти всегда создаёт дыру в контракте владения. Код компилируется, работает на маленьких примерах, а потом внезапно появляется leak (никто не удалил) или double-free (удалили дважды), потому что ответственность не закреплена ни за кем однозначно.
Ошибка №2: копирование указателя воспринимается как копирование объекта.
Это, пожалуй, самая частая причина double-free и use-after-free у новичков. int* q = p; не делает второй int, он делает второй ярлык на тот же адрес. Если вы не проговариваете вслух “теперь у нас два имени для одной памяти”, мозг автоматически дорисовывает несуществующую «копию объекта».
Ошибка №3: delete “размазан” по веткам и ранним выходам.
Как только в функции появляется несколько return и несколько веток if, а delete стоит “где-то там”, вероятность утечки становится почти статистической неизбежностью. Обычно это видно по коду: вы листаете функцию и думаете “так, а тут мы точно освободили?”. Если приходится думать — значит уже опасно.
Ошибка №4: после delete продолжают обращаться к указателю или передают его дальше.
Use-after-free часто рождается не из злого умысла, а из забывчивости: объект удалили, но адрес остался в переменной, поле структуры не обновили, или “временный алиас” сохранился где-то ещё. После delete указатель следует считать невалидным. Обнуление p = nullptr; помогает, но не лечит копии адреса.
Ошибка №5: несогласованность new[] и delete (и наоборот) воспринимается как “ну почти то же самое”.
Хотя эту тему вы уже видели, она напрямую связана с сегодняшними авариями: неверная форма освобождения — это тоже UB и часто проявляется как “сломанный heap”, то есть симптомы, похожие на double-free/use-after-free. Если выделяли массив — освобождайте delete[], без компромиссов и самообмана.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ