Работа с памятью в C++: как делать правильно
Ручной new/delete — источник утечек и двойных освобождений. Разбираем, как владение памятью выражать через типы, а не комментарии, и когда какой умный указатель уместен.

Строчка delete ptr;, написанная руками, — это заявление: я лично отвечаю за то, что сюда никто не придёт повторно, что до этой точки не случилось исключения и что указатель не скопировали в три других места. В C++11 и новее это заявление почти всегда лишнее. Владение памятью выражается типом: unique_ptr, shared_ptr, контейнер, значение на стеке. Разберём, какой инструмент под какую задачу и чем платишь за неверный выбор. Ориентируемся на C++17 и новее; часть приёмов доступна с C++11, отдельные упрощения — с C++14.
Почему ручной new/delete проигрывает
Проблема не в том, что new медленный. Проблема в том, что между new и парным delete может произойти всё что угодно: ранний return, брошенное исключение, второй владелец, скопировавший сырой указатель. Каждая из этих ситуаций даёт либо утечку, либо двойное освобождение — и то и другое компилятор не поймает.
// Так выглядит утечка, которую легко не заметить
Widget* createWidget(int mode) {
Widget* w = new Widget();
if (mode == 0) {
return nullptr; // w потерян: delete не вызван
}
w->configure(mode); // если configure бросит исключение — тоже утечка
return w;
}
Тот же код через unique_ptr закрывает обе дыры: деструктор умного указателя вызовется при любом выходе из функции, включая исключение.
#include <memory>
std::unique_ptr<Widget> createWidget(int mode) {
auto w = std::make_unique<Widget>(); // C++14
if (mode == 0) {
return nullptr; // память освободится автоматически
}
w->configure(mode); // исключение здесь тоже безопасно
return w; // владение уходит наружу через move
}
Правило по умолчанию: значение на стеке
Прежде чем тянуться за указателем, спросите: а нужна ли вообще куча? Объект, время жизни которого совпадает с областью видимости, живёт на стеке без единого new.
void process() {
std::vector<int> data(1000); // это тоже динамическая память,
// но vector сам её выделяет и освобождает — вручную ничего не делаем
std::string name = "report";
// при выходе из process оба объекта уничтожатся автоматически
}
Динамическое выделение оправдано, когда время жизни объекта не совпадает с блоком кода, когда размер известен только в рантайме и не влезает на стек, или когда нужен полиморфизм через базовый указатель. В остальных случаях стек быстрее и безопаснее.
unique_ptr: владелец ровно один
Это выбор по умолчанию для динамического объекта. Накладных расходов по памяти и скорости у него практически нет: unique_ptr без кастомного делитера занимает столько же, сколько сырой указатель. Копировать его нельзя — можно только передать владение через std::move.
auto config = std::make_unique<Config>();
// Передаём владение в другую функцию
std::unique_ptr<Config> owner = std::move(config);
// теперь config == nullptr, обращаться к нему нельзя
// Если функции нужен доступ, но НЕ владение — передаём сырой указатель или ссылку
void applyConfig(const Config& c); // ссылка честно говорит: я не владею
applyConfig(*owner);
Ключевая мысль: сырой указатель в современном C++ не запрещён. Он допустим как наблюдатель — когда нужно посмотреть на объект, но не управлять его временем жизни.
Сырой указатель теперь означает «я смотрю, но не владею». Владение всегда выражается умным указателем или значением. Если в сигнатуре виден T*, читатель кода вправе считать, что удалять этот объект — не его забота.
Массивы и кастомное освобождение
Для динамического массива есть специализация unique_ptr<T[]>, но в реальном коде почти всегда лучше std::vector. Кастомный делитер нужен, когда ресурс освобождается не через delete — например, C-библиотека с парой open/close.
// Обёртка над FILE* из C-библиотеки
auto closer = [](std::FILE* f){ if (f) std::fclose(f); };
std::unique_ptr<std::FILE, decltype(closer)> file(
std::fopen("data.txt", "r"), closer);
// fclose вызовется автоматически при выходе из области видимости
shared_ptr: владельцев несколько
shared_ptr ведёт счётчик ссылок: объект живёт, пока на него ссылается хотя бы один shared_ptr. Это удобно, но не бесплатно.
- Размер самого
shared_ptr— два указателя (на объект и на управляющий блок со счётчиками). - Счётчик атомарный: инкремент и декремент при копировании синхронизируются между потоками, а это дороже обычного присваивания.
make_sharedвыделяет объект и управляющий блок одним куском памяти — обычно быстрее, чемshared_ptr(new T), но держит память объекта, пока живыweak_ptr.
auto session = std::make_shared<Session>();
auto copy = session; // счётчик стал 2, оба владеют
// объект уничтожится, когда счётчик дойдёт до 0
Не берите shared_ptr по привычке. Разделяемое владение — это архитектурное решение, а не деталь. Если владелец на самом деле один, а shared_ptr взяли «на всякий случай», вы платите за атомарные операции и усложняете рассуждения о времени жизни.
Циклические ссылки и weak_ptr
Главная ловушка shared_ptr — цикл. Если два объекта держат shared_ptr друг на друга, их счётчики никогда не дойдут до нуля, и оба утекут даже при выходе из всех областей видимости.
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // назад ссылаемся weak_ptr — цикл разорван
};
// Чтобы воспользоваться weak_ptr, его нужно повысить до shared_ptr:
if (auto locked = node.prev.lock()) {
locked->doSomething(); // объект ещё жив, работаем
} // иначе prev уже уничтожен, lock() вернул nullptr
Что выбрать под задачу
| Ситуация | Инструмент | Цена |
|---|---|---|
| Время жизни = область видимости | значение на стеке | нет накладных расходов |
| Один владелец, куча | unique_ptr | практически как сырой указатель |
| Несколько владельцев | shared_ptr | атомарный счётчик, двойной размер |
| Наблюдатель без владения | ссылка или сырой T* | обязанность следить за временем жизни на вызывающем |
| Слабая ссылка на shared-объект | weak_ptr | перед доступом нужен lock() |
| Динамический набор однотипных | std::vector | управление памятью на контейнере |
Правило нуля и правило пяти
Если ваш класс держит ресурсы через умные указатели и контейнеры, он обычно не нуждается в собственном деструкторе, копирующих и перемещающих операциях — компилятор сгенерирует правильные. Это правило нуля: лучший ручной код управления ресурсами — тот, который вы не написали.
Как только вы объявили хотя бы одну из пяти специальных функций (деструктор, копирующий конструктор, копирующий оператор присваивания, перемещающий конструктор, перемещающий оператор присваивания), продумайте остальные четыре — это правило пяти. Явный деструктор с ручным delete — почти всегда сигнал, что вы делаете работу, которую сделал бы unique_ptr.
Типичные ошибки
- Двойное управление. Создать объект через
new, а потом обернуть один и тот же сырой указатель в два независимыхshared_ptr— получите двойное освобождение. Всегда идите черезmake_shared/make_unique. - shared_ptr там, где хватило бы unique_ptr. Лишнее разделяемое владение маскирует настоящую структуру времени жизни и мешает понять, кто за что отвечает.
- Возврат сырого указателя на локальный объект. Указатель на переменную, лежащую на стеке функции, становится висячим сразу после возврата.
- Забытый
weak_ptrв графах и наблюдателях. Любая структура с обратными ссылками — кандидат на цикл.
Проверять на утечки и обращения к освобождённой памяти удобно санитайзерами. AddressSanitizer и LeakSanitizer подключаются флагом компилятора -fsanitize=address в GCC и Clang и ловят большинство ошибок работы с памятью в тестах.
Что делать дальше
Проверьте свой код по одному критерию: сколько в нём осталось ручных new и delete. Каждый такой участок — кандидат на замену умным указателем или контейнером. Прогоните тесты под AddressSanitizer, чтобы увидеть скрытые утечки. Если пишете на C++20 или новее — посмотрите в сторону std::span для передачи невладеющих представлений над массивами: это ещё один способ убрать сырые указатели из сигнатур.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: C++ Core Guidelines: Resource management, cppreference: std::unique_ptr, std::shared_ptr
Частые вопросы
У меня старый проект на C++03, умных указателей нет. Что делать?
make_unique появился в C++11 или C++14?
Всегда ли make_shared лучше, чем shared_ptr(new T)?
Влияет ли shared_ptr на производительность заметно?
Санитайзер ничего не нашёл — значит, утечек точно нет?
Можно ли смешивать сырые указатели и умные в одном проекте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.