Введение в smart_ptr

Указатели в C и C++ дикие звери. Они чрезвычайно мощные, но в то же время такие опасные: небольшой недосмотр может нанести ущерб всему вашему приложению. Их основная проблема заключается в том, что вы и только вы должны следить за корректной работой с ними. Каждый динамически созданный объект (т.е. new T) должен сопровождаться ручным удалением (т.е. delete T). Забудьте об этом, и у вас получится хорошая утечка памяти.

Кроме того, динамически выделенные массивы (т.е. new T[N]) должны быть удалены с помощью другого оператора (т.е. delete[]). Это заставляет вас мысленно следить за тем, что вы выделили, и вызывать соответствующий оператор. Использование неправильного оператора приводит к неопределенному поведению, что ни в коем случае нельзя допускать при работе в C++.

Еще одна тонкая проблема заключается во владении. Сторонняя функция возвращает указатель: это динамически распределяемые данные? Если так, кто несет ответственность за очистку? Это невозможно понять просто посмотрев на тип возвращаемого значения.

Идея умных указателей

Умные указатели появились чтобы убрать проблемы, упомянутые выше. Они обеспечивают автоматическое управление памятью: когда умный указатель больше не используется, то память, на которую он указывает, автоматически освобождается, в отличие от традиционных указателей, которые сейчас так же известны как сырые указатели.

Мы можем представить умный указатель как класс-обертку над сырым указателем, у которого перегружены операторы -> и *. Благодаря этим оператором умный указатель предоставляет тот же синтаксис, что и сырой указатель. Когда умный указатель выходит из области видимости, срабатывает его деструктор и выполняется очистка памяти. Этот метод называется Resource Acquisition Is Initialization (RAII): класс, обернутый вокруг динамического ресурса (файл, сокет, соединение с БД, выделенная память и т.д.), который удаляется/закрывается в деструкторе. Таким образом, вы обязательно избежите утечек ресурсов.

Умные указатели можно рассматривать как элементарный сборщик мусора: своего рода автоматическое управление памятью, при котором объект автоматически удаляется, когда он перестает использоваться программой.

Типы умных указателей в C++

C++ 11 представил три типа умных указателей, все они определены в заголовке <memory> из стандартной библиотеки:

  • std::unqie_ptr — умный указатель, который единолично владеет динамически выделенным объектом.
  • std::shared_ptr — умный указатель, которому принадлежит общий динамически выделенный объект. Несколько объектов std::shared_ptr (внутренний счетчик отслеживает их) могут владеть одним и тем же объектом.
  • std::weak_ptr — как и std::shared_ptr, но не увеличивает счетчик.

Возможно, вы так же слышали о std::auto_ptr. Это класс из прошлого, ныне не используемая: забудьте об нём.

Сейчас все выглядит запутанно, особенно непонятно какой тип умного указателя следуют использовать. Не волнуйтесь, я расскажу обо всём дальше. Давайте копать глубже!

std::unique_ptr — единоличное владение

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

Пример использования std::unique_ptr

std::unique_ptr<Type> p(new Type);

Например,

std::unique_ptr<int>    p1(new int);
std::unique_ptr<int[]>  p2(new int[50]);
std::unique_ptr<Object> p3(new Object("Lamp"));

Также возможно построить std::unique_ptr с помощью специальной функции std::make_unique, например:

std::unique_ptr<int>    p1 = std::make_unique<int>();
std::unique_ptr<int[]>  p2 = std::make_unique<int[]>(50);
std::unique_ptr<Object> p3 = std::make_unique<Object>("Lamp");

Если вы можете, всегда предпочитайте размещать объекты, используя std::make_unique. Я покажу зачем это делать в последнем разделе этой статьи.

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

