Умные указатели в C++: рабочие приёмы владения
unique_ptr, shared_ptr и weak_ptr решают разные задачи, а не «то же самое, только безопаснее». Разбираем, где каждый уместен, что стоит подсчёт ссылок и как не поймать цикл, из-за которого память не освободится.

Утечка памяти в C++ редко выглядит как утечка. Чаще это shared_ptr, который держит объект на 200 МБ живым после того, как последний осмысленный владелец давно ушёл, — потому что в графе объектов остался цикл ссылок. Стандарт (C++11 и новее) даёт три инструмента владения: std::unique_ptr, std::shared_ptr и std::weak_ptr. Они не взаимозаменяемы. Разберём, какой выбирать под конкретную задачу и чем каждый платит.
Единоличное владение — unique_ptr по умолчанию
Правило простое: если не доказано, что владельцев несколько, владелец один. unique_ptr — это владение без накладных расходов. Он размером с обычный указатель (если делитер не хранит состояния), не считает ссылки, не берёт атомарных блокировок. Компилятор не даст его скопировать — только переместить.
#include <memory>
#include <vector>
struct Session {
int id;
explicit Session(int id) : id(id) {}
};
// Фабрика возвращает владение наружу через unique_ptr
std::unique_ptr<Session> make_session(int id) {
// make_unique — один вызов new, исключение-безопасно (C++14)
return std::make_unique<Session>(id);
}
void demo() {
auto session = make_session(42);
std::vector<std::unique_ptr<Session>> pool;
// копировать нельзя, только передать владение
pool.push_back(std::move(session));
// здесь session уже пустой (nullptr)
}
Ключевая деталь: возврат unique_ptr из функции — это способ явно сказать «владение переходит вызывающему». Приём для интерфейсов: если функция принимает unique_ptr<T> по значению, она забирает объект себе. Если принимает T* или const T& — только смотрит, не владеет.
unique_ptr с массивом и своим делитером
Для массивов есть специализация unique_ptr<T[]> — она вызовет delete[], а не delete. Для ресурсов не из кучи задаётся кастомный делитер, например для FILE*:
#include <cstdio>
#include <memory>
// Тип делитера — часть типа указателя
auto file_closer = [](std::FILE* f) {
if (f) std::fclose(f);
};
void write_log() {
std::unique_ptr<std::FILE, decltype(file_closer)>
log(std::fopen("app.log", "a"), file_closer);
if (!log) return;
std::fputs("started\n", log.get());
// fclose вызовется автоматически при выходе из области
}
Разделяемое владение — shared_ptr и его цена
shared_ptr нужен, когда время жизни объекта определяется не одним владельцем, а последним из нескольких: кэш, из которого объект может быть выкинут, пока на него ещё ссылается активный запрос; узел графа, на который указывают несколько соседей. Каждая копия увеличивает счётчик, каждое разрушение уменьшает, объект удаляется на нуле.
За это платят:
- Размер.
shared_ptr— это два указателя: на объект и на управляющий блок со счётчиками. Он вдвое больше сырого указателя. - Атомарность. Счётчик ссылок меняется атомарно, чтобы копирование из разных потоков было безопасным. Это дороже обычного инкремента, даже если многопоточность вам не нужна.
- Аллокации. Наивное создание — два выделения памяти: под объект и под управляющий блок.
Последнее лечится make_shared: он размещает объект и управляющий блок в одном блоке памяти — одна аллокация вместо двух, лучше локальность кэша.
#include <memory>
struct Config { int retries = 3; };
// Одна аллокация под объект + управляющий блок
auto cfg = std::make_shared<Config>();
// Против двух аллокаций при таком варианте:
// std::shared_ptr<Config> cfg(new Config());
Оговорка проmake_shared: объект и управляющий блок живут в одном куске памяти. Пока жив хотя бы одинweak_ptr, этот кусок не освобождается целиком — даже после удаления самого объекта. Для больших объектов с долгоживущими weak-ссылками это может держать память дольше, чем ожидаешь.
Цикл ссылок — где shared_ptr течёт
Самая частая настоящая утечка на shared_ptr — взаимные ссылки. Родитель держит shared_ptr на ребёнка, ребёнок держит shared_ptr на родителя. Счётчики обоих никогда не дойдут до нуля, даже когда снаружи на них уже никто не ссылается.
#include <memory>
#include <vector>
struct Node {
std::vector<std::shared_ptr<Node>> children;
// Обратная ссылка — weak, чтобы не замкнуть цикл
std::weak_ptr<Node> parent;
};
void build() {
auto root = std::make_shared<Node>();
auto child = std::make_shared<Node>();
root->children.push_back(child);
child->parent = root; // weak_ptr, счётчик root не растёт
// при выходе из build оба узла корректно удалятся
}
Правило владения в графах: связь «вниз» (владение) — shared_ptr, связь «вверх» или «вбок» (наблюдение) — weak_ptr. Прежде чем работать с объектом по weak_ptr, его нужно повысить до shared_ptr через lock() и проверить, что объект ещё жив:
if (auto p = node->parent.lock()) {
// p — валидный shared_ptr, объект гарантированно жив
// внутри этого блока
} else {
// родитель уже удалён
}
Как выбрать
| Указатель | Владение | Стоимость | Когда брать |
|---|---|---|---|
unique_ptr | единоличное | нулевая (без делитера с состоянием) | по умолчанию, если владелец один |
shared_ptr | разделяемое | атомарный счётчик, двойной размер | несколько владельцев, время жизни неизвестно заранее |
weak_ptr | наблюдение без владения | требует lock() перед доступом | обратные ссылки, кэши, разрыв циклов |
сырой T* | нет | нулевая | невладеющий параметр функции, которая не хранит указатель |
Сырой указатель не запрещён — он остаётся честным способом сказать «я на это только смотрю, владелец где-то ещё». Проблема сырого указателя в том, что он выражает владение по умолчанию у старого кода. В новом коде сырой указатель — это «наблюдатель», и он не должен переживать объект, на который смотрит.
Типичные ошибки
- shared_ptr из одного сырого указателя дважды. Два независимых
shared_ptr, созданных от одногоnew T, заведут два управляющих блока и удалят объект дважды. Никогда не оборачивайте один сырой указатель вshared_ptrбольше одного раза — используйтеmake_shared. - this в shared_ptr. Если объект должен раздать
shared_ptrна самого себя, наследуйте отstd::enable_shared_from_this<T>и используйтеshared_from_this(), а неshared_ptr<T>(this)— иначе снова два управляющих блока. - shared_ptr там, где хватило бы unique_ptr. «На всякий случай» — плохой аргумент: вы платите атомарностью за владение, которого нет.
- Передача shared_ptr по значению в горячий путь. Каждая передача по значению — атомарный инкремент и декремент. Если функция не хранит указатель, принимайте
const T&или сыройT*.
Про подсчёт ссылок в многопоточности одна оговорка: атомарен только сам счётчик. Одновременная запись в содержимое объекта через два shared_ptr из разных потоков — это гонка данных, синхронизация остаётся на вас.
Что делать дальше
Прогоните проект через санитайзеры: сборка с -fsanitize=address ловит двойное удаление и обращение к освобождённой памяти, -fsanitize=leak — незакрытые циклы. Для поиска shared_ptr, который стоило бы заменить на unique_ptr, включите clang-tidy с проверками группы modernize и performance. Дальше стоит посмотреть в сторону gsl::not_null и невладеющих ссылок из Core Guidelines — это про то, как выражать «здесь nullptr невозможен» на уровне типа.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: std::unique_ptr — cppreference.com, std::shared_ptr — cppreference.com
Частые вопросы
У меня старый компилятор без C++14, make_unique недоступен?
Почему после замены shared_ptr на weak_ptr память всё равно не освобождается?
Можно ли класть unique_ptr в std::vector и другие контейнеры?
Не сработало: программа падает при выходе, хотя везде умные указатели.
Насколько дороже shared_ptr обычного указателя в реальном коде?
Когда допустимо использовать сырой указатель в новом коде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.