Pull to refresh

Как применять IDisposable и финализаторы: 3 простых правила

Reading time 7 min
Views 61K
Original author: Stephen Cleary

От переводчика


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

Как применять IDisposable и финализаторы: 3 простых правила


Документация Microsoft о применении IDisposable довольно запутанная. На самом деле она упрощается до трех простых правил.

Правило первое: не применять (до тех пор, пока это действительно не понадобится)


Реализуя интерфейс IDisposable, вы не создаете деструктор. Помните, что в среде .NET есть сборщик мусора, который работает достаточно хорошо, чтобы не присваивать null многочисленным переменным.

Существует только две ситуации, когда необходимо реализовывать IDisposable. Посмотрите на класс и определите, нужен ли вам этот интерфейс:
  • В классе есть неуправляемые ресурсы
  • В классе есть управляемые (IDisposable) ресурсы

Обратите внимание, что ресурсы должны освобождать только те классы, которым эти ресурсы принадлежат. В частности, класс может иметь ссылку на общий ресурс — в этом случае вы не должны освобождать его, поскольку другие классы могут продолжать использовать этот ресурс.

Вот пример кода, который пишут многие начинающие программисты:

// Пример неправильного применения IDisposable.
public sealed class ErrorList : IDisposable
{
    private string category;
    private List<string> errors;

    public ErrorList(string category)
    {
        this.category = category;
        this.errors = new List<string>();
    }

    // (методы добавления/отображения ошибок)

    // Совершенно необязательноpublic void Dispose()
    {
        if (this.errors != null)
        {
            this.errors.Clear();
            this.errors = null;
        }
    }
}

Некоторые программисты (особенно те, кто раньше работал с C++) идут еще дальше и добавляют финализатор:

// Пример некорректного и подверженного ошибкам применения IDisposable.
public sealed class ErrorList : IDisposable
{
    private string category;
    private List<string> errors;

    public ErrorList(string category)
    {
        this.category = category;
        this.errors = new List<string>();
    }

    // (методы добавления/отображения ошибок)

    // Совершенно необязательно
    public void Dispose()
    {
        if (this.errors != null)
        {
            this.errors.Clear();
            this.errors = null;
        }
    }

    ~ErrorList()
    {
        // Очень плохо!</font>
        // Это может вызвать исключение в потоке финализатора и нарушить работу всего приложения!</font>
        this.Dispose();
    }
}

Пример правильная реализация IDisposable для описанного класса:

// Это пример корректного применения IDisposable.
public sealed class ErrorList
{
    private string category;
    private List<string> errors;

    public ErrorList(string category)
    {
        this.category = category;
        this.errors = new List<string>();
    }
}

Все верно. Правильное применение интерфейса IDisposable для этого класса — не применять его! Когда экземпляр ErrorList становится недоступным, сборщик мусора автоматически освобождает занятую им память.

Запомните эти два критерия для применения IDisposable — класс должен владеть неуправляемыми или управляемыми ресурсами. Можно пройтись по пунктам:

1. Класс ErrorList владеет неуправляемыми ресурсами? Нет, не владеет.
2. Класс ErrorList владеет управляемыми ресурсами? Запомните, «управляемые ресурсы» — это классы, реализующие IDisposable. Проверьте каждый член класса:
      1. Класс string реализует IDisposable? Нет, не реализует.
      2. Класс List реализует IDisposable? Нет, не реализует.
      3. Если никто из членов не реализует IDisposable, класс ErrorList не владеет управляемыми ресурсами.
3. Поскольку ErrorList не владеет ни управляемыми, ни неуправляемыми ресурсами, он не требует реализации интерфейса IDisposable.

Правило второе: для класса, владеющего управляемыми ресурсами, реализуйте IDisposable (но не финализатор)


Интерфейс IDisposable имеет только один метод: Dispose. Реализуя этот метод, вы должны выполнить одно важное обязательство: даже многократный вызов Dispose должен происходить без ошибок.

Реализация метода Dispose подразумевает, что: этот метод вызывается не из потока финализатора, экземпляр объекта еще не был собран сборщиком мусора и конструктор объекта успешно отработал. Эти предположения делают безопасным доступ к управляемым ресурсам.

Размещение финализатора в классе, который владеет только управляемыми ресурсами, может приводить к ошибкам. Этот пример кода может вызвать исключение в потоке финализатора и нарушит работу приложения:

// Пример неправильного и подверженного ошибкам использования финализатора.
public sealed class SingleApplicationInstance
{
    private Mutex namedMutex;
    private bool namedMutexCreatedNew;
 
    public SingleApplicationInstance(string applicationName)
    {
        this.namedMutex = new Mutex(false, applicationName, out namedMutexCreatedNew);
    }
 
    public bool AlreadyExisted
    {
        get { return !this.namedMutexCreatedNew; }
    }
 
    ~SingleApplicationInstance()
    {
        // Плохо, плохо, плохо!!!
        this.namedMutex.Close();
    }
}

Неважно, реализует ли SingleApplicationInstance интерфейс IDisposable, сам факт доступа к управляемым объектам в финализаторе — верный путь к ошибкам.

Вот пример класса, в котором отсутствует финализатор, а интерфейс IDisposable реализуется правильным, но чересчур сложным способом:

// Пример излишне сложной реализации IDisposable.
public sealed class SingleApplicationInstance : IDisposable
{
    private Mutex namedMutex;
    private bool namedMutexCreatedNew;
 
    public SingleApplicationInstance(string applicationName)
    {
        this.namedMutex = new Mutex(false, applicationName, out namedMutexCreatedNew);
    }
 
    public bool AlreadyExisted
    {
        get { return !this.namedMutexCreatedNew; }
    }
 
    // Черезчур сложно
    public void Dispose()
    {
        if (namedMutex != null)
        {
            namedMutex.Close();
            namedMutex = null;
        }
    }
}

Если класс владеет управляемыми ресурсами, он может в свою очередь вызывать у них метод Dispose. Никакого дополнительного кода не нужно. Помните, что некоторые классы переименовывают «Dispose» в «Close», поэтому реализация метода Dispose может состоять исключительно из вызовов методов Dispose и Close.

Равноценная и более простая реализация:

// Пример правильной реализации IDisposable.
public sealed class SingleApplicationInstance : IDisposable
{
    private Mutex namedMutex;
    private bool namedMutexCreatedNew;
 
    public SingleApplicationInstance(string applicationName)
    {
        this.namedMutex = new Mutex(false, applicationName, out namedMutexCreatedNew);
    }
 
    public bool AlreadyExisted
    {
        get { return !this.namedMutexCreatedNew; }
    }
 
    public void Dispose()
    {
        namedMutex.Close();
    }
}

Эта реализация метода Dispose полностью безопасна. Он может вызываться сколько угодно раз, поскольку каждая реализация IDisposable дочерних ресурсов в свою очередь безопасна. Это транзитивное свойство должно использоваться для написания подобных простых реализаций метода Dispose.

Правило третье: для класса, владеющего неуправляемыми ресурсами, реализуйте IDisposable и финализатор


Класс, который владеет одним неуправляемым ресурсом, не должен отвечать за что-то еще. Его единственное обязательство — закрывать этот ресурс.

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

Классы не должны отвечать за управляемые и неуправляемые ресурсы вместе. Написать такой класс возможно, но очень сложно сделать это правильно. Поверьте мне; лучше и не пытайтесь. Даже если в классе отсутствуют ошибки, его сопровождение превращается в кошмар. К выходу .NET 2.0 в Microsoft переписали множество классов из BCL (базовой библиотеки классов) — разделяли их на владеющие неуправляемыми и управляемыми ресурсами.

Примечание: наличие такой сложной официальной документации по IDisposable объясняется тем, что в Microsoft полагают, что ваш класс будет содержать оба типа ресурсов. Это пережиток .NET 1.0, оставленный для обратной совместимости. Даже классы, написанные в Microsoft, не следуют этому старому шаблону (они были изменены в .NET 2.0 с использованием шаблона, описанного в этой статье). FxCop будет говорить, что вам необходимо «правильно» реализовать IDisposable (т.е. использовать старый шаблон). Не слушайте его — FxCop ошибается.

