Добавить в избранное

Умные указатели C++: unique_ptr, shared_ptr и weak_ptr на практике

Чтение ≈ 5 мин

Когда брать unique_ptr, когда shared_ptr и зачем нужен weak_ptr. Разбираем владение, make_unique/make_shared, циклические ссылки, deleter и почему сырые new/delete в современном C++ не нужны.

Владение как идея

Главный вопрос при работе с динамической памятью — кто отвечает за освобождение объекта и когда. Умные указатели из <memory> делают ответ явным в типе: unique_ptr — единоличное владение, shared_ptr — разделяемое со счётчиком ссылок, weak_ptr — наблюдение без владения.

Это не просто «удобные обёртки». Это инструмент документирования архитектуры: по сигнатуре функции сразу видно, забирает она объект себе или только смотрит.

#include <memory>

std::unique_ptr<Widget> makeWidget();  // фабрика отдаёт владение вам
void render(const Widget& w);          // только смотрит — без умных указателей

unique_ptr — владение без накладных расходов

std::unique_ptr — обёртка над сырым указателем размером ровно с указатель. При уничтожении указателя удаляется и объект. Копировать нельзя, перемещать — можно: после std::move исходный указатель обнуляется.

auto p = std::make_unique<int>(42);        // C++14
*p = 10;

auto q = std::move(p);   // p теперь пустой, q владеет объектом
// p.get() == nullptr

make_unique предпочтительнее new: короче, исключение-безопасно и запрещает случайное двойное владение. Массивы тоже поддерживаются:

auto arr = std::make_unique<int[]>(100);   // new int[100], удалит через delete[]
arr[5] = 7;

Передача в функции

  • f(std::unique_ptr<T> p) — функция забирает объект. Вызывающий обязан написать std::move.
  • f(T& obj) или f(T* p) — функция только использует, владение остаётся у вызывающего.
void consume(std::unique_ptr<int> p) { /* владеет */ }
void peek(const int& v) { /* не владеет */ }

auto p = std::make_unique<int>(5);
peek(*p);
consume(std::move(p));   // после этой строки p пуст

shared_ptr — разделяемое владение

shared_ptr хранит счётчик ссылок: каждая копия увеличивает счётчик, каждое уничтожение — уменьшает. Объект удаляется, когда счётчик доходит до нуля.

auto a = std::make_shared<std::string>("данные");
{
    auto b = a;   // счётчик = 2
    auto c = a;   // счётчик = 3
}                 // b, c уничтожены — счётчик = 1
// a уничтожается — счётчик = 0, строка удалена

Цена: блок управления (счётчики + deleter) выделяется в куче, а инкремент/декремент — атомарные операции. Поэтому shared_ptr применяют только когда владение реально разделяется. «На всякий случай» — плохая причина.

make_shared выделяет объект и блок управления одной аллокацией — быстрее и компактнее, чем shared_ptr<T>(new T).

weak_ptr — наблюдатель и разрыв циклов

Классическая проблема shared_ptr — циклические ссылки: объекты держат друг друга и никогда не удаляются. Утечка памяти в чистом виде.

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;   // назад — weak, разрываем цикл!
};

weak_ptr не увеличивает счётчик и не может разыменоваться напрямую. Сначала его «запирают» через lock() — получается временный shared_ptr или пустота, если объект уже умер:

std::weak_ptr<int> w;
{
    auto s = std::make_shared<int>(7);
    w = s;
}  // s умер

if (auto locked = w.lock()) {
    std::cout << *locked;
} else {
    std::cout << "объект уничтожен\n";   // сюда попадём
}

Типичные сценарии: кэши, списки подписчиков, родительские ссылки в деревьях, наблюдатели.

Свой deleter

Умный указатель умеет вызывать не только delete. Кастомный удалитель нужен для C-ресурсов: файлов, сокетов, контекстов библиотек:

auto fileDeleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(fileDeleter)> fp(fopen("log.txt", "w"), fileDeleter);

У unique_ptr тип deleter — часть типа (может нести состояние). У shared_ptr deleter стирается по типу — удобнее, но чуть тяжелее.

Когда что брать — шпаргалка

  • Один владелец (99 % случаев): unique_ptr.
  • Объект живёт, пока хоть кто-то его держит (общий ресурс, асинхронные задачи): shared_ptr + make_shared.
  • Наблюдатель, кэш, обратные ссылки: weak_ptr.
  • Просто посмотреть/использовать внутри вызова: T& или T* — сырой указатель как невладеющий параметр это нормально.
  • this наружу из shared-объекта: наследуйте std::enable_shared_from_this и зовите shared_from_this().

Частые ошибки

  • shared_ptr<T>(new T) вместо make_shared — лишняя аллокация и исключение-опасно.
  • Два shared_ptr на один сырой указатель — двойное удаление.
  • Хранить weak_ptr без проверки lock() на каждое использование.
  • unique_ptr в контейнере — нормально, но копировать контейнер нельзя, только перемещать.
  • Гонки: счётчик ссылок атомарен, но сам объект не потокобезопасен автоматически.

Итог

Формула современного C++: сырой new почти не пишут — unique_ptr по умолчанию, shared_ptr при разделении, weak_ptr против циклов. Компилятор сам следит за временем жизни, утечки становятся редкостью, а семантика владения читается прямо из кода.

Практический паттерн: фабрика + unique_ptr

class Transport {
public:
    virtual void send(std::string_view data) = 0;
    virtual ~Transport() = default;
};

std::unique_ptr<Transport> makeTransport(std::string_view kind) {
    if (kind == "tcp") return std::make_unique<TcpTransport>();
    if (kind == "udp") return std::make_unique<UdpTransport>();
    throw std::invalid_argument("неизвестный транспорт");
}

Вызывающий получает владение уникальным указателем — семантика ясна из сигнатуры, утечки исключены.

unique_ptr в классах: pImpl

Умные указатели позволяют «указатель на реализацию» без утечек:

class Widget {
    struct Impl;                 // только объявление в .h
    std::unique_ptr<Impl> impl_;
public:
    Widget();
    ~Widget();                   // важно: в .cpp, где Impl полный
};

Приватность реализации, быстрая перекомпиляция при правках Impl, автоматическое владение.

shared_ptr: реальный сценарий

Разделяемое владение уместно, когда объект действительно принадлежит нескольким — например, соединение с БД, которое используют несколько сервисов и живёт, пока жив хоть один:

class DbPool {
    std::vector<std::shared_ptr<Connection>> conns_;
public:
    std::shared_ptr<Connection> acquire();
};

Заметьте: vector<shared_ptr> внутри пула + shared_ptr наружу — соединение живёт, пока хоть кто-то держит.

Проверка времени жизни: weak_ptr в кэше

std::unordered_map<std::string, std::weak_ptr<Texture>> cache;

std::shared_ptr<Texture> getTexture(const std::string& name) {
    if (auto it = cache.find(name); it != cache.end()) {
        if (auto tex = it->second.lock()) return tex;  // ещё жива
    }
    auto tex = loadTexture(name);
    cache[name] = tex;
    return tex;
}

Кэш не продлевает жизнь текстур — когда последний владелец отпустил, память освобождается, а weak-связь в кэше просто «устаревает».

Производительность: измерьте прежде чем менять

shared_ptr дороже unique_ptr из-за блока управления и атомарных счётчиков. Но в 99 % кода разница невидима — не разменивайте семантику на микрооптимизацию без профиля. Берите unique_ptr по умолчанию, shared_ptr только когда владение реально разделяется — и вопрос производительности почти не встанет.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий