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

  Юрий Ворон  | 

    | 

  Обновл. 23 Дек 2018  | 

 697

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

Хотя код выше кажется довольно простым, можно очень легко забыть в конце освободить память, выделенную для 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 (11 оценок, среднее: 5,00 из 5)
Загрузка...

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

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