Как и когда нужно имплементировать IDisposable?

213
10 декабря 2020, 14:30

В каком случае мой класс должен имплементировать интерфейс IDisposable? Подскажите правильную имплементацию. Что такое неуправляемые ресурсы, и как нужно оформлять их закрытие?

Answer 1

А что это вообще?

Объекты в .NET уничтожаются по своим хитрым правилам. Не как в C++, где объект уничтожается как только выходит из области видимости. В .NET реализована сборка мусора: если на объект нет ссылок, то он считается никому не нужным, и его находит и съедает «сборщик мусора». Причём не сразу, а когда ему заблагорассудится, может даже вообще никогда. [На самом деле, если на объект есть ссылка, но из другого никому не нужного объекта, то эта ссылка как бы не считается.]

Например, если вы обнулите ссылку на объект, это не приведёт к его немедленному уничтожению. (Это вообще не обязательно приведёт к его уничтожению даже потом: на этот объект могут быть и другие ссылки.)

Это может приводить к неприятным эффектам. К примеру, если мы в C++ открыли файл в конструкторе объекта, то мы его можем закрыть в деструкторе. При этом мы точно знаем, когда файл будет закрыт: по окончанию блока, в котором объявлена объект. Если мы откроем файл в конструкторе класса C#, и попытаемся закрыть его в деструкторе, получится плохо: деструктор вызывается лишь тогда, когда сборщик мусора убирает объект, то есть, непонятно когда, и может быть даже вообще никогда. Это значит, что если мы в другой части программы попытаемся открыть снова этот же файл, нам это не удастся: файл может быть всё ещё открыт старым объектом.

Выходит, что деструктор в C# практически бесполезен, и применять его вам придётся очень редко. (Он, кстати, официально называется финализатор.)

Какой выход из этой ситуации? Выход есть, но он требует внимания.

Вы можете объявить метод, который можно вызывать, когда объект должен «умереть». Это делается при помощи интерфейса IDisposable, у которого есть один метод Dispose(). В этом методе и должна происходить «подчистка».

Но этот метод не будет вызван автоматически. Этот метод должны всё равно вызвать вы, система не сделает это за вас. Для системы IDisposable просто ещё один интерфейс, он не имеет никакого особого значения (например, деструктор про него вообще не в курсе). Есть ещё удобная конструкция using, которая вызовет для вас метод Dispose в конце блока:

using (StreamReader r = new StreamReader(path))
{
    Console.WriteLine(r.ReadLine());
}

это почти то же, что

StreamReader r = new StreamReader(path);
Console.WriteLine(r.ReadLine());
r.Dispose();

(но правильно работает в случае исключений и тому подобных штук).

Зачем такой метод, если есть деструктор? Как мы уже выяснили, деструктор вызывается непонятно когда, или вообще никогда. Невозможно вызвать деструктор в подходящий момент. А вот вызвать метод, типа Dispose, в подходящий момент можно.

Когда это нужно?

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

Другой случай, когда вам практически всегда нужно реализовывать IDisposable — это если ваш класс работает с WinAPI, и имеет хэндл на внешний объект. Например, вы открываете файл через WinAPI, используя P/Invoke. В этом случае вам нужно вовремя закрыть его, и для этого реализовать IDisposable.

Давайте посмотрим, что это значит в общем случае. В нашем классе есть какая-то штука, за которую мы отвечаем, и которую должны прибить в конце существования класса. Такую штуку обычно называют ресурсом.

Ресурсы делятся на управляемые (те, которые являются по сути .NET-объектами) и неуправляемые (они обычно являются хэндлами системы и хранятся в IntPtr, но в принципе могут быть любым объектом вне данного рантайма .NET, или даже просто чисто логической сущностью, наподобие права на показ нотификации пользователю). Управляемый ресурс может внутри себя содержать и неуправляемые ресурсы.

Если ваш класс содержит ресурс (например, поле или свойство типа IDisposable или неуправляемый объект WinAPI) вы (скорее всего) должны реализовать IDisposable.

Закрытие управляемого ресурса практически всегда сводится к вызову Dispose, т. к. этот ресурс сам должен реализовывать IDisposable. Закрытие неуправляемого ресурса делается специфическим для типа этого ресурса образом.

Если вы пользуетесь неуправляемым ресурсом, превратите его в управляемый ресурс путём упаковки в SafeHandle (об этом ниже). [Разве что у вас есть очень веская причина так не делать. Нет, лень не считается веской причиной.]

