Краткий справочник по умным указателям С++

Умные указатели появились в стандарте С++11 и являются фундаментальной основой для написания безопасного кода с точки зрения утечек памяти в современном С++. Благодаря идиоме RAII (Resource Acquisition Is Initialization – получение ресурса есть инициализация), используемой в умных указателях, они позволяют эффективно работать с указателями на динамически выделенные области памяти. В этой статье вы узнаете об основных моментах при работе с этими полезными типами.

Содержание статьи

1. Общая информация
2. std::unique_ptr
3. std::shared_ptr
4. std::weak_ptr

Начнем с общих черт и описания в целом такого понятия, как умные указатели, а затем рассмотрим каждый вид умных указателей по отдельности. Рассмотрим их отличия, области применения, слабые и сильные места. Поехали!

1. Общая информация

  • Определение умных указателей содержится в заголовочном файле <memory>
  • Умные указатели назвали “умными” потому, что они хранят указатель на объект или некий ресурс, а также осуществляют владение этими данными. Они основаны на идиоме RAII.
  • Указатели unique_ptr и shared_ptr имеют перегруженные оператора доступа * и ->, поэтому умные указатели могут быть разыменованы как и обычные “сырые” (raw) указатели.
  • Для получения сырого указатели из объекта unique_ptr и shared_ptr используйте метод get().
  • Метод .get() может быть полезен, когда вам необходимо передать в функцию сырой указатель для отслеживания состояния объекта (желательно не изменять объект, которым владеет умный указатель, через сырые указатели).
void useObject(MyType* pObj) { }
useObject(mySmartPtr.get());
  • unique_ptr (начиная со стандарта C++11) и shared_ptr (со стандарта C++17) получили специализацию шаблона для управления массивами данных. Нововведение заключается во внедрении вызова оператора delete[] при удалении управляемого объекта). Это может быть полезно, когда вы получаете указатель на массив, например, из какой-либо библиотеки. Однако, если возможно, старайтесь использовать вместо массивов стандартные контейнеры такие как std::vector<T> или std::array<T>.
  • Напоминаю! Не используйте в своем коде auto_ptr! Этот указатель имеет ряд критических проблем, отмечен устаревшим в стандарте C++11 и полностью удален в стандарте С++17. Взамен используйте unique_ptr. Для поиска таких мест можете воспользоваться настройкой modernize-replace-auto-ptr утилиты Clang Tidy для автоматизации рефакторинга.
  • В стандартах C++17/20 отсутствует выведение аргументов шаблонов классов (class template argument deduction – CTAD) для умных указателей. Поэтому компилятор не в состоянии отличить указатель на массив от указателя на объекты не массивы созданные оператором new().
  • Начиная со стандарта С++20 появились возможность применение атомарных указателей std::atomic<std::shared_ptr<T>> и std::atomic<std::weak_ptr<T>>. Также, в этом стандарты были отмечены устаревшими глобальные атомарные функции для умных указателей, доступных с С++11.
  • C++20 добавляет различные функции конструирования *_for_overwrite, которые не принимают аргументов конструктора и используют инициализацию по умолчанию (аналогичную применению оператора new T). Это позволяет избежать ненужной инициализации в ситуациях, когда начальное значение никогда не считывается (например, чтение в буфер).

2. std::unique_ptr

std::unique_ptr это легковесный указатель монопольно владеющий ресурсом.

  • std::unique_ptr уничтожает управляемый объект когда указатель покидает область видимости. Для удаления объекта и назначения (при необходимости) нового объекта в управление std::unique_ptr используйте метод reset().
  • std::unique_ptr не может быть скопирован, но его можно переместить.
  • Обычно, указатель имеет небольшой размер, сопоставимый с размером обычного сырого указателя на объект или размер двух указателей, если при создании умного указателя устанавливается пользовательская функция очистки памяти.

Создание

Наилучший и самый безопасный способ создания unique_ptr это использование переменной типа auto и присваивание ей результата выполнения статического метода std::make_unique

auto pObj = make_unique<MyType>(...);

Уникальный указатель можно создать и с явным указанием оператора new:

unique_ptr<MyType> pObject(new MyType(...));

Код выше имеет два недостатка в сравнении с первым способом:

  1. тип указывается дважды
  2. используется сырой указатель, получаемый оператором new для инициализации умного указателя

Но в некоторых случаях второй пример может быть полезен и даже незаменимым:

  1. для создания умного указателя с пользовательским оператором удаления (об этом далее)
  2. для создания полиморфных объектов, см. пример ниже
unique_ptr<Base> pObject(new MyDerived(...));

Пользовательские функции (операторы) удаления

Оператор или функция удаления это функциональный объект используемый для удаления ресурса. По умолчанию используются стандартные операторы delete или delete[].

struct DelFn {  
  void operator()(MyTy* p) {
    p->SpecialDelete(); 
    delete p;
  }
};

using my_ptr = unique_ptr<MyTy, DelFn>;
На мой взгляд ещё удобнее использовать в качестве пользовательского оператора удаления лямбда-функции.
  • Оператор удаления не вызывается, если указатель содержит нулевой указатель
  • Метод get_deleter() возвращает не константную ссылку на оператор удаления. Этот факт позволяет использовать ссылку и для замены оператора удаления.

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

