Урок №189. Умные указатели и Семантика перемещения

  Юрий  | 

  |

  Обновл. 15 Сен 2021  | 

 66108

 ǀ   8 

На этом уроке мы рассмотрим, что такое умные указатели и семантика перемещения в языке С++.

Проблема

Рассмотрим функцию, в которой динамически выделяется переменная:

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

Или из-за генерации исключения:

В обоих случая функция завершает свое выполнения до того, как произойдет удаление ptr. Следовательно, мы получим утечку памяти, и так будет повторяться до тех пор, пока будет вызываться эта функция и пока будет срабатывать её досрочное завершение (из-за генерации исключения, досрочного return-а или чего-либо другого). По сути, такие проблемы возникают из-за того, что указатели не имеют встроенного механизма самостоятельной очистки памяти после себя.

Умные указатели


Одна из лучших особенностей классов — это деструкторы, которые автоматически выполняются при выходе объекта класса из области видимости. При выделении памяти в конструкторе класса, вы можете быть уверены, что эта память будет освобождена в деструкторе при уничтожении объекта класса (независимо от того, выйдет ли он из области видимости, будет ли явно удален и т.д.). Это лежит в основе парадигмы программирования RAII.

Так что же, выходом является использование класса для управления указателями и выполнения соответствующей очистки памяти? Да, именно так!

Например, рассмотрим класс, единственными задачами которого является хранение и «управление» переданным ему указателем, а затем корректное освобождение памяти при выходе объекта класса из области видимости. До того момента, пока объекты этого класса создаются как локальные переменные, мы можем гарантировать, что, как только они выйдут из области видимости (независимо от того, когда или как), переданный указатель будет уничтожен.

Вот первый набросок:

Результат выполнения программы:

Item acquired
Item destroyed

Рассмотрим детально, как работают эти программа и класс. Сначала мы динамически выделяем объект класса Item и передаем его в качестве параметра нашему шаблону класса Auto_ptr1. С этого момента объект item класса Auto_ptr1 владеет выделенным объектом класса Item (Auto_ptr1 имеет композиционную связь с m_ptr). Поскольку item объявлен в качестве локальной переменной и имеет область видимости блока, он выйдет из области видимости после завершения выполнения блока, в котором находится, и будет уничтожен. А поскольку это объект класса, то при его уничтожении будет вызван деструктор Auto_ptr1. Этот деструктор и обеспечит удаление указателя Item, который он хранит!

До тех пор, пока объект класса Auto_ptr1 определен как локальная переменная (с автоматической продолжительностью жизни, отсюда и часть «Auto» в имени класса), Item гарантированно будет уничтожен в конце блока, в котором он объявлен, независимо от того, как этот блок (функция main()) завершит свое выполнение (досрочно или нет).

Такой класс называется умным указателем. Умный указатель — это класс, предназначенный для управления динамически выделенной памятью и обеспечения освобождения (удаления) выделенной памяти при выходе объекта этого класса из области видимости. Соответственно, встроенные (обычные) указатели иногда еще называют «глупыми указателями», так как они не могут выполнять после себя очистку памяти.

Теперь вернемся к нашему примеру с myFunction() и покажем, как использование класса умного указателя сможет решить нашу проблему:

Если пользователь введет ненулевое целое число, то результат выполнения программы:

Item acquired
Enter an integer: 7
Hi!
Item destroyed

Если же пользователь введет ноль, то функция myFunction() завершит свое выполнение досрочно, и мы увидим:

Item acquired
Enter an integer: 0
Item destroyed

Обратите внимание, даже в случае, когда пользователь введет ноль, и функция завершит свое выполнение досрочно, Item по-прежнему будет корректно удален.

Поскольку переменная ptr является локальной переменной, то она уничтожается при завершении выполнения функции (независимо от того, как это будет сделано: досрочно или нет). И поскольку деструктор Auto_ptr1 выполняет очистку Item, то мы можем быть уверены, что Item будет корректно удален.

Критический недостаток

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

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

(Напряжённая музыка)

Хорошо, время истекло.

Мы не будем сейчас вам это говорить, мы сейчас вам это покажем:

Результат выполнения программы:

Item acquired
Item destroyed
Item destroyed

Очень вероятно (но не обязательно), что в нашей программе произойдет сбой именно в этот момент. Нашли проблему? Поскольку мы не предоставили конструктор копирования или свой оператор присваивания (перегрузку оператора присваивания), то язык C++ предоставил их самостоятельно. И то, что он предоставил, выполняет поверхностное копирование. Поэтому, когда мы инициализируем item2 значением item1, оба объекта класса Auto_ptr1 указывают на один и тот же Item. Когда item2 выходит из области видимости, он удаляет Item, оставляя item1 с висячим указателем. Когда же item1 отправляется на удаление своего (уже удаленного) Item, происходит «Бум!».

Вы получите ту же проблему, используя следующую функцию:

В этой программе item1 передается по значению в параметр item функции passByValue(), что приведет к дублированию указателя Item. Мы вновь получим «Бум!».

Так быть не должно. Что мы можем сделать?

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

Но как нам тогда вернуть Auto_ptr1 из функции обратно в caller?

Мы не можем вернуть Auto_ptr1 по ссылке, так как локальный Auto_ptr1 будет уничтожен в конце функции, и в caller передастся ссылка, которая будет указывать на удаленную память. Передача по адресу имеет ту же проблему. Мы могли бы вернуть указатель item по адресу, но мы можем забыть удалить item, что является основным смыслом использования умных указателей. Так что возврат Auto_ptr1 по значению — это единственная опция, которая имеет смысл, но тогда мы получим поверхностное копирование, дублирование указателей и «Бум!».