Если вы храните ссылку на другие объекты в вашем классе, но эти объекты не имплементируют IDisposable, то вам скорее всего не нужно реализовывать IDisposable самому. Просто память не является ресурсом, который надо освобождать: этим за вас занимается сборщик мусора. Раз вы не можете вызвать Dispose у подобъектов, то они будут освобождены, когда их «съест» сборщик мусора. Поскольку после Dispose ваш объект обычно больше никому не нужен, то он сам скоро будет недоступен, и ссылки из него на подобъекты не будут для сборщика мусора важны, так что обнуление ссылок ничего не даст.

То есть если ваши поля — обыкновенные объекты, не IDisposable (например, строки), то вам скорее всего не нужно реализовывать IDisposable для вашего класса. Если же какой-то один из ваших подобъектов реализует IDisposable, то и вам тоже скорее всего нужно реализовать IDisposable.

Реализация IDisposable для случая, когда в вашем классе есть как управляемые, так и неуправляемые ресурсы, сложна и содержит много тонких моментов. Поэтому Microsoft настоятельно рекомендует не пытаться сделать это, а обернуть неуправляемый ресурс в SafeHandle (или другой объект, чьё предназначение — обёртка для ресурса), и на уровне вашего класса работать лишь с управляемыми ресурсами.

Как имплементировать IDisposable правильно?

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

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

Вот примерный скелет имплементации (одолжен из этого ответа):

sealed class C : IDisposable
{
    SomeResource1 resource1;
    SomeResource2 resource2;
    // тут могут быть ещё ресурсы
    bool isDisposed = false;
    public C()
    {
        try
        {
            resource1 = AllocateResource1();
            resource2 = AllocateResource2();
        }
        catch
        {
            Dispose();
            throw;
        }
    }
    public void Use()
    {
        if (isDisposed) // использовать удалённый объект -- ошибка, её лучше проверять
            throw new ObjectDisposedException("Use called on disposed C");
        // ...
    }
    public void Dispose()
    {
        // мы уже умерли? валим отсюда
        if (isDisposed)
            return; // Dispose имеет право быть вызван много раз
        // освободим ресурсы
        if (resource2 != null)
        {
            resource2.Dispose();
            resource2 = null;
        }
        if (resource1 != null)
        {
            resource1.Dispose();
            resource1 = null;
        }
        // и запомним, что мы уже умерли
        isDisposed = true;
    }
}

Для чего нам try/catch в конструкторе? Если получение ресурса может окончиться неудачей или выбросить исключение, имеет смысл освободить ресурсы сразу, т. к. в этом случае Dispose клиентом вызвано не будет (он не получит ссылку на объект). В случае, если код в конструкторе гарантировано не может выбросить исключение, паттерн можно упростить:

sealed class C : IDisposable
{
    SomeResource1 resource1;
    SomeResource2 resource2;
    bool isDisposed = false;
    public C()
    {
        resource1 = AllocateResource1();
        resource2 = AllocateResource2();
    }
    public void Use()
    {
        if (isDisposed) // использовать удалённый объект -- ошибка, её лучше проверять
            throw new ObjectDisposedException("Use called on disposed C");
        // ...
    }
    public void Dispose()
    {
        if (isDisposed)
            return;
        resource2.Dispose();
        resource1.Dispose();
        isDisposed = true;
    }
}

Для случая иерархии классов метод Dispose в базовом классе нужно объявить виртуальным, и не забыть вызвать base.Dispose() в конце порождённых классов:

class C : IDisposable
{
    // ...
    public virtual void Dispose()
    {
        if (isDisposed)
            return;
        if (resource != null)
        {
            resource.Dispose();
            resource = null;
        }
        isDisposed = true;
    }
}
class C2 : C
{
    // ...
    public override void Dispose()
    {
        if (isDisposed)
            return;
        if (resource2 != null)
        {
            resource2.Dispose();
            resource2 = null;
        }
        isDisposed = true;
        base.Dispose();
    }
}

Финализаторы и паттерн с Dispose(bool disposing), рекомендуемый FxCop'ом, в этом случае не нужны, т. к. неуправляемых ресурсов наши классы не содержат. Так что предупреждение об этом можно игнорировать.

Как создать управляемую обёртку?

Итак, у нас есть неуправляемый ресурс (то есть, ресурс, не сводимый к .NET-объекту). Нам нужно построить для него IDisposable-обёртку.