В отличие от std::unique_ptr, std:shared_ptr допускает владение одним объектом несколькими умными указателями. Специальный внутренний счетчик уменьшается каждый раз, когда std::shared_ptr, указывающий на один и тот же ресурс, выходит из области видимости. Этот метод называется подсчетом ссылок. После уничтожения последнего объекта указателя, счетчик обнуляется и данные освобождаются.

Этот тип умного указателя полезен, когда вы хотите обмениваться динамическими данными так же, как вы бы это делали с сырыми указателями или ссылками.

Создание std::shared_ptr

std::shared_ptr устроен так:

std::shared_ptr<Type> p(new Type);

Например:

std::shared_ptr<int>    p1(new int);
std::shared_ptr<Object> p2(new Object("Lamp"));

Существует альтернативный способ построения std::shared_ptr, основанный на специальной функции std::make_shared:

std::shared_ptr<Type> p = std::make_shared<Type>(...parameters...);

Например:

std::shared_ptr<int>    p1 = std::make_shared<int>();
std::shared_ptr<Object> p2 = std::make_shared<Object>("Lamp");

Подводные камни у std::shared_ptr

У std::shared_ptr есть один неприятный сюрприз — циклическая ссылка. Если два объекта std::shared_ptr будут ссылаться друг на друга, то внутренний счётчик будет равен единице и эти объекты никогда не удалятся, например:

struct Player {
    std::shared_ptr<Player> friend;
    ~ Player() {
        std::cout << "~Player" << std::endl;
    }
}

int main() {
    std::shared_ptr<Player> first  = std::make_shared<Player>();
    std::shared_ptr<Player> second = std::make_shared<Player>();

    first->friend  = second;
    second->friend = first;
}

В коде выше создается циклическая ссылка ( эти объекты удалятся только в конце работы программы) — утечка памяти во всём её великолепии. К счастью, последний тип умного указателя придет на помощь.

std::weak_ptr — слабая ссылка

std::weak_ptr — умный указатель, который содержит «слабую» ссылку на объект, управляемый указателем std::shared_ptr. Часто используется для устранения циклических ссылок у std::shared_ptr

std::weak_ptr моделирует временное владение: когда объект должен быть доступен только если он существует и может быть удален в любой момент кем-то другим. std::weak_ptr может преобразоваться в std::shared_ptr для принятия временного владения. Если исходный std::shared_ptr будет уничтожен в процессе работы, время жизни объекта продлевается до того момента, пока не будет разрушен временный std::shared_ptr

std::weak_ptr в бою

std::weak_ptr является своего рода наблюдателем — он может наблюдать и получать доступ к тому же объекту, на который указывает std::shared_ptr, но не считается владельцем этого объекта.

Чтобы работать с реальным объектом, на который указывает std::weak_ptr, нужно преобразовать его в std::shared_ptr, вызвав метода lock(), например:

std::shared_ptr<int> p_shared = std::make_shared<int>(100);
std::weak_ptr<int>   p_weak(p_shared);
// ...
std::shared_ptr<int> p_shared_orig = p_weak.lock();
//

std::weak_ptr решает проблему висячих указателей (указатели, которые указывают на уже удаленные данные), предоставляя метод expire(), который проверяет был ли удалён хранимый объект. Если expire() == true, то хранимый объект был удалён — это то, что вы не можете проверить, работая с сырыми указателями.

Производительность smart_ptr

Согласно различным источникам (здесь и здесь), производительность умных указателей должна быть близка к сырым указателям. Небольшое снижение скорости может присутствовать при использовании std::shared_ptr из-за внутреннего подсчета ссылок. Но это не должно замедлять работу кода, если вы постоянно не создаете и не уничтожаете умные указатели.

Нужно ли избавляться от new/delete?

При работе с памятью желательно использовать умные указатели, потому что они решают как минимум три проблемы:

  • Автоматическое удаление
  • Явная семантика владения
  • Гарантия исключений

Таким образом, если научиться правильно использовать умные указатели, то компилятор отловит большинство потенциальных проблем и настучит тебе по рукам 🙂

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *