Синтаксис си/с++

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

Введение

Мы разбирали ручное управление динамической памятью через new/delete, систематически разберёт RAII как общую философию — умные указатели представляют практическое, готовое к использованию воплощение RAII конкретно для управления динамически выделенными объектами, автоматизируя освобождение памяти и существенно снижая риск утечек и ошибок ручного управления.

Концепция

std::unique_ptr<T> — умный указатель, обеспечивающий единственное, эксклюзивное владение динамически выделенным объектом — память автоматически освобождается через деструктор unique_ptr, когда сам unique_ptr уничтожается (выход из области видимости), и unique_ptr не может быть скопирован (только перемещён), что и обеспечивает гарантию единственности владельца на уровне системы типов. std::shared_ptr<T> поддерживает совместное владение объектом через внутренний счётчик ссылок — память освобождается только когда последний shared_ptr, ссылающийся на этот объект, уничтожается (счётчик ссылок доходит до нуля), что даёт большую гибкость по сравнению с unique_ptr, но за счёт дополнительных накладных расходов на поддержание этого счётчика. std::weak_ptr<T> — «слабая» ссылка на объект, управляемый shared_ptr, не влияющая на счётчик ссылок и, соответственно, не продлевающая время жизни объекта — используется для разрыва циклических ссылок между shared_ptr (статья ниже о подводных камнях) и для безопасной проверки, существует ли объект ещё в данный момент, без гарантированного владения им.

Пример кода

#include <iostream>
#include <memory>

class Resource {
public:
    Resource(int id) : m_id(id) { std::cout << "Resource " << m_id << " создан" << std::endl; }
    ~Resource() { std::cout << "Resource " << m_id << " уничтожен" << std::endl; }
private:
    int m_id;
};

int main()
{
    {
        std::unique_ptr<Resource> ptr = std::make_unique<Resource>(1);
        // НЕЛЬЗЯ скопировать: std::unique_ptr<Resource> ptr2 = ptr; // ОШИБКА КОМПИЛЯЦИИ
        std::unique_ptr<Resource> ptr2 = std::move(ptr); // МОЖНО переместить (статья 546)
    } // Resource 1 автоматически уничтожен ЗДЕСЬ, при выходе из блока — НЕ требуется явный delete!

    return 0;
}
// shared_ptr — совместное владение через счётчик ссылок
#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<Resource> ptr1 = std::make_shared<Resource>(2);
    std::cout << "Счётчик ссылок: " << ptr1.use_count() << std::endl; // 1

    {
        std::shared_ptr<Resource> ptr2 = ptr1; // КОПИРОВАНИЕ shared_ptr — допустимо, увеличивает счётчик
        std::cout << "Счётчик ссылок: " << ptr1.use_count() << std::endl; // 2
    } // ptr2 уничтожен здесь — счётчик уменьшается, но Resource ЕЩЁ ЖИВ, поскольку ptr1 всё ещё существует

    std::cout << "Счётчик ссылок: " << ptr1.use_count() << std::endl; // 1
    return 0;
} // Resource 2 уничтожается ЗДЕСЬ, когда ptr1 (последний владелец) выходит из области видимости
// weak_ptr — разрыв циклической ссылки между двумя shared_ptr, ссылающимися друг на друга
#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> previous; // weak_ptr вместо shared_ptr — РАЗРЫВАЕТ потенциальный цикл ссылок
    int value;
};

int main()
{
    auto first = std::make_shared<Node>();
    auto second = std::make_shared<Node>();

    first->next = second;
    second->previous = first; // weak_ptr — НЕ увеличивает счётчик ссылок first, предотвращая утечку памяти

    // Без weak_ptr (если бы previous был shared_ptr) — first и second держали бы друг друга
    // "живыми" БЕСКОНЕЧНО, даже после выхода из области видимости — классическая утечка через цикл
    return 0;
}

Пояснения к коду