Для начала, если наш ресурс — хэндл, то скорее всего у вас уже есть определённый во фреймворке потомок SafeHandle, подходящий для вашего ресурса. Загляните в пространство имён Microsoft.Win32.SafeHandles. В частности, вы можете использовать

  • SafeFileHandle, SafeMemoryMappedFileHandle, SafePipeHandle для файлов, отображений файлов в память и пайпов (каналов).
  • SafeMemoryMappedViewHandle для представлений памяти.
  • целая группа SafeNCryptKeyHandle, SafeNCryptProviderHandle, SafeNCryptSecretHandle для криптографических функций WinAPI.
  • SafeRegistryHandle для работы с реестром.
  • SafeWaitHandle для хэндлов, по которым можно ожидать чего-нибудь.

Эти классы представляют собой готовые обёртки, вы можете использовать их в определениях P/Invoke. Или если вам не совсем подходит один из готовых классов, вы можете унаследоваться от SafeHandle и получить свою обёртку. Пример отсюда, демонстрирует обе техники:

using System.Runtime.InteropServices.ComTypes;
using Microsoft.Win32.SafeHandles;
class FindHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    private FindHandle() : base(true) { }
    protected override bool ReleaseHandle() { return FindClose(this); }
}
[DllImport("kernel32.dll")] static extern bool FindClose(FindHandle handle);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
struct DATA // WIN32_FIND_DATA
{
    public FileAttributes FileAttributes;
    public FILETIME CreationTime, LastAccessTime, LastWriteTime;
    public uint FileSizeHigh, FileSizeLow;
    public uint Reserved0, Reserved1;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string FileName;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
    public string AlternateFileName;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern FindHandle FindFirstFileEx(string name, int i, 
                                         out DATA data, int so, IntPtr sf, int f);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool FindNextFile(FindHandle h, out DATA data);

Если вам нужно создать собственную обёртку, а готовые базовые классы не подходят, подойдёт такой костяк:

class CustomResourceHolder : IDisposable
{
    IntPtr resource;
    bool isDisposed = false;
    public CustomResourceHolder()
    {
        resource = AllocateUnmanagedResource();
        // эта строчка нужна в конце конструктора, иначе финализатор может начать
        // есть объект до окончания работы конструктора!
        GC.KeepAlive(this);
    }
    public IntPtr GetHandleDangerous() { return resource; }
    public void Dispose()
    {
        DoDispose();
        // эта строка гарантирует, что объект будет считаться достижимым
        // до конца DoDispose, и что в случае Dispose финализатор вызван не будет
        GC.SuppressFinalize(this);
    }
    ~CustomResourceHolder()
    {
        DoDispose();
    }
    void DoDispose()
    {
        if (isDisposed) return; // идемпотентность Dispose
        // в любом случае освободим ресурс
        // нам нужна понадобиться проверка того, а был ли реально аллоцирован
        // ресурс (например, его выделение могло бросить исключение)
        if (resource реально был выделен)
            FreeUnmanagedResource(resource);
        // и запомним, что мы уже умерли -- это должно быть последней строкой
        isDisposed = true;
    }
}

Подстрочное примечание для знатоков: зачем нужен GC.KeepAlive(this) в конце конструктора? Нам нужно гарантировать, что финализатор не начнёт выполняться до окончания конструктора. (Он может! См. тут [раздел «Myth: An object being finalized was fully constructed»] и тут). Тело конструктора в реальном коде может быть сложнее, и гарантии, что последним в конструкторе будет обращение к this, а не работа с полученным ресурсом, достаточно сложно, и требует отдельных усилий. (Свою долю сложности привносит и оптимизатор, который может переставить куски кода.)

Использование GC.KeepAlive(this) в конце конструктора позволяет избавиться от этих проблем наиболее простым способом.

Огромное спасибо @PashaPash, @Stack, @Discord, @Pavel Mayorov и @i-one, которые своей конструктивной критикой и рекомендациями очень помогли улучшить ответ.

READ ALSO
Обновить DataSource для DataGridViewComboBox

Обновить DataSource для DataGridViewComboBox

Есть DataGridView (c# winforms vs2010 net40), в который добавляется строка (по одной):

93
Помощь с реализацией перегрузки метода

Помощь с реализацией перегрузки метода

До сегодня не доводилось часто сталкиваться с перегрузкой, прошу помощи с этим

108
Всем привет! Есть проблема с unity3d

Всем привет! Есть проблема с unity3d

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

124
Помогите с алгоритмом решения задачи

Помогите с алгоритмом решения задачи

В таблице размером 3x3, проставлены в произвольном порядке цифры от 1 до 9Требуется последовательно обойти все элементы этой таблицы таким...

126