Класс должен выглядеть похожим образом:

// Пример корректной реализации IDisposable.
// В идеале нужно еще наследоваться от SafeHandle.
public sealed class WindowStationHandle : IDisposable
{
    public WindowStationHandle(IntPtr handle)
    {
        this.Handle = handle;
    }
 
    public WindowStationHandle()
        : this(IntPtr.Zero)
    {
    }
 
    public bool IsInvalid
    {
        get { return (this.Handle == IntPtr.Zero); }
    }
 
    public IntPtr Handle { get; set; }
 
    private void CloseHandle()
    {
        // Если хэндл нулевой, ничего не делаем
        if (this.IsInvalid)
        {
            return;
        }
 
        // Закрытие хэндла, запись ошибок
        if (!NativeMethods.CloseWindowStation(this.Handle))
        {
            Trace.WriteLine("CloseWindowStation: " + new Win32Exception().Message);
        }

        // Установка хэндлу нулевого значения
        this.Handle = IntPtr.Zero;
    }
 
    public void Dispose()
    {
        this.CloseHandle();
        GC.SuppressFinalize(this);
    }
 
    ~WindowStationHandle()
    {
        this.CloseHandle();
    }
}
 
internal static partial class NativeMethods
{
    [DllImport("user32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static extern bool CloseWindowStation(IntPtr hWinSta);
}

В конце метода Dispose стоит вызов GC.SuppressFinalize(this). Это гарантирует, что финализатор объекта вызываться не будет.

Если же Dispose не будет вызван явно, то в конечном итоге сработает финализатор и закроет хэндл.

Метод CloseHandle сначала проверяет, является ли хэндл нулевым. Затем закрывает его, не выбрасывая возможных исключений: т.к. CloseHandle может быть вызван из финализатора, и выброс исключения приведет к остановке процесса. Завершается метод CloseHandle обнулением хэндла. Т.о. его можно будет вызывать сколько угодно раз. Это, в свою очередь, делает безопасным многократный вызов Dispose. Проверку хэндла можно было бы поместить в Dispose, однако размещение этой проверки в CloseHandle позволяет передавать нулевые хэндлы в конструктор и присваивать их свойству Handle.

Причина, по который SuppressFinalize вызывается после CloseHandle, заключается в том, что если в Dispose при закрытии хэндла произойдет ошибка, то финализатор все равно будет вызван. Эта причина подробно обсуждалась в блоге Джо Даффи (тоже очень хорошая статья, кстати — прим. пер.), хотя и является достаточно слабым аргументом. Разница существовала бы лишь в том случае, если бы метод CloseHandle при вызове из финализатора закрывал хэндл другим способом. Так делать, конечно, можно, но не рекомендуется.

Важно! Класс WindowStationHandle не содержит хэндл расположения окна и не знает ничего о создании или открытии расположения окна. Эти функции (как и другие, связанные с окнами) — задача другого класса (вероятно, «WindowStation»). Это помогает создавать корректные реализации, т.к. каждый финализатор должен выполняться даже на объектах с не до конца отработавшими из-за выброса исключения контрукторами. На практике так делать затруднительно, и это еще одна причина, почему класс-обертка должен быть разделен на класс, ответственный за закрытие хэндла, и на собственно класс-обертку.

Примечание: приведенное выше решение — самое простое, и имеет свои недостатки. Например, если поток завершается сразу после выполнения функции размещения ресурса, то может произойти утечка этого ресурса. Если вы упаковываете IntPtr-хэндл, то лучше наследоваться от класса SafeHandle. Если же вам нужно пойти дальше и поддержать надежное освобождение ресурсов, то тут все быстро становится очень запутанным (еще одна хорошая статья — прим. пер.)!
Tags:
Hubs:
+25
Comments 24
Comments Comments 24

Articles