[Перевод] Умные указатели в современном C++ с точки зрения новичка

fe403f4b1f73bd7dbda5262c05961df2

Новые (?) пути управления памятью


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

Более того, динамически выделяемые массивы (например, new T[N]) необходимо удалять с помощью другого оператора (например, delete[]). Поэтому приходится мысленно отслеживать, что вы выделили, и соответственно вызывать нужный оператор. Ошибки с выбором формы приводят к неопределенному поведению, чего при работе на C++ нужно избегать любой ценой.

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

Суть умных указателей


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

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

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

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

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


В C++11 появилось три типа умных указателей, все они определены в заголовке из Стандартной библиотеки:

  • std: unique_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

A std: unique_ptr создается следующим образом:

std::unique_ptr p(new Type);


Например:

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


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

std::unique_ptr p = std::make_unique(...размер или параметры...);


Например:

std::unique_ptr    p1 = std::make_unique();
std::unique_ptr  p2 = std::make_unique(50);
std::unique_ptr p3 = std::make_unique("Lamp");


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

std: unique_ptr в действии

Главная особенность этого умного указателя — исчезать, когда он больше не используется. Рассмотрим следующий код:

void compute()
{
    std::unique_ptr data = std::make_unique(1024);
    /* выполнение некоторых значимых вычислений над вашими данными...*/
} // `data` выходит из области действия здесь: она автоматически уничтожается
int main()
{
    compute();
}


Умный указатель выходит из области видимости, когда функция compute () достигает конца тела. Вызывается деструктор указателя, и память очищается автоматически. Больше ни о чем волноваться не нужно.

Один ресурс, один std: unique_ptr

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

void compute(std::unique_ptr p) { ... } 

int main()
{
    std::unique_ptr ptr = std::make_unique(1024);
    std::unique_ptr ptr_copy = ptr; // ОШИБКА! Копирование запрещено
    compute(ptr);  // ОШИБКА! `ptr` передается копией, а копирование не разрешено
}


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

Технически это происходит потому, что у std: unique_ptr нет конструктора копирования: это может быть очевидно для вас, если вы знакомы с семантикой перемещения (я написал об этом вводную статью, если вы не знакомы). Во второй части этой статьи я покажу, как правильно передавать умные указатели.

Понимание std: shared_ptr: конвивиальный вариант


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

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

Как создать std::shared_ptr
std::shared_ptr создается так:
std::shared_ptr p(new Type);


Например:

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


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

std::shared_ptr p = std::make_shared(...parameters...);


Например:

std::shared_ptr    p1 = std::make_shared();
std::shared_ptr p2 = std::make_shared("Lamp");


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

Проблемы с массивами

До C++17 не было простого способа соорудить std: shared_ptr, хранящий массив. До C++17 этот умный указатель по умолчанию всегда вызывает delete (а не delete[]) на своем ресурсе: вы можете создать обходной путь, используя кастомное удаление. Один из многих конструкторов std: shared_ptr принимает в качестве второго параметра лямбду, в которой вы вручную удаляете принадлежащий ему объект. Например:

std::shared_ptr p2(new int[16], [] (int* i) { 
  delete[] i; // Кастомное удаление
});


К сожалению, нет возможности сделать это при использовании std: make_shared.

std: shared_ptr в действии

Одна из главных особенностей std: shared_ptr — возможность отслеживать, сколько указателей ссылаются на один и тот же ресурс. Получить информацию о количестве ссылок можно с помощью метода use_count (). Рассмотрим следующее:

void compute()
{
  std::shared_ptr ptr = std::make_shared(100);
  // ptr.use_count() == 1
  std::shared_ptr ptr_copy = ptr;   // Сделать копию: с shared_ptr возможно!
  // ptr.use_count() == 2
  // ptr_copy.use_count() == 2, в конце концов, это одни и те же базовые данные.
} // Здесь `ptr` и `ptr_copy` выходят из области действия. Больше никаких ссылок  
  // исходные данные (т.е. use_count() == 0), поэтому они автоматически убираются.
int main()
{
  compute();
}


Обратите внимание, как ptr и ptr_copy выходят из области видимости в конце функции, доводя счетчик ссылок до нуля. В этот момент деструктор последнего объекта обнаруживает, что ссылок больше нет, и запускает очистку памяти.

Один ресурс, много std: shared_ptr. Не забывайте о циклических ссылках!

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

struct Player
{
  std::shared_ptr companion;
  ~Player() { std::cout << "~Player\n"; }
};

int main()
{
  std::shared_ptr jasmine = std::make_shared();
  std::shared_ptr albert  = std::make_shared();

  jasmine->companion = albert; // (1)
  albert->companion  = jasmine; // (2)
}


Логично, не так ли? К сожалению, я только что создал так называемую круговую ссылку. В начале моей программы я создаю два умных указателя jasmine и albert, которые хранят динамически создаваемые объекты: назовем эти динамические данные jasmine-data и albert-data, чтобы было понятнее.

Затем в (1) я передаю jasmine указатель на albert-data, а в (2) albert хранит указатель на jasmine-data. Это все равно что дать каждому игроку компаньона.

