Автор: Sergey Teplyakov

Совершенно оправданно книга камрада Рихтера CLR via C# считается лучшей в своей роде. Более детального описания платформы .NET сложно найти, да и его книги по внутреннему устройству Windows тоже совершенно уникальны.

Но в его книгах (в особенности CLR via C#) есть ряд не вполне адекватных советов по дизайну наших с вами приложений. Так, Рихтер весьма категоричен при описании паттерна Dispose, который вполне валиден для разработчика библиотек, но сомнителен для разработчиков приложений.

И вот еще один весьма сомнительный совет по поводу необходимости явного вызова метода Dispose:

Важно. В общем случае, я очень не рекомендую явно вызывать метод Dispose в вашем коде. Дело в том, что сборщик мусора CLR хорошо написан, и вы позволить ему делать свою работу. Сборщик мусора знает, когда объект становится недостижимым из кода приложения, и только тогда уничтожит его. Когда явно вызывается метод Dispose, то, по сути, это говорит, что приложению объект больше не нужен. Но во многих приложениях невозможно знать точно, что объект больше не используется.

Предположим, ваш код создает новый объект и вы передаете ссылку на этот объект в другой метод. Этот метод может сохранить ссылку на этот объект в некотором внутреннем поле (в корневой ссылке) и вызывающий метод никак не может узнать об этом. Конечно, вызывающий метод может вызвать Dispose, но потом, некоторый код может попытаться использовать этот объект и получит ObjectDisposeException. Я рекомендую явно вызывать метод Dispose только тогда, когда вы точно знаете, что в этом месте требуется очистка ресурсов (как в случае попытки удаления открытого файла).

Джеффри Рихтера, CLR via C#, глава 21 The Managed Heap and Garbage Collection

Вообще, идея, чтобы каждый делал свою работу мне очень нравится,), и я согласен с советами, которые говорят о вреде в большинстве случаев явного вызова сборки мусора через GC.Collect. Но мне совсем не понятно, как можно говорить, чтобы в общем случае разработчики избегали вызова метода Dispose?

Давайте подумаем о том, в каких случаях мы используем disposable объекты и когда стоит вызывать метод Dispose, а когда нет.

1. Использование ресурсов в блоке using или идиома RAII (Resource Acquisition Is Initialization).

Напомню, что идиома RAII пришла из языка С++, в котором она является основой автоматического управления памятью и ресурсами. Ее идея в том, что в конструкторе объекта захватывается некоторый ресурс, а в деструкторе он освобождается. При этом используется тот факт, что деструктор объекта будет вызван при выходе из области видимости автоматически, не зависимо от причина выхода из этой самой области видимости: выход по ключевому слову return или благодаря генерации исключения.

Многие другие языки программирования (и среды разработки) активно используют эту идиому. Так, в языке C# есть конструкция using, в C++/CLI implicitly dereferenced variables (это когда переменная ссылочного типа используется без всяких галочек и процентов), в F# - use binding. Даже в Java, начиная с 7-й версии есть конструкция try-with-resources.

С моей точки зрения именно этот способ является наиболее распространенным и предпочтительным паттерном использования ресурсов. Ведь не зря, камрады из Редмонда так хвалятся тем, что благодаря новым асинхронным возможностям мы теперь можем пользоваться блоком using в наших асинхронных приложениях.

2. Использование ресурсов в качестве полей других объектов

Помимо использования в качестве локальной переменной, мы можем положить disposable объект в виде поля нашего класса. В этом случае наш класс тоже должен реализовать интерфейс IDisposable, который затем может использоваться в виде локальной переменной или в виде поля другого объекта.

В обоих этих случаях мы можем столкнуться с проблемой, поднятой Джеффри Рихтером и передать наш ресурс некоторому методу, который его сохранит. С другой стороны, подобное использование ресурсов противоречит всем канонам, ведь этот самый disposable объект является деталью реализации нашего метода или класса, и передача его куда-то во внешний мир в большинстве случаев является не лучшим решением.

С подобными рассуждениями мы можем прийти к выводу, что такие понятия как инварианты класса тоже не стоит использовать, ведь из конструктора вы можете вызвать некоторый метод и передать ему себя в недостроенном виде.

Тем более, что помимо приведенных случаев, есть еще один вариант использования паттерна Dispose.

3. Использование Disposable объектов не для управления ресурсами

В большинстве случаев на платформе .NET для управления ресурсами используются следующие конструкции: (1) классы, владеющие неуправляемыми ресурсами класс должен содержать финализатор (finalizer) и (2) класс, владеющий управляемыми или неуправляемыми ресурсами должен реализовывать интерфейс IDisposable (подробнее о разнице между управляемыми и неуправляемыми ресурсами можно почитать в статье Dispose Pattern).

