Как C++ управляет памятью?
Когда мы говорим про управление памятью в C++, мы неизменно обращаемся к термину storage duration (длительность хранения). Storage duration – это свойство объекта, которое описывает, когда тот попадает в память и когда её освобождает.
В C++ существует четыре вида [1] storage duration:
- Автоматическая storage duration. Когда управление входит в область видимости объекта (также известную как scope [2]), он размещается в автоматической памяти, зачастую реализованной в виде стека; когда управление покидает эту область, вызывается деструктор и память освобождается.
- Статическая связана с использованием спецификаторов
static
иextern
. Объекты со статической storage duration создаются при запуске программы и удаляются при её завершении. - Storage duration потока устанавливается спецификатором
thread_local
. Имеющие эту storage duration объекты создаются при старте потока и удаляются при его завершении. - Динамическая storage duration неразрывно связана с использованием ключевых слов
new
иdelete
.
Можно сказать, что в случае с автоматической storage duration память освобождается автоматически, а в случае с динамической – вручную. Почему же тогда не использовать всегда автоматическую память?
- Чтобы использовать стек, необходимо заранее на этапе компиляции знать, как много памяти понадобится, а это известно не всегда.
- Иногда надо, чтобы объект оставался в памяти и после выхода из области видимости в которой был создан, а в случае размещения объекта на стеке это невозможно.
Чтобы обойти эти ограничения, необходимо использовать динамическую память про использование которой мы и будем сегодня говорить.
Что такое умные указатели и зачем они нужны?
Используем динамическую память, отлично. Теперь объекты могут покидать область видимости, где были созданы, и иметь определяемый во время выполнения размер – жизнь стала налаживаться и жаловаться как будто не на что.
Предлагаем взглянуть на следующий фрагмент кода:
#include <memory>
). Для краткости во фрагментах кода внутри статьи это было опущено.На первый взгляд, здесь всё хорошо, но есть нюансы:
- Если
func()
выбросит исключение, то управление не дойдёт доdelete
и память не освободится. - Если
func()
вернёт true, то после выполненияfunc2()
управление покинет функцию, но память не освободится, т.к. автор кода забыл добавитьdelete
внутрь условия. - Если бы автор забыл
delete
также в 6-й строке, память тоже не освободилась бы.
new/delete
. Как – увидим ниже.Помимо проблем непосредственно с new/delete
, существует проблема и с простыми указателями. Она заключается в сложности разделения указателей, которые владеют объектом (owning pointer), а значит, и ответственны за вызов new/delete
, и указателей, которые используют объект (non owning pointer).
При использование простых указателей (также известных как raw pointers) невозможно без дополнительных комментариев или дополнительного изучения кода определить, какой указатель объектом владеет, а какой – только использует. Взгляните на следующую декларацию:
Главная проблема здесь, что тому, кто будет вызывать функцию, совершенно неясно, должен он вызвать delete
для возвращаемого указателя или за это ответственен код где-то в другой части программы. Иначе говоря, здесь не видно, является указатель владеющим или использующим.
Все вышеназванные проблемы изящно решаются умными указателями. Умные указатели в C++ – это не что-то магическое, встроенное в синтаксис языка, а не более чем набор классов из стандартной библиотеки. Разберёмся с ними один за одним.
std::unique_ptr
Первым умным указателем, с которым мы познакомимся, будет std::unique_ptr
[3]. Он ссылается на объект в динамической памяти и при выходе из области видимости уничтожает хранимый объект. Взглянем на пример кода ниже:
Когда std::unique_ptr
выходит из области видимости, утечки памяти не происходит, потому что в своем деструкторе умный указатель вызывает delete
для объекта на который ссылается, высвобождая тем самым память.
new/delete
, они лишь позволяют программисту не делать этого и, как следствие, защищают его от ошибок.От проблем с внезапными исключениями использующих умные указатели (в частности std::unique_ptr
) программистов защищает развёртывание стека (stack-unwinding [4]).
Подробное рассмотрение этого механизма С++ выходит за рамки статьи, но главное, что нужно знать о нём – если на стеке был создан объект, а после этого было выброшено исключение, C++ гарантированно вызовет деструктор для этого объекта. Это значит, что если мы обновим код в листинге 3 так, чтобы он использовал умные указатели, то избавимся от всех трёх вышеназванных проблем:
Теперь программисту не надо ставить delete
, а в случае, если одна из функций выбросит исключение, развёртывание стека защитит нас от утечки памяти.
И всё бы хорошо, но мы по-прежнему используем new
. Чтобы правило никогда не использовать new/delete
соблюдалось, была придумана функция std::make_unique
[5], которая позволяет создавать std::unique_ptr
, но с несколькими дополнительными фичами:
- Теперь правило никогда не использовать
new/delete
может быть полностью соблюдено. std::make_unique
позволяет не писать имя класса дважды:
std::make_unique
решает проблему неопределённого порядка вычисления аргументов (unspecified evaluation order). Рассмотрим следующий фрагмент кода:
Здесь возможен следующий порядок вычисления аргументов:
new A()
new B()
std::unique_ptr<A>(...)
std::unique_ptr<B>(...)
Если при вызове new B()
произойдет исключение, занятая при вызове new A()
память не освободится, потому что умный указатель для этого объекта ещё не был создан, а delete
никто вызывать и не собирался. Использование std::make_unique
решает подобные проблемы.
std::unique_ptr
используется тогда, когда объект должен иметь только одного владельца, однако мы можем передать право на владение кому-то другому. Чтобы это сделать, необходимо использовать std::move
[6]. Рассмотрим код:
Раз std::unique_ptr
нельзя копировать, становится непонятно, как разрешить кому-то использовать указываемый объект, не передавая ему право на владение. Очень просто: нужно всего лишь использовать простой указатель на созданный объект, который можно получить с помощью метода get()
, как в листинге 10. Однако будьте внимательны: не допускайте ситуации, когда std::unique_ptr
вместе с хранимым объектом были уничтожены, но где-то по-прежнему находится простой указатель указывающий на невалидную (уже) область памяти.
Когда в коде используются простые указатели и умные, сразу становится понятно, где указатель владеет объектом, а где – только использует.
get()
простые указатели от любых других простых указателей, был придуманstd::experimental::observer_ptr
[7], но на данный момент он ещё не вошёл в стандарт.std::unique_ptr
– это умный указатель, о котором вы должны подумать в первую очередь, когда решите разместить что-нибудь в динамической памяти. Это ваш умный указатель по умолчанию.
std::shared_ptr и std::weak_ptr
std::unique_ptr
и правда хорош, но он не поможет в ситуации, когда мы хотим, чтобы несколько объектов работали с одним общим ресурсом и чтобы в момент, когда все эти объекты были выгружены из памяти, за ненадобностью автоматически выгрузился бы и ресурс.
В такой ситуации необходимо использовать std::shared_ptr
[8]. Этот умный указатель разрешает объекту иметь несколько владельцев, а когда все владельцы уничтожаются, уничтожается и объект. Такое поведение достигается за счёт наличия специального счётчика ссылок внутри std::shared_ptr
. Каждый раз, когда такой указатель копируется, счётчик инкрементируется, а когда один из указателей уничтожается – декрементируется. В момент, когда счётчик достигает нуля, объект уничтожается. Посмотрим на код:
std::make_shared
является аналогом std::make_unique
для std::shared_ptr
.
Чтобы разорвать цикличность, необходимо использовать std::weak_ptr
[9]. Это фактически умный указатель non owning, предназначенный для использования именно с std::shared_ptr
. Копирование std::weak_ptr
не увеличивает счётчик в std::shared_ptr
, а значит и не защищает объект от уничтожения. При этом всегда имеется возможность проверить, существует ли ещё объект, на который ссылается std::weak_ptr
, или нет. Внимание на код:
Вообще говоря, std::weak_ptr
необходимо использовать всегда, когда надо ссылаться на управляемый std::shared_ptr
объект, но не защищать его от уничтожения.
std::auto_ptr
[10]: никогда не использовать std::auto_ptr
. Этот умный указатель был помечен как устаревший в C++ 11, а в C++ 17 был полностью удалён из стандарта языка.Выводы
Каждый программист на C++ должен уметь использовать умные указатели. Умные указатели – это ваш способ управления динамической памятью по умолчанию. std::unique_ptr
– это ваш умный указатель по умолчанию. Использование умных указателей не противоречит использованию простых указателей, в случае, если последние используют объекты, а не владеют ими. std::auto_ptr
– зло.
Источники
- https://en.cppreference.com/w/cpp/language/storage_duration
- https://en.cppreference.com/w/cpp/language/scope
- https://en.cppreference.com/w/cpp/memory/unique_ptr
- https://en.cppreference.com/w/cpp/language/throw
- https://en.cppreference.com/w/cpp/memory/unique_ptr/make_unique
- https://en.cppreference.com/w/cpp/utility/move
- https://en.cppreference.com/w/cpp/experimental/observer_ptr
- https://en.cppreference.com/w/cpp/memory/shared_ptr
- https://en.cppreference.com/w/cpp/memory/auto_ptr
- https://en.cppreference.com/w/cpp/memory/weak_ptr
Комментарии