Это перевод первой части статьи. Статья была написана в 2008 году. Спустя 10 лет почти не потеряла актуальности.
В течение более чем 20-летнего опыта кодирования я иногда разрабатывал собственные языки для решения задач. Они варьировались от простых имеративных языков до специализрованных регулярных выражений для деревьев. При создании языков есть множество рекомендаций и некоторые простые правила не должны нарушаться. Одно из них:
Никогда не создавай язык с исключениями, в котором нет детерминированного освобождения ресурсов.
Угадайте какой рекомендации не следует рантайм .NET, и как следствие все языки на его базе?
Причина по которой существует данное правило — детерминированное освобождение ресурсов необходимо для создания поддерживаемых программ. Детерминированное освобождение ресурсов обеспечивает определенную точку, в которой программист уверен, что ресурс освобожден. Существует два способа написания надежных программ: традиционный подход — освобождать ресурсы как можно раньше и соверменный подход — с освобождением ресурсов в течение неопределенного времени. Преимущество современного подхода в том, что программисту не надо явно освобождать ресурсы. Недостаток в том, что гораздо сложнее написать надежное приложение, появляется много трудноуловимых ошибок. К сожалению рантайм .NET создан с использованием современного подхода.
.NET поддерживает недетерминированное освобождение ресурсов с помощью метода Finalize
, который имеет специальное значение. Для детерминированного освобождения ресурсов Microsoft также добавил интерфейс IDisposable
(и другие классы, которые мы рассмотрим позже). Тем не менее для рантайма IDisposable
это обычный интерфейс, как и все остальные. Такой статус "второсортного" создает некоторые сложности.
В C# "детерминированное освобождение для бедных" может быть реализовано с помощью операторов try
и finally
или using
(что почти тоже самое). В Microsoft долго обсуждали делать ли счетчики ссылок или нет, и мне кажется, что было принято неверное решение. В результате для детерминированного освобождение ресурсов нужно использовать неуклюжие конструкции finally
\using
или прямой вызов IDisposable.Dispose
, что чревато ошибками. Для С++ программиста, который привык использовать shared_ptr<T>
оба варианта не привлекательны. (последнее предложение дает понять откуда у автора такое отношение — прим. пер.)
IDisposable
— решение для детерминированного освобождения ресурсов, предлагаемое Miсrosoft. Одно предназначено для следующих случаев:
IDisposable
) ресурсами. Тип обязательно должен владеть, то есть управлять временем жизни, ресурсов, а не просто ссылаться на них. IDisposable
. Я не рекомендую наследоваться от классов, владеющих неуправляемыми ресурсами. Лучше использовать вложение.IDisposable
помогает детерминированно освобождать ресурсы, но имеет свои проблемы.
Объекты IDisposable
использоватькорректно довольно громоздко. Использование объекта нужно обернуть в конструкцию using
. Плохо то, что C# не позволяет использовать using
с типом, не реализующим IDisposable
. Поэтому программист должен каждый раз обращаться к документации чтобы понять надо ли писать using
, или просто писать using
везде, а потом стирать там, где ругается компилятор.
Managed C++ в этом отношении гораздо лучше. Он поддерживает стековую семантику для ссылочных типов, которая работает как using
только для тех типов, где это необходимо. C# мог бы выиграть от возможности писать using
с любым типом.
Эта проблема может быть решена с помощью. инструментов анализа кода. Ухудшает ситуцию то, что если забыть using
, то программа может пройти тесты, но упасть во время работы "в полях".
IDisposable
вместо подсчета ссылок несет другую проблему — определение владельца. Когда в C++ последня копия shared_ptr<T>
выходит из области видимости ресурсы освобождаются сразу же, не надо думать кто должен освобождать. IDisposable
напротив заставляет программиста определять кто "владеет" объектом и ответственнен за его освобождение. Иногда владение очевидно: когда один объект инкапсулирует другой и сам реализует IDisposable
, следовательно отвечает за освобождение дочерних объектов. Бывает время жизни объекта определяется блоком кода и программист просто использует using
вокруг этого блока. Тем не менее существует много случаев, где объект может быть использован в нескольких местах и его время жизни определить сложно (хотя в этом случае подсчет ссылок справился бы прекрасно).
Добавление IDisposable
к классу и убирание IDisposable
из списка реализуемых интерфейсов — это ломающее изменение. Клиентский код, который не ожидает IDisposable
, то не освободит ресурсы, если вы добавите IDisposable
к одному из своих классов, передаваемых по ссылке на интерфейс или базовый класс.
Microsoft сам столкнулся с этой проблемой. IEnumerator
не наследуется от IDisposable
, а IEnumerator<T>
наследуется. Если коду, принимающему IEnumerator
передать IEnumerator<T>
, то Dispose
не будет вызван.
Это не конец света, но выдает некоторую второстепенную сущность IDisposable
.
Самый большой недостаток вызванный IDisposable
в области проектирования иерархии — каждый класс и интерфейс должен предсказать понадобятся ли его наследникам IDisposable
.
Если интерфейс не наследует IDisposable
, но классы реализующие интерфейс реализуют также IDisposable
, то конечный код будет или игнорировать детерминированное освобождение, или сам должен проверять реализует ли объект интерфейс IDisposable
. Но для этого уже не получится использовать конструкцию using
и придется писать уродский try
и finally
.
Короче говоря IDisposable
усложняет разработку повторно используемого софта. Ключевая прчина это нарушение одного из приципов объектно-ориентированного проектирования — разделения интерфейса и реализации. Освобождение ресурсов должно быть деталью реализации. Microsoft решил сделать детерминированное освобождение ресурсов интерфейсом второго сорта.
Одно из не очень красивых решений — сделать все классы реализующими IDisposable
, но в подавляющем большинстве классов IDisposable.Dispose
не будет делать ничего. Но это слишком не красиво.
Еще одна сложность IDisposable
— коллекции. Часть коллекций "владеют" объектами в них, а часть нет. При этом коллекции сами не реализуют IDisposable
. Программист должен не забывать вызывать IDisposable.Dispose
для объектов в коллекции или создавать своих наследников классов коллекций, которые реализуют IDisposable
чтобы обозначить "владение".
IDisposable
может быть вызван явно в любое время, независимо от времени жизни объекта. То есть к каждому объекту добавляется состояние "освобожден", в котором рекомендуется выбрасывать исключение ObjectDisposedException
. Проверка состояния и выбрасывание исключений — дополнительные расходы.
Вместо проверок на каждый чих, лучше считать обращение к объекту в "освобожденном" состоянии "неопределенным поведением", как обращение к освобожденной памяти.
IDisposable
это всего лишь интерфейс. Класс, реализующий IDisposable
, поддерживает детерминированное освобождение, но не гарантирует его. Для клиентского кода вполне нормально не вызывать Dispose
. Поэтому класс, реализующий IDisposable
, должен поддерживать как детерминированное, так и недетерминированное освобождение.
Microsoft предлагает патерн для реализации IDisposable
. (Ранее был вообще ужасный паттерн, но относительно недавно, после появления .NET 4, документацию поправили, в том числе под влиянием этой статьи. В старых редакциях книг по .NET вы можете найти старый вариант. — прим. пер. )
IDisposable.Dispose
может быть не вызван вообще, поэтому класс должен включать финализатор чтобы освободить ресурсы.IDisposable.Dispose
может быть вызван несколько раз и должен отработать без видимых побочных эффектов. Поэтому необходимо добавлять проверку был метод уже вызван или нет.IDisposable.Dispose
завершит работу. Нобходимо использование GC.SuppressFinalize
чтобы избежать таких "гонок".Кроме того:
IDisposable
в классе, унаследованном от CriticalFinalizerObject
требует нетривиальных конструкций. void Dispose(bool disposing)
это вируальный метод и должен испольняться в Constrained Execution Region, что требует вызова RuntimeHelpers.PrepareMethod
.Завершение работы объекта — часто возникает в программах параллельными или асинхронными потоками. Например класс использует отдельный поток и хочет завершить его с помощью ManualResetEvent
. Это вполне можно сделать в IDisposable.Dispose
, но может приводить к ошибке если код вызывать в финализаторе.
Чтобы понять ограничения в финализаторе надо понимать как работает сборщик мусора. Ниже упрощенная схема, в которой опущены многие детали, связанные с поколениями,, слабыми ссылками, возрождением объектов, фоновой сборкой мусора итд.
Сборщик мусора .NET использует алгоритм mark-and-sweep. В целом логика выглядит так:
GCHandle
, очередь финализации. В случае выгрузки домена приложения (завершения программы) считается, что переменные в стеке и статические поля не являются корнями.GC.SuppressFinalize
говорит GC не делать этого). Объекты попадают в очередь в непредсказуемом порядке.В фоне работает поток (или несколько) финализации:
Теперь должно быть понятно почему нельзя обращаться из финализатора к управляемым ресурсам — вы не знаете в каком порядке вызываются финализаторы. Даже вызов IDisposable.Dispose
другого объекта из финализатора может привести к ошибке, так как код освобождения ресурсов может работать в другом потоке.
Есть несколько исключений, когда можно обращаться к управляемым ресурсам из финализатора:
CriticalFinalizerObject
выполняется после финализации объектов, не-унаследованных от этого класса. Это означает что можно вызывать ManualResetEvent
из финализатора пока класс не унаследован от CriticalFinalizerObject
В общем случае лучше не обращаться к управлемым ресурсам из финализаторов. Тем не менее логика завершения необходима нетривиального софта. В Windows.Forms
содержит логику завершения в методе Application.Exit
. Когда вы разрабатываете свою библиотеку компонентов лучше всего логику завершения завязать на IDisposable
. Нормальное завершение в случае вызова IDisposable.Dispose
и экстренное в противном случае.
Microsoft тоже столкнулась с этой проблемой. Класс StreamWriter
владеет объектом Stream
(в зависимости от параметров конструктора в последней версии — прим. пер.). StreamWriter.Close
сбрасывает буфер и вызывает Stream.Close
(тоже просиходит если обернуть в using
— прим. пер.). Если StreamWriter
не закрыт, буфер не сбрасывается и чатсь данных теряется. Microsoft просто не переопределил финализатор, таким образом "решив" проблему завершения. Прекрасный пример нужности логики завершения.
Много информации о внутреннем устройстве .NET в этой статье почерпнуто из книги "CLR via C#" джеффри Рихтера. Если у вас её еще нет, то купите. Серьезно. Это необходимые знания для любого C# программиста.
Большинство .NET программистов никогда не столкнется с проблемами, описанными в этой статье. .NET развиватся в сторону повышения уровня абстракции и уменьшении потребности в "жонглировании" неуправляемыми ресусрами. Тем не менее эта статья полезна тем, что описывает глубокие детали простых вещей и их влияние на проектирование кода.
В следующей части будет подробный разбор как правильно работать с управляемыми и неуправляемыми ресурсами в .NET с кучей примеров.
К сожалению, не доступен сервер mySQL