Как было сказано выше, unique_ptr может быть только перемещаемым объектом. Перемещение следует выполнять функцией std::move(), чтобы явно отобразить передачу владения объектом от одного указателя к другому:

auto pObj = make_unique<MyType>(...);
func(std::move(pObj));
// pObj является невалидным указателем после вызова std::move()

Дополнительная информация

  • Метод reset() – сбрасывает указатель (владеемый объект удаляется)
  • unique_ptr – полезен при реализации идиомы “pimpl” (pointer to implementation – указатель на реализацию).
  • Уникальный указатель рассматривается в первую очередь как кандидат на возвращаемый объект из фабричных методов. Если уникальный указатель не подходит, тогда рассмотрите возможность применения shared_ptr (или weak_ptr).

3. std::shared_ptr

Общий (shared) указатель обеспечивает “распределенное” владение объектом. Множество shared_ptr могут указывать на один и тот же объект. Рядом с каждым объектом создается счетчик указателей, ссылающегося на него. Уничтожение объекта происходит в тот момент, когда уничтожается последний указатель на него (shared_ptr или weak_ptr).

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

Создание

Рекомендуемый метод, как и с уникальными указателями, это использованием статического метода make_shared:

auto pObj = make_shared<MyType>(...)

make_shared выделяет место под контролируемый объект и рядом создает управляющий блок. Это позволяет компактнее использовать динамическую память.

Пользовательские операторы удаления

Указатель на оператор удаления хранится в управляющем блоке. При создании shared_ptr с помощью конструктора можно указать пользовательский оператор удаления.

void DelFn(MyTp* p) {
    if (p) p->OnDelete(); 
    delete p;
}

shared_ptr<MyTp> ptr(new MyTp(), DelFn);
  • Оператор удаления обязан корректно обрабатывать нулевые указатели. Допускаются ситуации, когда оператор удаления может быть вызван в пустых указателях shared_ptr.
  • Метод get_deleter() возвращает неконстантный указатель на оператор удаления.

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

Владение объектом в указателе shared_ptr не является монопольным, поэтому вы можете поделиться указателем на объект владения (для указателей unique_ptr не рекомендуется создавать несколько unique_ptr указателей на один и тот же объект). Счетчик shared указателей обновляется автоматически при создании и уничтожении объектов shared_ptr, но за этот функционал конечно приходится немного заплатить. Shared указатели можно перемещать методом std::move().

Для получения сырого указателя используйте метод get().

Дополнительная информация

  • Изменение состояния счетчика указателей является атомарным, но доступ к указателю не является потоко-безопасным.
  • Для создания shared_ptr указателей на *this используйте метод shared_from_this() и унаследуйте класс от std::enable_shared_from_this.
  • Приведение типов указателей может выполняться функциями dynamic_pointer_cast, static_pointer_cast или reinterpret_pointer_cast.
  • В виду не монопольности владения объектом, неаккуратное использование shared указателей может приводить к возникновению циклических зависимостей, когда несколько указателей ссылаются друг на друга и не могут уничтожить объект, что может приводить у утечке памяти

4. std::weak_ptr

Слабые (weak) указатели содержат слабую ссылку на объект, который управляется shared указателем. std::weak_ptr для доступа к указываемому объекту обязан быть преобразован в std::shared_ptr методом lock().

  • Одним из примеров, где слабые указатели находят широкое применение это кэширование. Например, ресурсы выделяемые операционной системой всегда представляют собой слабые ссылки и перед использованием нужно убедится, что это ресурс всё ещё является доступным.
  • Применение слабых указателей позволяет избежать циклической зависимости при применении shared указателей.

Создание

Слабые указатели создаются из shared_ptr, но перед их использованием их нужно конвертировать обратно в shared_ptr.

std::weak_ptr pWeak = pSharedPtr;

if (auto observe = pWeak.lock()) {
    // Объект существует
} else {
   // shared_ptr является пустым
}

При создании std::weak_ptr из std::shared_ptr происходит инкремент счетчика слабых указателей в управляющем блоке shared указателя. Это происходит даже в том случае, если все shared указатели, ссылающиеся на объект, были уничтожены, тогда управляющий блок существует до тех пора, пока существует хотя бы один std::weak_ptr, указываемый на него. Это может быть проблемой если управляющий блок был выделен в памяти рядом с управляемым объектом (например при использовании метода make_shared<T>()). Получается, что управляемый объект уничтожается, а вот управляющий блок остается висеть в памяти.

Дополнительная информация

  • метод use_count() возвращает количество shared указателей, которые управляют одним и тем же объектом
  • используйте expired() для проверки факта существования управляемого объекта
  • слабые указатели не содержат операторов * и ->, поэтому единственный способ получить доступ к управляемому объекту – конвертировать weak_ptr в shared_ptr методом lock().

В качестве выводов

Эта статья познакомила или напомнила вам важную информацию об умных указателях в современном C++. Представленная здесь информация должна облегчить работу с основными типами умных указателей и идиомой RAII.


Пост является переводом статьи: https://www.cppstories.com/2021/smart-ptr-ref-card/