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


Указатели в языках 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<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_ptrs с помощью специальной функции std::make_unique, вот так:

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

Например:

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::unique_ptr в действии

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

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

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

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

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

void compute(std::unique_ptr<int[]> p) { ... } 

int main()
{
    std::unique_ptr<int[]> ptr = std::make_unique<int[]>(1024);
    std::unique_ptr<int[]> 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<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");

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

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

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

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

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

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

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

void compute()
{
  std::shared_ptr<int> ptr = std::make_shared<int>(100);
  // ptr.use_count() == 1
  std::shared_ptr<int> 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<Player> companion;
  ~Player() { std::cout << "~Player\n"; }
};

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

  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<int> p_shared = std::make_shared<int>(100);
std::weak_ptr<int>   p_weak1(p_shared);
std::weak_ptr<int>   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<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();
//

Конечно, 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<A>(new A()), std::unique_ptr<B>(new B())) { ... }

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

void function(std::make_unique<A>(), std::make_unique<B>()) { ... }

Дело в том, что 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

Комментарии (20)


  1. Videoman
    21.12.2022 17:49

    Дополнения к std::shared_ptr и std::make_shared:
    1. Без объяснения как на практике устроен std::shared_ptr, невозможно понять что за сценой присутствует control block и без std::make_shared у нас появиться дополнительный уровень косвенности и следственно лишнее выделение памяти под него. std::make_shared объединяет эти два выделения, делая создание и удалении быстрее.
    2. std::weak_ptr естественно делает увеличение счетчика ссылок, но это отдельный счетчик, который также находится в control block-е.


    1. skozharinov
      21.12.2022 18:10

      del


    1. monah_tuk
      22.12.2022 01:17
      +1

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


      1. Videoman
        22.12.2022 01:24

        Всё верно. Это обратное следствие. Как всегда дилемма: скорость-память.

        Также ещё из минусов make_shared: custom deleter не задать, aliasing constructor не вызвать.


  1. tenzink
    21.12.2022 18:43

    Я бы ещё добавил рекомендацию рассматривать std::unique_ptr как умный указатель "по-умолчанию". Неоднократно видел неоправданное использование std::shared_ptr.


    1. ReadOnlySadUser
      21.12.2022 19:00
      +10

      Давайте будем честными - все мы видели очень мало оправданного использования std::shared_ptr :)


  1. kovserg
    21.12.2022 19:15

    А на что вообще могут указывать указатели в C++?


    1. gudvinr
      22.12.2022 03:13
      +1

      Указатель — это просто адрес ячейки памяти. Он может указывать в принципе на всё. Не важно, существует это или нет, что по этому адресу лежит если есть и т.д.


      1. kovserg
        22.12.2022 19:28

        Только не C++

        struct A {
        	int x, y:1;
        	void mf() {}
        	virtual void vmf() {}
        	static  void sf() {}
        	A() {}
        	~A() {}
        };
        
        int main() {
        	A a, aa[2];
        	auto lf1=[&](){ a.x=1; };
        	auto lf2=[&](){ a.x=2; };
        	label:
        
        	auto p01=&a;
        	auto p02=&1[aa];
        	auto p03=&a.x; 
        	// auto p04=&a.y;   // attempt to take address of bit-field
        
        	auto p05=&A::x;
        	// auto p06=&A::y;  // invalid pointer to bit-field
        	auto p07=&A::sf;
        	auto p08=&A::mf;
        	auto p09=&A::vmf;
        	// auto p10=&A::A;  // taking address of constructor
        	// auto p11=&A::~A; // taking address of destructor
        	// auto p12=&label; // was not declared in this scope
        
        	auto p13=&lf1;
        	p13=&lf1;
        	// p13=&lf2;// cannot convert ‘main()::<lambda()>*’ to ‘main()::<lambda()>*’ 
        	return 0;
        }
        

        А еще есть отдельный тип UB указателей не представимых в машинных кодах :)


        1. gudvinr
          22.12.2022 20:19

          Часть из того, что вы написали, это не репрезентативные примеры.


          Указатель на bit field невозможен просто потому, что bit field меньше, чем минимальный размер ячейки памяти, т.е. 1байт. Это не противоречит утверждению о том, что "указатель — это просто адрес ячейки памяти". Но теоретически можно получить указатель на область памяти, где будет храниться этот bit field. Указывать он туда будет, но никто не говорит, что это будет полезный указатель.


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


          Указатель на label получить можно, но есть нюанс.


          С лямбдами в принципе проблем никаких, это просто разные функции, с разными типами. То что в ошибке указаны одинаковые сигнатуры — это проблема сообщений об ошибки, такие лямбды могли бы быть названы как main()::<lambda()>[1] и main()::<lambda()>[2]. Проблемы не будет, если capture list отсутствуют, скорее всего. Но не уверен.
          Но это на самом деле не важно с позиции указателей, потому что проблемы с существованием указателей нет.
          Если задать void * p13 вместо auto, ошибка исчезает.


          1. kovserg
            22.12.2022 22:04

            Указатель на bit field невозможен просто потому, что bit field меньше, чем минимальный размер ячейки памяти, т.е. 1байт

            Да хоть 32бита можно сделать, всё равно нельзя: wandbox.org/permlink/AAsG87xzqrrBXQmG

            И что по поводу p05,p08,p09, которые уже не просто адрес ячейки памяти.

            ps: &&label поддерживают не все компиляторы.

            pps: я к тому что единообразия нет.


        1. Tsvetik
          22.12.2022 20:45

          А что вот это такое?
          auto p02=&1[aa];


          1. kovserg
            22.12.2022 22:06

            Это просто адрес элемента массива aa с индексом 1. Тоже самое что (aa+1) или &(aa[1]).


  1. Tuxman
    21.12.2022 19:33
    +6

    std::unique_ptr<Object> p3 = std::make_unique<Object>("Lamp");

    Зачем там длинно писать и указывать тип два раза, когда make_unique уже идёт с типом.

    auto p3 = std::make_unique<Object>("Lamp");


  1. tixo
    22.12.2022 11:28

    Вроде проблема с утечкой памяти была исключена ещё в C++17.
    Единственная причина использовать make_shared это экономия ресурсов (и то не всегда). А в чём преимущества make_unique?


    1. ReadOnlySadUser
      22.12.2022 12:52

      В удобной семантике и консистентности с make_shared? Ну и просто более красиво как-то смотрится, чем

      auto ptr = std::unique_ptr{ new Object };

      Мне лично проще думать "make unique Object", чем "construct unique_ptr which holds Object".


    1. Ritan
      24.12.2022 10:01

      В каком смысле проблема с утечкой была решена? Циклические ссылки в рамках подсчёта ссылок решить нельзя(без костылей)


      1. tixo
        24.12.2022 14:04

        Мой коммент относился к:
        void function(std::unique_ptr<A>(new A()), std::unique_ptr<B>(new B())) { ... }
        "Предположим, что new A() выполняется успешно, но new B() выбрасывает исключение: вы ловите его, чтобы возобновить нормальное выполнение программы. К сожалению, стандарт C++ не требует, чтобы объект A был уничтожен, а его память высвобождена: память тихо утекает, и нет способа ее очистить."
        IMHO в C++17 компилятор не имеет право перемешивать этапы инициализации параметров ф-ии.
        "In a function call, value computations and side effects of the initialization of every parameter are indeterminately sequenced with respect to value computations and side effects of any other parameter." [ Order of evaluation - cppreference.com ] Поэтому описанная утечка памяти не произойдёт и использование make_shared/make_unique в этом случае не обязательно.


  1. tixo
    22.12.2022 14:03
    +1

    Ещё хочу напомнить про незаслуженно забытый aliasing constructor для shared_ptr.
    Это мегафича позволяющая shared_ptr-у ссылаться и владеть совершенно разными объектами! Часто требуется передать указатель на часть чего-то большего, и при этом гарантировать, что это большее не удалится, пока мы работаем с его частью, shared_ptr позволяет это.


  1. tixo
    23.12.2022 20:06

    Бывает надо передать в shared_ptr указатель без права владения. В этом случае тоже помогает aliasing constructor, но не уверен, что здесь нет нарушения какого-нибудь правила стандарта.
    Может кто прокомментировать следующий код?

    #include <cstdio>
    #include <memory>
    
    void foo(std::shared_ptr<int> sp) {printf("*sp = %d\n", *sp);}
    
    int main()
    {
        int n = 777;
    // redundantly:	std::shared_ptr<int> sp{ &n, [](int*) {} };
        std::shared_ptr<int> sp{ std::shared_ptr<void>{}, &n };
    
        foo(sp);
    }

    https://godbolt.org/z/x6n6aGrxc