色んな事を書く

シンプルさを極めたエンジニアになりたい

.NET の Dispose パターンについて

.NET の Disponse パターンって何で必要なんだっけってのとどうやって実装するんだっけってのをまとめときます。

前提

C# は、ガベージコレクション (以下 GC) によって、メモリ管理を大部分自動化しています。ヒープのどのアドレスにオブジェクトを配置するのか、使われなくなったオブジェクトはどれか、どのタイミングでそれらのオブジェクトを破棄するのか、など全て GC がよしなにやってくれています。

しかし、これらのメモリ管理はマネージドリソースのみに行われます。アンマネージリソースを扱う場合は、ガベージコレクションを待たずにリソースを解放する必要があります。ここで言っているアンマネージドリソースとはファイルストリームや DB コネクションのことです。都度都度リソース解放の処理を書くのは面倒なので、Dispose パターンが登場となります。

Dispose パターンとは?

Disposeパターンとは、アンマネージリソースを明示的に解放するためのデザインパターンです。このパターンは、IDisposable インターフェースを実装することで実現されます。IDisposable インターフェースを実装することで、オブジェクトが不要になった時に、明示的にリソースを解放することができます。

Finalizer を使ってリソースの破棄処理を書くことも出来はしますが、以下のデメリットがあります。

  • あるオブジェクトが GC によって破棄可能とマークされてからファイナライザーが呼び出されるまでの時間が非決定的である
    • 不要なリソースの破棄までに時間を要するので、大規模のリソースを確保したままやリソースの枯渇に繋がってしまうかもしれません
  • あるオブジェクトが GC によって破棄可能とマークされると、実際にファイナライザーが呼び出され破棄されるのは次の GC のタイミングである
    • これにより破棄対象のオブジェクトの寿命が延びてしまうことになります

Dispose パターンの実装方法

Dispose パターンを実装するためには、以下の手順が必要です。

  1. IDisposable インターフェースを実装する。
  2. Dispose メソッドを実装する。
  3. ファイナライザをオーバーライドする。
  4. Dispose メソッド内で、アンマネージリソースを解放するコードを実装する。

Dispose パターンを実装する際には、以下の注意点があります。

  1. Dispose メソッドは複数回呼び出されても、問題が起こらないように実装する必要がある。
  2. インスタンスが保持しているリソースは全て解放する必要がある
  3. 必要であれば base クラスの Dispose を呼び出す
  4. ファイナライズキューの数を減らすためにインスタンスのファイナライズを抑制する
  5. Dispose は OutOfMemoryException のような予測不可能な状況以外で例外を投げるべきでない

これらの注意事項は IDsposable.cs のコメントに書いてあります。

以下は、ファイルの読み込みと書き込みを行うためのクラスの例です。このクラスでは、ファイルを開いた時にアンマネージリソースとしてファイルハンドルを使用しています。そのため、Dispose パターンを実装する必要があります。

public class FileHandler : IDisposable
{
    private readonly FileStream _fileStream;
    private bool _disposed;

    public FileHandler(string filePath)
    {
        _fileStream = File.Open(filePath, FileMode.OpenOrCreate);
        _disposed = false;
    }

    public void Write(string text)
    {
        if (_disposed)
        {
            throw new ObjectDisposedException("FileHandler");
        }

        var bytes = Encoding.UTF8.GetBytes(text);
        _fileStream.Write(bytes, 0, bytes.Length);
    }

    public string Read()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException("FileHandler");
        }

        _fileStream.Position = 0;
        var bytes = new byte[_fileStream.Length];
        _fileStream.Read(bytes, 0, bytes.Length);
        return Encoding.UTF8.GetString(bytes);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }

        if (disposing)
        {
            _fileStream.Dispose();
        }

        _disposed = true;
    }
    
    ~FileHandler()
    {
        Dispose(false);
    }
}

上記のコードには、IDisposable インターフェースを実装することで、Dispose パターンが実現されています。Dispose メソッドでは、アンマネージリソースである fileStream を解放しています。また、disposed フラグを使用して、複数回の Dispose メソッドの呼び出しに対応しています。

全てのリソース破棄の処理は Dispose(false) に集約されている必要があります。なぜなら、ファイナライザから呼び出されたかどうかを区別するためです。

ファイナライザからの呼び出しで Dispose(false) としているのは、ファイナライズの呼び出し順序が非決定的であるためです。あるインスタンスのリソースを破棄する際に別インスタンスDispose() を呼び出そうとすると、そのインスタンスはすでにファイナライズされているかもしれません。なので Dispose(bool)Dispose() とファイナライザのどちらから呼び出されたのか区別する必要があります。

今回の例ではファイナライザをオーバーライドしましたが、慎重に検討すべきとのことです。なぜならファイナライザが呼び出される際の前提を置くことが出来ず、実装難易度が高いためです。基底クラスがファイナライズ可能な型 (ファイナライザのオーバーライドを持っている) の場合、派生クラスではオーバーライドしてはいけません。

If the base class already is finalizable and implements the Basic Dispose Pattern, you should not override Finalize again. You should instead just override the Dispose(bool) method to provide additional resource cleanup logic.

Dispose Pattern - Framework Design Guidelines | Microsoft Learn

StreamWriter, StreamReader の leaveOpen

余談ですが、StreamWriter や StreamReader の公司ラクダでは leaveOpen というパラメータを渡すことが出来ます。これらのクラスは確定で Stream コンストラクタで受け取るのですが、デフォルトだと StreamWriter/StreamReader を破棄する時に受け取った Stream も一緒に破棄するようです。そのような挙動をしてほしくない時に leaveOpen を渡すのでしょう。

StreamWriter Constructor (System.IO) | Microsoft Learn

StreamReader Constructor (System.IO) | Microsoft Learn

public class StreamWriterLeaveOption
{
    public void Test()
    {
        using var stream = new MemoryStream();
        Setup(stream);
        stream.Position = 0;
    
        using var streamReader = new StreamReader(stream, encoding: Encoding.UTF8);
        var line = streamReader.ReadLine();
    }

    private void Setup(Stream stream)
    {
        using var writer = new StreamWriter(stream, leaveOpen: true, encoding: Encoding.UTF8);
        writer.WriteLine("あああ");
    }
}

こんな感じですかね?leaveOption を true にしておかないと、StreamWriter の破棄時に Memory Stream も破棄されて、StreamReader では読み取れなくなってしまいます。メソッドやクラスを跨いで Stream を取りまわしたい時には便利そう。ただ Position が変わってしまうからバグに繋がりそうだけど。

まとめ

C# では、ガベージコレクションによってメモリ管理が自動化されていますが、アンマネージリソースを扱う場合は、明示的にリソースを解放する必要があります。そのために、Dispose パターンが必要となります。IDisposable インターフェースを実装することで、不要になったオブジェクトのリソースを明示的に解放することができます。しかし、Dispose パターンを実装する際には、呼び出し順序や複数回の呼び出しに注意して実装する必要があることを忘れないでください。