На этом уроке мы рассмотрим, что такое умные указатели и семантика перемещения в языке С++.
Проблема
Рассмотрим функцию, в которой динамически выделяется переменная:
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.
Уверен, что это предложение "Во-вторых, std::auto_ptr всегда удаляет свое содержимое, используя оператор delete, который не работает с массивами. Это означает, что std::auto_ptr не будет правильно работать с динамическими массивами, поскольку использует неправильный тип удаления." поймут далеко не все 🙂
Почему не написать четко, что речь идет о разнице между delete ptr
и delete [ ] ptr ??? И почему не разъяснить это? Переводчик должен это понимать. А так ничего. На 4.
Наверное потому, что этот нюанс подробно рассматривался в предыдущих уроках?
Интересно, а можно ли помимо самого указателя, в умном указателе держать счетчик (количество держателей указателя), который бы при создании, присваивании увеличивалсяч на 1, а при работе деструктора уменьшался на 1. В, общем так, как реализовывается подсчет ссылок в COM объектах. И когда доходит до 0 — должно происходить освобождение памяти.
не читая комментариев пришла в голову та же идея:
Пример с использованием std::auto_ptr и его проблема:
Результат:
Item acquired
Item acquired
item1 is 2
item2 is 5
Item destroyed
Ownership transferred
item2 is 2
Item destroyed
Присваивание у Вас в примере — это и есть использование std::auto_ptr ? То есть он никаким особым образом не вызывается? Из урока это тоже не понятно.
вопрос отпал, не обратила внимания, что оба item — умные указатели std::auto_ptr