std::unique_ptr<Resource> ptr с попыткой копирования (закомментированной как ошибка компиляции) и реально работающим перемещением (std::move(ptr)) показывает фундаментальное ограничение unique_ptr, гарантирующее единственность владения на уровне системы типов — компилятор сам предотвращает ситуацию, где два разных unique_ptr могли бы случайно ссылаться на один и тот же объект, что устранило бы саму гарантию единственного владельца. Демонстрация счётчика ссылок (use_count()) для shared_ptr показывает реальный механизм совместного владения — каждое копирование shared_ptr увеличивает счётчик, каждое уничтожение копии уменьшает его, и реальное освобождение управляемого объекта Resource происходит только тогда, когда счётчик достигает нуля (последний владелец уничтожен), что демонстрируется тем, что Resource 2 продолжает существовать даже после уничтожения ptr2, пока жив ptr1. Пример с Node::previous как weak_ptr (вместо потенциально проблематичного shared_ptr) показывает классическое практическое применение weak_ptr — без этого изменения два узла, ссылающиеся друг на друга через shared_ptr в обоих направлениях, образовали бы циклическую зависимость, в которой счётчик ссылок каждого из них никогда не достигнет нуля (поскольку каждый держит другого «живым»), приводя к перманентной утечке памяти даже после того, как внешний код потерял все остальные ссылки на оба узла.

Подводные камни

  • Использование shared_ptr «по умолчанию» вместо unique_ptr без действительной необходимости в совместном владенииshared_ptr несёт дополнительные накладные расходы (поддержание атомарного, потокобезопасного счётчика ссылок) по сравнению с unique_ptr, и для случаев, где объект реально имеет единственного, чётко определённого владельца (что является значительно более распространённой, типичной ситуацией, чем реальная потребность в совместном владении), unique_ptr — более эффективный и более явно документирующий реальное предназначение код выбор, и shared_ptr стоит использовать осознанно, именно когда совместное владение действительно нужно.
  • Создание циклических ссылок через shared_ptr без использования weak_ptr для разрыва цикла, как явно демонстрирует пример с Node — это одна из главных, классических опасностей shared_ptr, приводящая к утечкам памяти, не обнаруживаемым автоматическим механизмом подсчёта ссылок самим по себе (поскольку счётчик каждого узла в цикле никогда не достигает нуля), и любая структура данных с потенциальными циклическими связями между объектами, управляемыми shared_ptr (двусвязные списки, древовидные структуры со ссылками «родитель-потомок» в обоих направлениях), должна явно использовать weak_ptr для одного из направлений связи, разрывающего потенциальный цикл.
  • Создание shared_ptr из «сырого» указателя несколько раз для того же самого объекта (std::shared_ptr<Resource> a(rawPtr); std::shared_ptr<Resource> b(rawPtr); для одного и того же rawPtr) вместо копирования уже существующего shared_ptr — каждое такое отдельное создание из «сырого» указателя создаёт НЕЗАВИСИМЫЙ счётчик ссылок, не связанный с другими, что приводит к множественному, несогласованному освобождению одного и того же объекта (как только первый из независимых счётчиков достигает нуля), являющемуся классической ошибкой двойного освобождения, и правильный способ совместного владения — копирование уже существующего shared_ptr (как ptr2 = ptr1 в примере), а не повторное, независимое создание из исходного «сырого» указателя.
  • Попытка использовать weak_ptr напрямую, как обычный указатель, без предварительного преобразования в shared_ptr через метод lock()weak_ptr не предоставляет прямого доступа к управляемому объекту (через оператор */->) именно потому, что объект может быть уже уничтожен к моменту попытки доступа, и корректное использование требует явного вызова lock(), возвращающего временный shared_ptr (либо валидный, если объект ещё существует, либо пустой/нулевой, если объект уже был уничтожен), что нужно явно проверить перед фактическим использованием полученного результата.