Но в наших с вами дот нетах есть и исключения. Так, например, класс Task содержит финализатор, но не содержит неуправляемых ресурсов (да, таска может содержать управляемые ресурсы, но сейчас не об этом). В случае задач финализатор используется не для очистки ресурсов, а чтобы понять, является ли исключение текущей задачи необработанным.

Помимо этого, есть примеры использования disposable объектов не для управления ресурсами напрямую, а для выполнения некоторых других операций.

3.1. Отписка от событий

Так, если ваш класс в конструкторе подписывается на событие долгоживущего объекта, то чтобы избежать утечки памяти нам нужно в некоторый момент времени от них отписаться. В этом случае финализатор нам никак не поможет, поскольку чтобы он вызвался нам нужно вначале отписаться от глобального события. В этом случае вполне валидным решением является использование для этих целей метода Dispose:class CustomDisposable : IDisposable
{
public CustomDisposable()
{
AppDomain.CurrentDomain.UnhandledException += Handler,
}
public void Dispose()
{
AppDomain.CurrentDomain.UnhandledException -= Handler,
}
private void Handler(object sender, UnhandledExceptionEventArgs e)
{}
}

3.2. Освобождение блокировок или другие постдействия

Помимо отписки от событий или явного освобождения ресурсов метод Dispose и блок using может использовать для любых других произвольных действий. Например, мы можем использовать disposable оболочку для освобождения блокировок, аналогично тому, как это делается в С++:

Так, для класса ReaderWriterLockSlim мы можем сделать методы расширения, которые будут захватывать блокировку в конструкторе и освобождать ее в методе Dispose:// Это не продакшн код, а лишь демонстрация возможностей!
static class ReaderWriterLockSlimEx
{
public static IDisposable UseRead(this ReaderWriterLockSlim rwLock)
{
rwLock.EnterReadLock(),
return new ReadDisposeWrapper(rwLock),
}
class ReadDisposeWrapper : IDisposable
{
private readonly ReaderWriterLockSlim _rwLock,
public ReadDisposeWrapper(ReaderWriterLockSlim rwLock)
{
_rwLock = rwLock,
}
public void Dispose()
{
_rwLock.ExitReadLock(),
}
}
}

Да, некоторые товарищи, в том числе Эрик Липперт не одобряет использование блока using не для управления ресурсами, но иногда именно такой подход бывает довольно удобным.

Когда можно не вызывать Dispose?

А есть ли случаи, когда вызывать Dispose не обязательно? Да, есть, конечно.

Некоторые классы могут реализовывать интерфейс IDisposable, но при этом могут аллоцировать ресурсы лишь в редких случаях. Упомянутые вышеTask и Task<,T>, реализуют IDisposable, но по словам Стивена Тауба (одного из авторов TPL) вызывать Dispose для них не нужно, поскольку ресурсы внутри объекта Task выделяются лишь в редких случаях.

Некоторые классы реализуют IDisposable, который достался от базового класса, таким примером является класс StringReader, который по своей природе не владеет ресурсами.

Бывают случаи, когда класс владеет некритическими ресурсами, при этом алгоритм владения является нечетким. Таким примером является класс ImageList из Windows Forms, который не очищает все содержащиеся в нем изображения.

В большинстве случаев и правда крайне не рекомендуется вызывать метод Dispose на объектах, созданных не вами. Также может быть опасным вызывать метод Dispose в многопоточных сценариях, поскольку это может привести к гонкам. Но в этом случае я бы скорее постарался ограничить многопоточное использование ресурса в одном классе, что позволит перед явной очисткой нашего ресурса дождаться завершения всех рабочих потоков.

Другими словами, случаи когда вы можете не вызывать метод Dispose и правда существуют, но я бы не назвал это явление таким уж распространенным. Дизайн класса говорит о намерениях проектировщика и реализация интерфейса IDisposable говорит клиентам класса, что объект поддерживает явное освобождение ресурсов. При этом не зная о о последствиях отсутствия вызова Dispose я бы рассматривал наиболее пессимистический вариант и старался бы освободить ресурсы явным образом.

Заключение

Если уж говорить о рекомендациях по вызову метода Dispose, то я бы озвучил совет так, как это сделал Джо Даффи, в замечательной книге Framework Design Guidelines:

Если тип реализует интерфейс IDisposable и владение ресурсами очевидно (к чему в большинстве случаев стоит стремиться, прим. С.Т.), вы должны сделать все возможное для вызова метода Dispose после завершения использования объекта. Но если владение ресурсами неочевидно (поскольку объект используется во множестве мест или используется из нескольких потоков), то отсутствие вызова Dispose не причинит особого вреда. (Помимо ряда неприятных случаев, типа класса FileStream, когда отсутствие вызова метода Dispose может привести к непредсказуемым последствиям).

Дополнительные ссылки
  • Dispose Pattern
  • Об идиоме RAII и классе ReaderWriterLockSlim
  • Полноценное расширение для класса ReaderWriterLockSlim

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).