Другой вариант — переопределить конструктор копирования и оператор присваивания для выполнения глубокого копирования. Таким образом, мы, по крайней мере, гарантированно избежим дублирования указателей (которые будут указывать на один и тот же объект). Но глубокое копирование может быть затратной операцией (а также нежелательной или даже невозможной), и мы не хотим делать ненужные копии объектов просто для того, чтобы вернуть Auto_ptr1 из функции. Кроме того, присваивание или инициализация глупого указателя не копирует объект, на который указывает, так почему же мы ожидаем, что умные указатели будут вести себя по-другому?

Что же делать?

Семантика перемещения


А что, если бы наш конструктор копирования и оператор присваивания не копировали указатель (семантика копирования), а передавали владение указателем из источника в объект назначения? Это основная идея семантики перемещения. Семантика перемещения означает, что класс, вместо копирования, передает право собственности на объект.

Давайте обновим наш класс Auto_ptr1 с использованием семантики перемещения:

Результат выполнения программы:

Item acquired
item1 is not null
item2 is null
Ownership transferred
item1 is null
item2 is not null
Item destroyed

Обратите внимание, перегруженный operator= передает право собственности на m_ptr от item1 к item2! Следовательно, у нас не выполняется дублирования указателей, и всё аккуратно очищается (удаляется).

std::auto_ptr и почему его лучше не использовать

Теперь самое время поговорить о std::auto_ptr. std::auto_ptr, представленный в C++98, был первой попыткой в языке C++ сделать стандартизированный умный указатель. В std::auto_ptr решили реализовать семантику перемещения точно так же, как это сделано в классе Auto_ptr2.

Однако, std::auto_ptr (как и наш класс Auto_ptr2) имеет ряд проблем, которые делают его использование опасным.

Во-первых, поскольку std::auto_ptr реализовывает семантику перемещения через конструктор копирования и оператор присваивания, то передача std::auto_ptr в функцию по значению приведет к тому, что ваш Item будет перемещен в параметр функции и, следовательно, будет уничтожен в конце функции, когда параметры этой функции выйдут из области видимости (в нашем классе Auto_ptr2 передача выполняется по ссылке). Затем, когда вы попытаетесь получить доступ к аргументу std::auto_ptr из caller-а (не осознавая, что он был передан и удален), вы внезапно выполните разыменование нулевого указателя. Бум!

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

Наконец, std::auto_ptr не очень хорошо работает со многими другими классами из Стандартной библиотеки С++ (особенно с контейнерными классами и классами алгоритмов). Это происходит из-за того, что классы Стандартной библиотеки С++ предполагают, что, когда они копируют элемент, они фактически выполняют копирование, а не перемещение.

Из-за вышеупомянутых недостатков в C++11 перестали использовать std::auto_ptr, а в C++17 планировали удалить его из Стандартной библиотеки С++.

Правило: std::auto_ptr устарел и не должен использоваться. Используйте вместо него std::unique_ptr или std::shared_ptr.

Что дальше?


Основная проблема с std::auto_ptr заключается в том, что до C++11 в языке C++ просто не было механизма, позволяющего отличить «семантику копирования» от «семантики перемещения». Переопределение семантики копирования для реализации семантики перемещения привело к неопределенным результатам и непреднамеренным ошибкам. Например, вы можете написать item1 = item2 и вообще не знать, изменится ли item2 или нет!

По этой причине в C++11 понятие «перемещение» было определено формально, в следствии чего в язык С++ было добавлено ​​«семантику перемещения», чтобы должным образом отличать копирование от перемещения. Теперь, когда вы понимаете, чем семантика перемещения может быть полезной, мы рассмотрим её детально на следующих уроках.

В C++11 std::auto_ptr был заменен кучей других типов умных указателей:

   std::scoped_ptr;

   std::unique_ptr;

   std::weak_ptr;

   std::shared_ptr.

Мы также рассмотрим два самых популярных из них: std::unique_ptr (который является прямой заменой std::auto_ptr) и std::shared_ptr.

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (223 оценок, среднее: 4,90 из 5)
Загрузка...

Комментариев: 8

  1. Александр:

    Уверен, что это предложение "Во-вторых, std::auto_ptr всегда удаляет свое содержимое, используя оператор delete, который не работает с массивами. Это означает, что std::auto_ptr не будет правильно работать с динамическими массивами, поскольку использует неправильный тип удаления." поймут далеко не все 🙂
    Почему не написать четко, что речь идет о разнице между delete ptr
    и delete [ ] ptr ??? И почему не разъяснить это? Переводчик должен это понимать. А так ничего. На 4.

    1. Алексей Л.:

      Наверное потому, что этот нюанс подробно рассматривался в предыдущих уроках?

  2. Андрей:

    Интересно, а можно ли помимо самого указателя, в умном указателе держать счетчик (количество держателей указателя), который бы при создании, присваивании увеличивалсяч на 1, а при работе деструктора уменьшался на 1. В, общем так, как реализовывается подсчет ссылок в COM объектах. И когда доходит до 0 — должно происходить освобождение памяти.

    1. Ежевика топор:

    2. Алексей:

      не читая комментариев пришла в голову та же идея:

  3. kmish:

    Пример с использованием std::auto_ptr и его проблема:

    Результат:

    Item acquired
    Item acquired
    item1 is 2
    item2 is 5
    Item destroyed
    Ownership transferred
    item2 is 2
    Item destroyed

    1. Анастасия:

      Присваивание у Вас в примере — это и есть использование std::auto_ptr ? То есть он никаким особым образом не вызывается? Из урока это тоже не понятно.

      1. Анастасия:

        вопрос отпал, не обратила внимания, что оба item — умные указатели std::auto_ptr

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

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