На цьому уроці ми розглянемо, що таке розумні вказівники і семантика переміщення в мові С++.
Проблема
Розглянемо функцію, в якій динамічно виділяється змінна:
|
1 2 3 4 5 6 7 8 |
void myFunction() { Item *ptr = new Item; // Item-ом може бути структура або клас // Робимо що-небудь з ptr тут delete ptr; } |
Хоча код, наведений вище, здається досить простим, можна дуже легко забути в кінці звільнити пам’ять, виділену для ptr. Навіть якщо ви не забудете це зробити, існує безліч причин, через які ptr не буде видалено. Це може статися через передчасне повернення за допомогою оператора return:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> void myFunction() { Item *ptr = new Item; int a; std::cout << "Enter an integer: "; std::cin >> a; if (a == 0) return; // функція виконує передчасне повернення, внаслідок чого ptr не буде видалено! // Робимо що-небудь з ptr тут delete ptr; } |
Або через генерацію винятку:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> void myFunction() { Item *ptr = new Item; int a; std::cout << "Enter an integer: "; std::cin >> a; if (a == 0) throw 0; // генерується виняток > функція передчасно завершує своє виконання > ptr не видаляється! // Робимо що-небудь з ptr тут delete ptr; } |
В обох випадках функція завершує своє виконання до того, як відбудеться видалення ptr. Отже, ми отримаємо витік пам’яті, і так повторюватиметься до тих пір, поки викликатиметься ця функція і поки спрацьовуватиме її передчасне завершення (через генерацію винятку, передчасне виконання оператора return або через щось інше). По суті, такі проблеми виникають через те, що вказівники не мають вбудованого механізму самостійного виконання очищення пам’яті після себе.
Розумні вказівники
Одна з найкращих особливостей класів — це деструктори, які автоматично виконуються при виході об’єкта класу з області видимості. При виділенні пам’яті в конструкторі класу, ви можете бути впевнені, що ця пам’ять буде звільнена в деструкторі при знищенні об’єкта класу (незалежно від того, чи вийде він з області видимості, чи буде явно видалений тощо). Це лежить в основі ідіоми програмування RAII.
Так що ж, рішенням є використання класу для управління вказівниками і виконання відповідного очищення пам’яті? Так, саме так!
Наприклад, розглянемо клас, єдиними завданнями якого є зберігання та «управління» переданим йому вказівником, а потім коректне звільнення пам’яті при виході об’єкта класу з області видимості. До того моменту, поки об’єкти цього класу створюються як локальні змінні, ми можемо гарантувати, що, як тільки вони вийдуть з області видимості (незалежно від того, коли та як), переданий вказівник буде знищено.
Ось перший варіант:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#include <iostream> template<class T> class Auto_ptr1 { T* m_ptr; public: // Отримуємо вказівник для "володіння" через конструктор Auto_ptr1(T* ptr=nullptr) :m_ptr(ptr) { } // Деструктор подбає про видалення вказівника ~Auto_ptr1() { delete m_ptr; } // Виконуємо перевантаження оператора розіменування і оператора ->, щоб мати можливість використовувати Auto_ptr1 як m_ptr T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } }; // Клас для перевірки працездатності вищенаведеного коду class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } }; int main() { Auto_ptr1<Item> item(new Item); // динамічне виділення пам'яті // ... але ніякого явного delete тут не потрібно // Також зверніть увагу на те, що Item-у в кутових дужках не потрібен символ *, оскільки це надається шаблоном класу return 0; } // item виходить з області видимості тут і знищує виділений Item замість нас |
Результат виконання програми:
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() і покажемо, як використання класу розумного вказівника зможе вирішити нашу проблему:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
#include <iostream> template<class T> class Auto_ptr1 { T* m_ptr; public: // Отримуємо вказівник для "володіння" через конструктор Auto_ptr1(T* ptr=nullptr) :m_ptr(ptr) { } // Деструктор подбає про видалення вказівника ~Auto_ptr1() { delete m_ptr; } // Виконуємо перевантаження оператора розіменування і оператора ->, щоб мати можливість використовувати Auto_ptr1 як m_ptr T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } }; // Клас для перевірки працездатності вищенаведеного коду class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } void sayHi() { std::cout << "Hi!\n"; } }; void myFunction() { Auto_ptr1<Item> ptr(new Item); // ptr тепер "володіє" Item-ом int a; std::cout << "Enter an integer: "; std::cin >> a; if (a == 0) return; // передчасне повернення функції // Використання ptr ptr->sayHi(); } int main() { myFunction(); return 0; } |
Якщо користувач введе ненульове ціле число, то результат виконання програми:
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, наведений вище, має критичну помилку, яка ховається за деяким автоматично згенерованим кодом. Перш ніж продовжити, подивіться, чи зможете ви визначити, що це за помилка.
Підказка: Подумайте, які частини класу генеруються автоматично, якщо ви їх не надаєте самостійно.
(Напружена музика)
Добре, час минув!
Ми не будемо зараз вам про це говорити, ми зараз вам це покажемо:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
#include <iostream> // Шаблон класу той же, що і в прикладі, наведеному вище template<class T> class Auto_ptr1 { T* m_ptr; public: Auto_ptr1(T* ptr=nullptr) :m_ptr(ptr) { } ~Auto_ptr1() { delete m_ptr; } T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } }; class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } }; int main() { Auto_ptr1<Item> item1(new Item); Auto_ptr1<Item> item2(item1); // в якості альтернативи ви можете не ініціалізувати item2 значенням item1, а просто виконати присвоювання item2 = item1 return 0; } |
Результат виконання програми:
Item acquired
Item destroyed
Item destroyed
Дуже ймовірно (але не обов’язково), що в нашій програмі відбудеться збій саме в цей момент. Знайшли проблему? Оскільки ми не надали конструктор копіювання або свій оператор присвоювання (перевантаження оператора присвоювання), то мова C++ надала їх самостійно. І те, що вона надала, виконує поверхневе копіювання. Тому, коли ми ініціалізуємо item2 значенням item1, обидва об’єкти класу Auto_ptr1 вказують на один і той же Item. Коли item2 виходить з області видимості, він видаляє Item, залишаючи item1 з висячим вказівником. Коли ж item1 відправляється на видалення свого (вже видаленого) Item, відбувається «Бах!».
Ви отримаєте ту ж проблему, використовуючи наступну функцію:
|
1 2 3 4 5 6 7 8 9 10 11 |
void passByValue(Auto_ptr1<Item> item) { } int main() { Auto_ptr1<Item> item1(new Item); passByValue(item1) return 0; } |
У цій програмі item1 передається по значенню в параметр item функції passByValue(), що призведе до дублювання вказівника Item. Ми знову отримаємо «Бах!».
Так бути не повинно. Що ми можемо зробити?
Ми можемо явно визначити і видалити конструктор копіювання з оператором присвоювання, тим самим запобігаючи виконанню будь-якого копіювання. Це також запобіжить виконанню передачі по значенню.
Але як нам тоді повернути Auto_ptr1 з функції назад в caller?
|
1 2 3 4 5 |
??? generateItem() { Item *item = new Item; return Auto_ptr1(item); } |
Ми не можемо повернути Auto_ptr1 по посиланню, тому що локальний Auto_ptr1 буде знищено в кінці функції, і в caller буде передано посилання, яка вказуватиме на видалену пам’ять. Передача по адресі має ту ж саму проблему. Ми могли б повернути вказівник item по адресі, але ми можемо забути видалити item, що є основним сенсом використання розумних вказівників. Тому повернення Auto_ptr1 по значенню — це єдина опція, яка має сенс, але тоді ми отримаємо поверхневе копіювання, дублювання вказівників і «Бах!».
Інший варіант — перевизначити конструктор копіювання і оператор присвоювання для виконання глибокого копіювання. Таким чином, ми, принаймні, гарантовано уникнемо дублювання вказівників (які вказуватимуть на один і той же об’єкт). Але глибоке копіювання може бути витратною операцією (а також небажаною або навіть неможливою), і ми не хочемо робити непотрібні копії об’єктів просто для того, щоб повернути Auto_ptr1 з функції. Крім того, присвоювання або ініціалізація “звичайного” вказівника не копіює об’єкт, на який вказує, так чому ж ми очікуємо, що розумні вказівники вестимуть себе по-іншому?
Семантика переміщення
А що, якби наш конструктор копіювання і оператор присвоювання не копіювали вказівник (семантика копіювання), а передавали володіння вказівником з джерела в об’єкт призначення? Це основна ідея семантики переміщення. Семантика переміщення означає, що клас, замість копіювання, передає право власності на об’єкт.
Давайте оновимо наш клас Auto_ptr1 з використанням семантики переміщення:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include <iostream> template<class T> class Auto_ptr2 { T* m_ptr; public: Auto_ptr2(T* ptr=nullptr) :m_ptr(ptr) { } ~Auto_ptr2() { delete m_ptr; } // Конструктор копіювання, який реалізує семантику переміщення Auto_ptr2(Auto_ptr2& a) // примітка: Посилання не є константним { m_ptr = a.m_ptr; // переміщуємо наш "звичайний" вказівник від джерела до нашого локального об'єкта a.m_ptr = nullptr; // підтверджуємо, що джерело більше не володіє вказівником } // Оператор присвоювання, який реалізує семантику переміщення Auto_ptr2& operator=(Auto_ptr2& a) // примітка: Посилання не є константним { if (&a == this) return *this; delete m_ptr; // підтверджуємо, що видалили будь-який вказівник, який наш локальний об'єкт мав до цього m_ptr = a.m_ptr; // потім переміщуємо наш "звичайний" вказівник з джерела до нашого локального об'єкта a.m_ptr = nullptr; // підтверджуємо, що джерело більше не володіє вказівником return *this; } T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } bool isNull() const { return m_ptr == nullptr; } }; class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } }; int main() { Auto_ptr2<Item> item1(new Item); Auto_ptr2<Item> item2; // почнемо з nullptr std::cout << "item1 is " << (item1.isNull() ? "null\n" : "not null\n"); std::cout << "item2 is " << (item2.isNull() ? "null\n" : "not null\n"); item2 = item1; // item2 тепер є "власником" значення item1, об'єкту item1 присвоюється null std::cout << "Ownership transferred\n"; std::cout << "item1 is " << (item1.isNull() ? "null\n" : "not null\n"); std::cout << "item2 is " << (item2.isNull() ? "null\n" : "not null\n"); return 0; } |
Результат виконання програми:
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.
