Чистый С++: Как правильно разрушать
Добрый день, Серега вновь добрался до клавиатуры и рассуждает о C++. Сегодня поговорим о том, зачем еще в C++ нужны классы, как работают деструкторы и на какие еще грабли можно наступить, если смешать два языка. Под катом ничего нового и выдающегося для тех, кто знает C++ еще со времен ДОСа. Если же вы еще только изучаете этот язык — добро пожаловать.
Как вы, наверное, заметили в прошлый раз, у класса есть очень важная вещь — деструктор! Эта функция будет вызвана ВСЕГДА, когда класс разрушается. Не важно что произошло, выход из функции посередине или даже выброшенное исключение, деструктор класса будет вызван в любом случае, пока программа работает. (Для справки, программа уже не работает, если исключение никто не ловит, поэтому в main нужно ставить try. catch на все типы исключений.). На C деструктор вызывается вручную, что заставляет писать много лишних подробностей.
А дополнительные ветвления и циклы лишь добавляют путаницы и громоздкости в коде.
Деструктор не заменяется секцией finalize, доступной в других языках! Дело в том, что он вызывается только у уже созданных объектов, а разобраться в том, кого уже создали, а кого нет в finalize возможно далеко не всегда. Приходится городить вложенные блоки и множественные секции финализации, что также делает код излишне запутанным. Вот хороший пример подобной ситуации:
Грамотное и повсеместное использование деструкторов приближает C++ по стилю программирования к языкам со сборщиком мусора. При таком подходе программист занят построением модели, а не выслеживанием симметричных выделений и освобождений ресурсов. Однако такое сближение сильно обманчиво и часто заканчивается обращением к удаленному с кучи объекту и аварийным выходом из программы. А если при этом еще активно пользоваться конструкциями языка C (упомянутый в прошлый раз «Ц с классами»), то однажды гарантированно произойдет обращение к данным объекта одного типа через указатель на другой тип. Вот очень хороший пример (делить на заголовочные файлы не обязательно, но полезно, чтобы было легко менять порядок их включения):
Попробуйте в этом примере поменять порядок включения второго и третьего заголовочных файлов. Очень хорошо, когда подобные ошибки легко обнаружить, как в этом изолированном примере. Когда-же код разбросан по многим файлам, скрыт несколькими уровнями иерархии наследования и виртуальной перегрузкой операторов, то легче сойти с ума, чем понять что-же на самом деле происходит. Если же использовать в этом примере чистый C++, то компилятор выдаст ошибку во время компиляции.
Вернемся к деструкторам. При их написании главное не бросить случайно исключение. Дело в том, что деструктор может быть вызван именно в процессе обработки исключения. В этом случае, процесс размотки стека процедуры размотки стека заканчивается самым простым и ожидаемым образом: немедленным выходом из программы. Так что внимательно за ними следите. Ни в коем случае не выделяйте в них память, ресурсы и уж тем более не бросайте исключений явно. Это требование диктует определенное отношение ко всяким «закрывающим» функциям. Например, какой нибудь Socket::Close() более не может сделать throw std::runtime_error(«Close socket error: socket was not opened») . Дело в том, что самое логичное, что можно сделать с «закрывающей» функцией — вызвать ее в деструкторе. А вот обкладывать этот вызов различными проверками условий — совсем даже не логично. И если вы работаете в коллективе, то кто-то обязательно постарается закрыть уже закрытое. Так-что запишите себе куда нибудь простое правило: в любой «закрывающей» функции нужно тихо и без лишних телодвижений делать именно то, что от нее требуется — «закрыть» то, что просят, даже если это физически невозможно.
Еще раз обращаю внимание на то, что нельзя в таких функциях и деструкторах ничего выделять или захватывать. Очень распространенная ошибка:
Если этот деструктор будет вызван в процессе обработки исключения вида «кончилась память», то вы долго будете искать причину вылета программы. При этом даже упомянутый тут логгер не поможет, т.к. не сумеет сформировать и сохранить так нужное сообщение. Как-же быть в такой ситуации? Самый простой способ, но не самый «красивый» — сгенерировать сообщение заранее, когда было все хорошо, и держать его в приватной части до поры до времени. Более «продвинутый» вариант:
Надеюсь понятно, что g_logger здесь не имеет права заниматься выделениями памяти и открытиями файлов, а обязан иметь наготове буффер фиксированного размера и сливать его в заранее открытый файл по заполнению?
Плавно перейдем к выделению ресурсов. Правильный подход — сначала выделить, а затем использовать. Вот неправильный пример:
Нужно постоянно помнить о том, что память может кончится в самый неподходящий момент, и состояние программы в такой момент обязано оставаться определенным. Если обязано быть два элемента в массиве — значит либо два целых, либо ни одного. Никаких «недосозданных» структур данных быть не должно, т.к. это прямой путь к сбою при работе деструкторов. Хорошая программа — это такая, которая даже при нехватке памяти корректно сохраняет свои данные и тихо выходит. Ну, может быть не тихо, а вежливо попрощавшись. К сожалению, это не всегда так легко достижимо. Например, std::list не имеет метода reserve. Для таких случаев приходится заводить «пустое» состояние элемента данных, вроде null_ptr для указателей или -1 для индексов, и класть его сначала в структуру данных. А в деструкторе аккуратно обходить такие элементы. Здесь уместно вспомнить про увлечение всякими операторами, создающими на стеке временные объекты. Эти объекты, в свою очередь, выделяют ресурсы, которые не выделяются, а бросают исключение прямо посередине сложного выражения, оставляя части данного выражения в полувычесленном состоянии. Например итератор, сдвигаемый оператором ++ в середине выражения будет абсолютно бесполезен в секции catch.
Чтобы целостность структур данных получалась сама собой, без лишних телодвижений со стороны программиста, нужно стремиться к тому, чтобы всякие такие структуры создавались в конструкторах и представляли из себя объект, а разрушались деструкторами, корректно исключая себя из общей структуры данных программы. Указанный выше пример с целыми числами следовало бы написать например так:
Однако это уже не такая простая тема моделирования предметной области поставленной задачи.
Что такое деструктор
Деструктор выполняет освобождение использованных объектом ресурсов и удаление нестатических переменных объекта. По сути деструктор — это функция, которая называется по имени класса (как и конструктор) и перед которой стоит тильда (
). Деструктор не имеет возвращаемого значения и не принимает параметров. Каждый класс может иметь только один деструктор.
Деструктор автоматически вызывается, когда удаляется объект. Удаление объекта происходит в следующих случаях:
когда завершается выполнение области видимости, внутри которой определены объекты
когда удаляется контейнер (например, массив), который содержит объекты
когда удаляется объект, в котором определены переменные, представляющие другие объекты
динамически созданные объекты удаляются при применении к указателю на объект оператора delete
Рассмотрим следующую ситуацию:
В классе Person определен деструктор:
Все, что делает данный деструктор, это выводит на консоль соответствующее сообщение. Как правило, деструкторы определяют код для особождения ресурсов, но в данном случае нам не надо освобождать никаких ресурсов, и мы могли бы определить даже пустой деструктор.
В функции main создается переменная tom, которая хранит объект Person. Это обычная переменная, не указатель. И для нее деструктор будет вызываться, когда завершит выполнение та область видимости, где эта переменная определена, то есть функция main.
Также здесь определен указатель bob, который указывает на объект Person. Это динамический объект, который определяется с помощью ключевого слова new. Когда такие объекты выходят из области видимости, то для них автоматически не выполняется деструктор. Поэтому для вызова деструктора и удаления таких объектов применяется оператор delete :
Таким образом, у данной программы будет следующий консольный вывод:
При этом выполнение самого деструктора еще не удаляет сам объект. Непосредственно удаление объекта производится в ходе явной фазы удаления, которая следует после выполнения деструктора.
Стоит также отметить, что для любого класса, который не определяет собственный деструктор, компилятор сам создает деструктор. Например, если бы класс Person не имел бы явно определенного деструктора, то для него автоматически создавался бы следующий деструктор:
Как работает деструктор
Объясните, что именно освобождает память при вызове деструктора для объекта, ведь по умолчанию он имеет пустое тело.
Формально деструктор не имеет никакого отношения к освобождению памяти "своего" объекта. Освобождение памяти делается внешним кодом, который и вызывает деструктор перед освобождением. Поэтому с этой точки зрения ваш вопрос несколько бессмыслен.
Деструктор — специальная функция. Даже если он имеет пустое тело, совсем не означает, что "ничего не делает". Например, деструктор всегда неявно вызывает деструкторы подобъектов данного класса, даже если у него внешне "пустое" тело.
Понятно, что деструктор может быть ответственен за явное освобождение посторонних ресурсов, которыми напрямую владеет ваш объект (в том числе память). Но в этом случае и тело деструктора не будет пустым.
В "традиционных" реализациях, как ни странно, когда деструктор виртуален, функция освобождения динамической памяти объекта зачастую неявно вызывается изнутри деструктора (а не наружным кодом). Такой трюк необходим для того, чтобы обеспечить правильное поведение перегруженного operator delete для класса (если таковой имеется). Но это уже внутренние детали реализации.
Третий пункт ссылается на следующую ситуацию.
В языке С++ абстрактный алгоритм работы оператора delete сводится к последовательности из двух шагов:
- Вызов правильного деструктора объекта
- Вызов правильной функции освобождения "сырой" памяти operator delete(void *) .
Функция operator delete(void *) , как известно, может замещаться/перегружаться пользователем. Разрешается как замещать глобальный ::operator delete(void *) , так и перегружать статическую функцию operator delete(void *) в конкретных классах. При этом спецификация языка требует, чтобы выбор конкретного operator delete(void *) делался так, как будто его поиск (name lookup) делался из деструктора удаляемого объекта.
в таком коде при выполнении delete pb после выполнения деструктора B::
B должен вызваться B::operator delete , а при выполнении delete pd после выполнения деструктора D::
D должен вызваться D::operator delete . Другими словами, несмотря на то, что функция operator delete всегда является статическим членом класса, она должна вести себя фактически как виртуальная (!) функция.
Для того, чтобы удовлетворить этому требованию языка, большинство реализаций просто-напросто переносят вызов правильного operator delete внутрь деструктора. Таким образом требуемая "виртуальность" функции operator delete достигается бесплатно, за счет виртуальности деструктора.
При этом понятно, что operator delete(void *) нужно вызвать только для полных объектов, размещенных в динамической памяти, а для остальных объектов — не нужно (т.е. нельзя). Чтобы принять это во внимание, компиляторы снабжают деструктор неявным булевским параметром, говорящим деструктору, надо ли вызывать operator delete . Таким образом в вышеприведенном примере деструкторы на самом деле будут иметь следующий вид
Выражения delete pb и delete pd в такой ситуации превращаются просто в виртуальные вызовы pb->
B(true) . Первое попадает в B::
Компилятор GCC, кстати, в более ранних версиях реализовывал этот подход именно так, как описано выше — через скрытый булевский параметр, а в современных версиях этот же поход он реализует через генерацию двух отдельных деструкторов.