Отправляясь на очередное алгоритмическое собеседование по "плюсам", я чувствовала себя довольно спокойно: прослушав с десяток-другой подкастов из серии "Собеседование Junior/Middle C++", благополучно справившись с тестовым заданием, будучи готовой "пояснить за" многопоточность, сокеты и atomic'и, и, в конечном итоге, имея за своей спиной определенный опыт работы с языком, впору было задаться вопросом, а чего я, собственно, в этом С++не видела?
Тем не менее, какие-то пробелы в знаниях имеются у всех: все обстояло хорошо ровно до той поры, как меня спросили про умные указатели.
Умные указатели? Ну, да, было что-то такое. Unique_ptr, Shared_ptr... Едва ли в тот момент я могла сказать что-то более внятное за неимением практического опыта использования вышеобозначенных объектов.
Коротко говоря, несмотря на то, что в общем и целом собеседование прошло неплохо, именно умные указатели стоили мне тех самых считанных баллов, из-за которых предпочтение было отдано другому кандидату. И тем не менее, приняв во внимание опыт предшествующих ошибок, я посвятила пару вечеров, чтобы основательно разобраться в данной теме и сформировать представление о том, что такое умные указатели, какие они бывают, почему их так много и зачем вообще все это нужно.
Поехали.
1. Что не так с "обычными" указателями?
Главный недостаток "обычных" указателей в языке С++ состоит в том, что ответственность за освобождение памяти полностью ложится на плечи разработчика, о чем, собственно, немудрено и забыть. Более того, даже если вы честно собирались удалить указатель, возможны ситуации, когда выполнение функции завершается преждевременно (вызов return, break или проброс исключения) и до строчки с delete дело так и не доходит, что, в конечном счете, приводит к утечке памяти.
Рассмотрим, например, такой пример: предположим, что в теле функции создается указатель на некоторый объект, размещаемый в памяти динамически, т.е. занимающий ее ровно до тех пор, пока ссылающийся на нее указатель не будет явным образом удален (вызов delete):
Тем не менее, логика программы оказывается такова, что если на вход функции example() поступает нечетное число, то она досрочно завершает свое выполнение, инструкция delete myPtr не выполняется, а, следовательно, не вызывается и деструктор для созданного ранее объекта Thing. Таким образом, он по-прежнему продолжает занимать место в памяти даже несмотря на то, что больше нам не нужен (и, более того, мы даже не имеем к нему доступа!).
Один потерянный указатель - нехорошо, но терпимо, однако, что произойдет, если нам придется вызвать example() 100, 1000 или 100 000 раз?
2. В чем суть умных указателей?
Как я уже упоминала ранее, указатели не удаляются сами по себе - в отличие от классов, деструктор для которых автоматически вызывается в тот момент, когда объект покидает область видимости. Так вот, попросту говоря, умные указатели являются своего рода классом-оберткой для "обычного" указателя, реализующего автоматический вызов деструктора, а заодно обеспечивающий кое-какие дополнительные функции, речь о которых пойдет ниже.
3. Множественное владение одним ресурсом
Еще одной проблемой с "обычными" указателями является то, что мы можем наделать сколько угодно копий одного и того же указателя, однако, коль скоро хотя бы для одного из них будет вызван delete, память, на которую все они ссылаются, окажется освобождена, и остальные превратятся в тыкву... Простите, nullptr.
Таким образом, приведенная ниже штука вызовет ошибку компиляции, так как в ней я пытаюсь разыменовать second, принадлежащий которому ресурс был благолучно удален вместе с first.
Возможность возникновения таких ситуаций заметно усложняет нам жизнь, так как всякий раз при использовании указателя нам потребуется проверить, а не является ли он, чего доброго, nullptr, а уж если вы работаете над кодом совместно с другими разработчиками, то и подавно беда-беда. Поэтому для ситуаций, когда нам требовалось бы обеспечить
а) безопасность памяти
б) единоличное владение ресурсом,
был придуман unique_ptr, определенный в заголовочном файле <memory>.
Справедливости ради говоря, unique_ptr был далеко не первой попыткой создания интеллектуальной обертки для "обычного" указателя - еще ранее был придуман auto_ptr, обладавший, однако, рядом собственных недостатков и окончательно удаленный в стандарте C++17.
4. Unique_ptr и монопольное владение ресурсом
Как и следует из названия, Unique_ptr обладает монопольным правом владения своим ресурсом - в том смысле, что для него не существует конструктора копии или перегруженных операторов, позволяющих сделать это.
Тем не менее, в теории мы можем сделать два одинаковых Unique_ptr на базе "обычного" указателя или самого объекта - чего, впрочем тоже не стоит делать, т.к. дальнейшее поведение программы будет непредсказуемым.
Для того, чтобы создать Unique_ptr, можно использовать одноимённый конструктор или функцию make_Unique(); оптимизированную по производительности.
Помимо этого, у Unique_ptr (как и у прочих "умных" указателей) есть собственный деструктор, автоматически вызывающийся при выходе Unique'а из области видимости программы, что дополнительно избавляет вас от хлопот о самостоятельной очистке памяти:
5. Shared_ptr() и совместное использование ресурса
Тем не менее, иногда у нас могут возникнуть такие ситуации, когда бы нам потребуются более одного указателя на один и тот же объект, при этом, время жизни объекта продлевается до тех, пока на него ссылается хотя бы один Shared_ptr():
Из приведенного скриншота мы можем видеть, что мы не только можем создать несколько умных указателей, ссылающихся на один и тот же объект, но и использовать "копию" после уничтожения "оригинала" - то есть, деструктор для объекта Thing будет вызван лишь после уничтожения всех ссылающихся на него Shared_ptr().
Технически, Shared_ptr() хранит в себе не один, а целых два указателя, один из которых ссылается на целевой объект, а другой - на блок управления, общий для всех shared_ptr, ссылающийся на данный объект. Из этого проистекает одна очень важная особенность применения данного типа умных указателей: второй (третий, четвертый и т.д...) shared_ptr должны создаваться как копия первого (см. выше), а не принимать в качестве входного параметра исходный объект.
В случае, если мы проигнорируем это правило, независимо ссылающиеся на один и тот же объект shared_ptr не будут знать ничего о существовании друг друга, т.к. каждый из них реализует свой собственный блок управления, и при уничтожении (якобы) единственного указателя вышеобозначенный блок управления освободит память.
Этот код выдаст ошибку, т.к. в тот момент, когда мы попытаемся обратиться к second->helloThere() во второй раз, принадлежащий ему объект будет уничтожен деструктором, вызванным в тот момент, когда на него перестал ссылаться first.
Как и в случае с unique_ptr, shared_ptr может быть создан при помощи make_shared:
6. Круговая зависимость и weak_ptr
Представим себе такую ситуацию: предположим, что у нас есть два объекта одного и того же класса, хранящих указатели друг на друга:
Казалось бы, ну и что с того?
Тем не менее, уже при первом запуске программы выявляются кое-какие проблемы: из приведенного ниже скриншота видно, что ни для одного из созданных объектов так и не был вызван деструктор - что, в общем-то, немудрено, т.к. мы не можем удалить first, пока на него ссылается second, и не можем удалить second, покуда на него продолжает ссылаться first:
Такая проблема называется круговой зависимостью.
Для того, чтобы разрешить этот порочный круг, и был придуман weak_ptr: как и в случае с shared_ptr, здесь мы можем создавать сколько душе угодно таких указателей, однако, ни один из них не будет являться определяющим фактором для освобождения памяти объекта. Иными словами, weak_ptr играет роль своего рода "пассивного наблюдателя", способного ссылаться на объект, покуда тот существует, но не способного поддерживать его существование после того, как на него перестанут ссылаться полноценные "сильные" указатели.
Давайте исправим написанный ранее код с учетом всего вышесказанного:
Что поменялось? Во-первых, указатели-члены класса стали weak_ptr, а, следовательно, перестают блокировать удаление своего объекта. Во-вторых, weak_ptr может быть совершенно безболезненно создан, принимая за образец shared_ptr в качестве входного параметра, что демонстрируется в функции makePair().
Наконец, имеет смысл обратить внимание на то, что мы не можем просто так взять и разыменовать слабый указатель, в т.ч. для обращения к методам его объекта - для этого требуется использовать метод lock(), возвращающий эквивалентный shared_ptr.
Теперь мы не только можем создать "рабочие" объекты X с перекрестными ссылками друг на друга, но и обеспечить корректное освобождение памяти=вызов деструктора по выходу из функции, все хорошо.
Подводя итоги
- "Умные" указатели решают проблему самостоятельного контроля за освобождением памяти;
- "Умные" указатели содержатся в заголовочном файле <memory>;
- unique_ptr обеспечивает единоличное владение объектом;
- Не следует создавать несколько экземпляров unique_ptr, ссылающихся на один и тот же объект;
- shared_ptr ссылаются на один и тот же объект и поддерживают его время жизни до тех пор, пока на него продолжает ссылаться хотя бы один shared_ptr;
- Второй и последующие экземпляры shared_ptr должны создаваться как копия ранее созданных (а не "обычного" указателя);
- weak_ptr может хранить адрес своего объекта, но не обеспечивает продления его существования;
- weak_ptr не может быть разыменован. Для того, чтобы использовать weak_ptr, необходимо преобразовать его в shared_ptr() при помощи метода lock().
Благодарю за внимание!