1. Зачем нужна динамическая память и что делает new
Когда вы только начинаете программировать, кажется, что памяти бесконечно много и всё живёт “просто потому что объявлено”. Но это ощущение примерно как вера в то, что холодильник сам наполняется ночью (увы). В реальности у нас есть разные области памяти: локальные переменные живут в своём времени жизни, контейнеры управляют ресурсами, а иногда программе нужно создать объект так, чтобы он пережил текущий блок {} — и тут появляется динамическая память.
new — это не «магия», а конкретные шаги
Важно воспринимать new как выражение, которое делает несколько действий подряд. Грубо говоря, выражение new T{...} означает: «найди место в динамической памяти, создай там объект T, верни адрес этого объекта».
Можно представить это так:
flowchart LR
A["Выражение new T{...}"] --> B[Выделение памяти в heap]
B --> C[Конструирование объекта T]
C --> D[Возврат адреса: T*]
То есть результат new — это указатель. Не объект. Адрес.
Мини-пример:
#include <iostream>
int main() {
int* p = new int{42};
std::cout << *p << '\n'; // 42
delete p;
p = nullptr;
}
Здесь мы создали int в динамической памяти, вывели значение, и обязаны его удалить.
2. new T vs new T{}: инициализация и корректность
В этом разделе важно притормозить и поговорить не про освобождение памяти, а про инициализацию. Новички часто попадают в ловушку: «Я же сделал new int, значит там 0». Нет. Это как заказать “кофе” и ожидать “капучино с корицей” — технически вы не уточнили.
new int может дать мусор
Если вы пишете new int, то для фундаментальных типов (вроде int, double) вы часто получаете неинициализированное значение. Читать его — плохая идея.
int main() {
int* a = new int; // значение НЕ инициализировано
int* b = new int{}; // значение инициализировано в 0
int x = *b; // ok, x == 0
// int y = *a; // нельзя: неинициализированное значение
delete a;
delete b;
}
new int{} (или new int()) просит значение по умолчанию, а для int это 0.
Для пользовательских типов обычно всё «лучше»
Если T — это struct/class с конструктором, то new T обычно всё равно вызовет конструктор. Но общая дисциплина полезна: если хотите «нормальное значение по умолчанию» — используйте {}.
3. Что делает delete на самом деле
После того как мы научились выделять память, нужно понять вторую половину договора. Если new — это «создай объект», то delete — это «закрой объект и верни память».
Два шага delete
delete p; делает два важных действия.
Сначала он завершает время жизни объекта по адресу p (то есть вызывает деструктор, если он есть), а затем освобождает память, которую ранее выдали под этот объект.
Это очень важно: delete — не просто «стереть байтики», он про корректное завершение жизни объекта.
Проверочный пример с деструктором:
#include <iostream>
struct Trace {
~Trace() { std::cout << "~Trace\n"; } // ~Trace
};
int main() {
Trace* p = new Trace{};
delete p; // ~Trace
}
Вывод здесь будет:
// ~Trace
Деструктор отработал — значит объект «закрылся» корректно.
delete nullptr; — безопасно
Отдельная хорошая новость: delete nullptr; — допустимая операция. Это не «ошибка», это «ничего не делаем».
int main() {
int* p = nullptr;
delete p; // ок: ничего не происходит
}
Это удобно, потому что код можно писать проще: указатель по умолчанию nullptr, а деструктор/очистка могут делать delete без лишних if.
4. Массивы: new[] и delete[]
Сейчас будет момент, который ломает мозг ровно один раз, а потом становится привычным: массивы удаляются не так, как одиночные объекты.
new T[n] возвращает T*, но это не значит, что удалять можно как угодно
Когда вы пишете:
int* arr = new int[3]{1, 2, 3};
вы получаете int*. Но это указатель на первый элемент массива, а не «на объект int». И освобождение должно знать, что это массив, чтобы корректно уничтожить его элементы и освободить память в правильной форме.
Пример:
#include <iostream>
int main() {
int* arr = new int[3]{1, 2, 3};
std::cout << arr[0] << ' ' << arr[1] << ' ' << arr[2] << '\n'; // 1 2 3
delete[] arr;
arr = nullptr;
}
Инициализация массива: new int[n] и new int[n]{} отличаются
Точно так же, как и с одиночным int, массив фундаментальных типов может оказаться частично или полностью неинициализированным, если вы не попросили инициализацию.
int main() {
int* a = new int[3]; // элементы НЕ инициализированы
int* b = new int[3]{}; // элементы инициализированы в 0
// читать a[0] нельзя, а b[0] == 0 — можно
delete[] a;
delete[] b;
}
5. Правило парности: new↔delete, new[]↔delete[]
Это центральное правило лекции. Его стоит не просто запомнить, а понимать как «контракт формата».
Таблица соответствий
| Выделили | Получили | Освобождаем |
|---|---|---|
|
|
|
|
|
|
Можно сформулировать ещё проще: квадратные скобки должны совпасть. Есть [] при выделении — должны быть [] при удалении.
Почему несоответствие — UB
Здесь важно честно: многие думают, что “ну delete и delete[] почти одно и то же”. Нет. Если вы перепутаете формы — стандарт говорит, что поведение не определено (UB).
Почему? Потому что при удалении массива система должна понимать, сколько элементов уничтожать (и как), а внутреннее представление выделения массива может отличаться от одиночного объекта. Компилятор и рантайм могут хранить служебную информацию рядом с массивом, могут вызывать цепочку деструкторов, могут использовать другой механизм освобождения — и если вы сказали «это одиночный объект», когда там массив, вы нарушили контракт.
В стандарте C++ даже выделяют отдельные термины для удаления одного объекта и удаления массива: “single-object delete expression” и “array delete expression”.
6. Что бывает при несоответствии new[] и delete
Сейчас будет самое неприятное место: «а как именно ломается?». Ответ: по-разному, и это главный признак UB.
Демонстрация на типе с деструктором
Мы можем увидеть разницу хотя бы в том, что при delete[] для массива объектов должны вызываться деструкторы для каждого элемента.
#include <iostream>
struct Item {
int id{};
~Item() { std::cout << "~Item " << id << '\n'; }
};
int main() {
Item* a = new Item[3]{{1}, {2}, {3}};
delete[] a;
}
Ожидаемый вывод (порядок может быть обратным):
// ~Item 3
// ~Item 2
// ~Item 1
А теперь “плохая” строка (мы её НЕ выполняем):
// delete a; // UB: массив удаляется как одиночный объект
Если бы мы это сделали, могли бы получить ситуацию, когда деструктор вызвался бы не для всех элементов, или вызвался бы некорректно, или память освободилась бы «не тем способом».
new + delete[] тоже UB
И наоборот, тоже нельзя:
int main() {
int* p = new int{7};
// delete[] p; // UB: одиночный объект удаляется как массив
delete p;
}
7. Практические нюансы
После delete указатель не становится nullptr
Отдельный важный психологический момент: delete не обязан превращать указатель в nullptr. Он просто освобождает ресурс. Адрес может остаться прежним, но он больше не указывает на живой объект. Это называется dangling pointer (висячий указатель).
Поэтому в учебном коде часто делают так:
int main() {
int* p = new int{10};
delete p;
p = nullptr; // дисциплина: “обнулили после освобождения”
}
Это не “магическое лечение” всех проблем, но это снижает шанс случайно разыменовать уже освобождённый адрес.
Мини-встраивание: режим memory-demo
Чтобы тема не осталась «теорией про страшный heap», добавим маленький диагностический режим в наше консольное учебное приложение (условно назовём его TaskBook). Идея проста: у нас есть меню команд, и одна команда запускает демонстрацию корректной парности new/delete и new[]/delete[].
Пусть у нас уже есть каркас: читаем команду строкой, сравниваем, выполняем действие. Добавим функцию runMemoryDemo().
#include <iostream>
#include <string>
void runMemoryDemo() {
int* p = new int{42};
std::cout << "p=" << *p << '\n'; // p=42
delete p;
int* a = new int[3]{1, 2, 3};
std::cout << "a[1]=" << a[1] << '\n'; // a[1]=2
delete[] a;
}
int main() {
std::string cmd;
std::cin >> cmd;
if (cmd == "memory-demo") {
runMemoryDemo();
}
}
Обратите внимание, как здесь всё «симметрично»: одиночный объект — delete, массив — delete[]. Да, это микропример. Но именно такая «скучная симметрия» потом спасает часы отладки.
8. Типичные ошибки при работе с new/delete и new[]/delete[]
Ошибка №1: перепутать delete и delete[].
Это самая дорогая ошибка в пересчёте на седые волосы. Проблема в том, что компилятор часто не может вас спасти: тип указателя и там, и там будет T*. Поэтому приходится дисциплинировать себя: если выделяли с [], то и удаляем с [].
Ошибка №2: считать, что new int даёт ноль.
new int для фундаментальных типов может не инициализировать значение. Если вы планируете читать значение — используйте new int{} или сразу задавайте конкретное значение new int{42}. Иначе вы берёте из памяти «что лежало на полу».
Ошибка №3: забыть, что delete не обнуляет указатель.
После delete адрес в переменной остаётся прежним, и визуально кажется, что “всё ещё указывает”. Из-за этого легко случайно разыменовать мёртвый адрес позже. Простая учебная дисциплина p = nullptr; после освобождения делает такие ошибки заметнее.
Ошибка №4: попытаться удалить то, что не выделяли через new.
delete применяют только к адресу, полученному из соответствующего new (или new[]). Нельзя делать delete от адреса локальной переменной, от элемента std::vector, от памяти, полученной “непонятно откуда”. Это почти гарантированно UB, даже если “пока работает”.
Ошибка №5: писать ручное управление памятью там, где оно вообще не нужно.
Очень частая новичковая ловушка: “Раз я теперь знаю new, буду всё делать через new”. На практике это почти всегда ухудшает код: появляется больше ветвлений, больше договорённостей «кто удаляет», больше риск несоответствия форм и утечек. Сегодня мы изучаем new/delete не потому, что это «лучший стиль», а потому что это фундамент языка, который нужно понимать, чтобы не попадать в ловушки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