Когда jasmine выходит из области видимости в конце программы, ее деструктор не может очистить память: все еще есть один умный указатель, указывающий на jasmine-data, это albert→companion. Аналогично, когда albert выходит из области видимости в конце программы, его деструктор не может очистить память: ссылка на albert-data все еще живет через jasmine→companion. В этот момент программа просто завершается, не освободив память: утечка памяти во всем ее великолепии. Если вы запустите приведенный выше фрагмент, то заметите, что ~Player () никогда не будет вызван.

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

Понимание std: weak_ptr: поверхностный вариант


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

Этот умный указатель полезен для решения некоторых раздражающих проблем, которые нельзя решить с помощью необработанных указателей. Вскоре мы увидим, как это сделать.

Как создать std: weak_ptr

Вы можете создать std: weak_ptr только из std: shared_ptr или другого std: weak_ptr. Например:

std::shared_ptr p_shared = std::make_shared(100);
std::weak_ptr   p_weak1(p_shared);
std::weak_ptr   p_weak2(p_weak1);


В приведенном выше примере p_weak1 и p_weak2 указывают на одни и те же динамические данные, принадлежащие p_shared, но счетчик ссылок не растет.

std: weak_ptr в действии

std: weak_ptr является своего рода инспектором дляstd: shared_ptr от которого он зависит. Вы должны сначала преобразовать его в std: shared_ptr с помощью метода lock () если вы действительно хотите работать с реальным объектом:

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


Конечно, p_shared_orig может быть нулевым в случае, если p_shared был удален в другом месте.

std: weak_ptr решает проблемы

С помощью std: weak_ptr очень легко решить проблему висящих указателей — тех, которые указывают на уже удаленные данные. Он предоставляет метод expired (), который проверяет, был ли объект, на который ссылается ссылка, уже удален. Если expired () == true, исходный объект был где-то удален, и вы можете действовать соответствующим образом. Это то, что вы не можете сделать с необработанными указателями.

Как я уже говорил, std: weak_ptr также используется для разрыва циклической ссылки. Давайте вернемся к примеру Player, приведенному выше, и изменим переменную-член с std: shared_ptr companion на std: weak_ptr companion. В данном случае мы использовали std: weak_ptr для устранения запутанного владения. Фактически имкющиеся динамически выделяемые данные остаются в основном теле, в то время как каждый Player теперь имеет слабую ссылку на них. Запустите код с этим изменением, и вы увидите, что деструктор вызывается дважды, правильно.

Заключительные заметки и мысли об умных указателях


В этой статье я хотел дать обзор различных типов умных указателей в C++ и описать их свойства. Давайте закончим этот обзор, высказав еще некоторые мысли.

Мне нравятся умные указатели. Должен ли я навсегда избавиться от new/delete?

Иногда вы действительно хотите полагаться на двойников new/delete, например:

  • когда вам нужно кастомное удаление, как мы видели ранее, когда мы добавили поддержку массивов в std: shared_ptr;
  • когда вы пишете собственные контейнеры и хотите вручную управлять памятью;
  • с помощью так называемой конструкции in-place, более известной как placement new: новый способ создания объекта на уже выделенной памяти. Более подробная информация здесь.


Работают ли умные указатели медленнее, чем обычные?

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

Рациональное обоснование std: make_unique и std: make_shared

Этот альтернативный способ построения умных указателей дает два преимущества. Во-первых, он позволяет нам забыть о ключевом слове new. При работе с умными указателями мы хотим избавиться от гнусной комбинации new/delete, верно? Во-вторых, это делает ваш код защищенным от исключений. Рассмотрим вызов функции, принимающей на вход два умных указателя, следующим образом:

void function(std::unique_ptr(new A()), std::unique_ptr(new B())) { ... }


Предположим, что new A () выполняется успешно, но new B () выбрасывает исключение: вы ловите его, чтобы возобновить нормальное выполнение программы. К сожалению, стандарт C++ не требует, чтобы объект A был уничтожен, а его память высвобождена: память тихо утекает, и нет способа ее очистить. Обернув A и B в std: make_unique, вы будете уверены, что утечка не произойдет:

void function(std::make_unique(), std::make_unique()) { ... }


Дело в том, что std: make_unique и std: make_unique теперь являются временными объектами, а очистка временных объектов правильно указана в стандарте C++: их деструкторы будут вызваны и память освобождена. Поэтому, если есть возможность, всегда предпочитайте выделять объекты с помощью std: make_unique и std: make_shared.

Источники


cppreference.com — std: unique_ptr
cppreference.com — std: shared_ptr
cppreference.com — std: make_shared
cppreference.com — std: weak_ptr
Wikipedia — Smart pointer
Rufflewind — A basic introduction to unique_ptr
IBM — Stack unwinding
Herb Sutter — GotW #102: Exception-Safe Function Calls
StackOverflow — Advantages of using std: make_unique over new operator
StackOverflow — shared_ptr to an array: should it be used?
StackOverflow — When is std: weak_ptr useful?
StackOverflow — How to break shared_ptr cyclic reference using weak_ptr

© Habrahabr.ru