Урок №197. Розумні вказівники і Семантика переміщення

  Юрій  | 

  Оновл. 30 Бер 2021  | 

 32

На цьому уроці ми розглянемо, що таке розумні вказівники і семантика переміщення в мові С++.

Проблема

Розглянемо функцію, в якій динамічно виділяється змінна:

Хоча код, наведений вище, здається досить простим, можна дуже легко забути в кінці звільнити пам’ять, виділену для 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 Зірок (Немає Оцінок)
Loading...

Залишити відповідь

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