【C#】IDisposable を継承する際に Dispose パターンの知識が必要になる理由

SonarLint を使っていなければ気づかなかったシリーズ。
C# は C++ と比べて変なテクニックを山ほど覚える必要がなく、とても扱いやすい言語だと思っていたのですが、最近はそうでもないと思うようになってきました。

using System;

// ヒャッハー集団のベースクラス
public class HyahhaaBase : IDisposable {
  public virtual void Dispose() {
    // アンマネージドリソースを解放する処理
  }
}

// モヒカンヒャッハークラス
sealed class MohicanHyahhaa : HyahhaaBase {
  public override void Dispose() {
    // 子クラスのアンマネージドリソースを解放する処理
  }
}

このコードはメモリリークします。

SonarLint については以下の記事で簡単に紹介しています。

その他の、SonarLint を使っていなければ気づかなかったシリーズ。

継承することを前提としたベースクラスでは IDisposable の扱いに注意が必要です。

IDisposable の扱い方については、SonarLint の警告で気が付きました。
.Net 5 から導入されたファイナライザについても知らなかったので、調べるきっかけを作ってくれて、とても助かりました。

どこが悪いのか?

冒頭に記載したコードは、ベースクラス側でもアンマネージドリソースを持っていて、子クラス側でも別のアンマネージドリソースを持っている状況でメモリリークします。

sealed class MohicanHyahhaa : HyahhaaBase {
  public override void Dispose() {
    base.Dispose(); // ベース(親)クラスの Dispose を明示的に呼ぶ必要がある。
    // 子クラスのアンマネージドリソースを解放する処理
    // 親クラスを先に解放すると問題がある場合は、先に子クラス側の解放処理を行い
    // ↓ に base.Dispose(); を書く。
  }
}

Object.Finalize をオーバーライドするときも同様の問題が発生します。
ただ、Finalize は、あまりあてにならないので、IDisposable を使うことになります。
以下の Microsoft 公式ドキュメントに、その旨の記載があります。

ファイナライザー???
ファイナライザー(Object.Finalize メソッド)は .Net 5.0 で追加された機能です。
ファイナライザーとデストラクタは同じものではありませんが、デストラクタが呼ばれるときにファイナライザーが処理されるので、似たようなものです。
.Net 5 以降では、デストラクタは必要がなければ実行されません。
ブロックを出たとき、null 代入時にデストラクタが実行されるとは限らず、ガベコレに未解放のオブジェクトが溜まらないと、強制的にガベコレを実行しても回収してくれません。
※GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); を何度実行しても回収してくれない。
なので、.Net 5 以降では、クラスが持っているリソースがデストラクタで解放されることを想定したコードを書いても、期待通りのタイミングでは処理されません。
対象になるのは .Net 5 以降なので、以下のバージョンならデストラクタが処理されるタイミングを選べます。

.Net Core 全てのバージョン
.Net Framework 全てのバージョン
.Net Standard 全てのバージョン

※.Net 5 以降と、上記の .Net ほにゃらら は全くの別物だと思った方が良いです。

安全な IDisposable の使い方

以下の条件を満たすよう実装する必要があります。
Dispose パターンと呼ぶみたいです。

以下は、SonarLint が IDisposable の扱い方で問題がある場合に警告を出す条件の抜粋です。

sealed classes are not checked.

訳:sealed class は対象外。

sealed class Hoge : IDisposable

sealed を付けて継承しないことを明示しているクラスなら気にしなくて良い。
つまり、IDisposable を実装しているクラスを継承する・派生させる場合に問題が起きます。

If a base class implements IDisposable your class should not have IDisposable in the list of its interfaces. In such cases it is recommended to override the base class’s protected virtual void Dispose(bool) method or its equivalent.

訳:
ベースクラス(親クラス)が IDisposable を実装している場合、子クラス側で IDisposable を継承するべきではない。
その場合、protected virtual void Dispose(bool) メソッドをオーバーライドするか、同様の実装を行うことが推奨される。

class Base : IDisposable {
  public void Dispose() {}
  protected virtual void Dispose(bool disposing) {}
}
// Base が IDisposable を継承しているので、子クラス側では継承しない。
class Derived : Base {
  // Dispose はオーバーライドせず、Dispose(bool) をオーバーライドする。
  protected override void Dispose(bool disposing) {}  
}
The class should not implement IDisposable explicitly, e.g. the Dispose() method should be public.

訳:クラスは IDisposable を明示的に実装するべきではない、例えば、Dispose() メソッドは public にするべき。

ちょっと意味が分からないです。

これについては言及しなくても、言語仕様で public になることが決まってしまいます。
別の意図があると思うのですが、それが分かりません。

The class should contain protected virtual void Dispose(bool) method. This method allows the derived classes to correctly dispose the resources of this class.

訳:
クラスは protected virtual void Dispose(bool) メソッドを持つべき。
このメソッドは、継承クラスがリソースを解放できるようにする。

using System;
class Base : IDisposable {
  public void Dispose() {}
  protected virtual void Dispose(bool disposing) {}
}
class Derived : Base {
  protected override void Dispose(bool disposing) {}
}
The content of the Dispose() method should be invocation of Dispose(true) followed by GC.SuppressFinalize(this)

訳:Dispose() メソッドでは Dispose(true) に続けて GC.SuppressFinalize(this) を実行するべき。

using System;
class Base : IDisposable {
  public void Dispose() {
    Dispose(true);
    GC.SuppressFinalize(this);
  }
  protected virtual void Dispose(bool disposing) {}
}

GC.SuppressFinalize(this) は、ガベコレ実行時にファイナライザーが処理されないようにする命令です。

Dispose(bool) でリソースの解放を行っているので、更にファイナライザーが処理されると、デストラクタで Dispose を呼び出している場合、リソースが二重に解放される可能性があります。
既にリソースが解放されているなら、解放処理をスキップする…という処理になっていれば良いのですが、その処理がない場合、解放済みのリソースを再度解放することになり、メモリアクセス違反や、null 解放の例外がスローされる可能性があります。
その場合、例外をキャッチする処理がなければ、アプリがクラッシュします。

あとは純粋に、解放処理が2回走るのが無駄です。

If the class has a finalizer, i.e. a destructor, the only code in its body should be a single invocation of Dispose(false).

訳:クラスがファイナライザー(デストラクタ)を持つ場合、Dispose(false) だけ行うべき(それ以外のコードを持つべきでない)。

using System;
class Base : IDisposable {
  ~Base() {
    // デストラクタは Dispose(false) だけ行うべき。
    Dispose(false);
  }
  public void Dispose() {
    Dispose(true);
    GC.SuppressFinalize(this);
  }
  protected virtual void Dispose(bool disposing) {}
}
If the class inherits from a class that implements IDisposable it must call the Dispose, or Dispose(bool) method of the base class from within its own implementation of Dispose or Dispose(bool), respectively. This ensures that all resources from the base class are properly released.

訳:
IDisposable を実装しているクラスを継承する場合、継承クラスが持つ Dispose() または Dispose(bool) メソッド内で、ベースクラスの Dispose() または、Dispose(bool) メソッドを必ず呼ばなければならない(should ではなく must)。
そうすることで、ベースクラスが持つリソースも、継承クラスが持つリソースも適切に解放されるようになる。

using System;
class Base : IDisposable {
  public void Dispose() {
    Dispose(true);
    GC.SuppressFinalize(this);
  }
  protected virtual void Dispose(bool disposing) {}
}
class Derived : Base {
  protected override void Dispose(bool disposing) {
    base.Dispose(disposing);
  }
}
Dispose パターンの確認用コード
namespace test;

static class Program {
  static void Main() {
    Console.WriteLine("start GC.MaxGeneration={0}", GC.MaxGeneration);
    Test();
    Console.WriteLine("finished");
  }
  static void CreateB() {
    Console.WriteLine("create B");
    var b = new B();
    b.Hoge();
    Console.WriteLine("destroy B");
  }
  static void Test() {
    while (true) {
      CreateB();

      string? s = Console.ReadLine();
      if (string.IsNullOrEmpty(s)) { continue; }
      if (s == "q") { break; }
    }
  }
}

class A : IDisposable {
  protected List<int> list = new ();
  protected int count = 0;
  public A() {
    Console.WriteLine("A");
    for (int i = 0; i < 1000000; i++) { this.list.Add(this.count++); }
  }
  ~A() {
    Console.WriteLine("~A");
    this.Dispose(false);
  }
  public void Dispose() {
    Console.WriteLine("A.Dispose");
    this.Dispose(true);
    GC.SuppressFinalize(this);
  }
  protected virtual void Dispose(bool disposing) {
    Console.WriteLine("A.Dispose({0})", disposing);
  }
}

sealed class B : A {
  public B() { Console.WriteLine("B"); }
  ~B() { Console.WriteLine("~B"); }
  public void Hoge() { Console.WriteLine("list.Count={0}", base.list.Count); }
  protected override void Dispose(bool disposing) {
    Console.WriteLine("B.Dispose({0})", disposing);
    base.Dispose(disposing);
  }
}

上記は Visual Studio 2022 .Net 7 用のコードです。
未確認ですが、.Net 5 や 6 でも挙動は変わらないと思います。

実行すると、CreateB() メソッド内で、クラス B を生成します。
B のベースクラス A は、100万個の int 型データを生成して保持しています。
ガベコレをできるだけ早く走らせるために、この大量のデータが必要です。

エンターキーを押す度に CreateB() が実行されます。
CreateB() を抜けるときに、クラス B のインスタンスは破棄されますが、デストラクタは走りません。

何度かエンターキーを押すと、ようやくガベコレが走ります。
Dispose パターンに対応することで、クラス B のデストラクタと Dispose、クラス A のデストラクタと Dispose が処理されることが確認できます。

.Net 5 以降では必要なければデストラクタを実装する必要はありません。
デストラクタが処理されることを確認するために実装しているだけです。

デストラクタを実装することで、ガベコレに処理対象として登録されてしまいます。
デストラクタで何も処理しないなら、メモリとCPU時間の無駄になります。

デストラクタを実装する場合、ベースクラス(A)側で Dispose(bool) メソッドを呼ぶ必要があります。
継承クラス(B)側もデストラクタを持ちますが、継承クラス側では Dispose(bool) を呼び出さなくても適切に Dispose が呼ばれているので、継承クラス側に Dispose(bool) は必要ないと分かります。

アンマネージドリソースを持たなくても IDisposable は便利

本来の用途からは外れますが、クラスが持つリソースを好きなタイミングで解放できるため IDisposable は便利です。

sealed class B : IDisposable {
  StreamReader reader = new ("hoge.txt");
  public void Hoge() { throw new InvalidOperationException(); }
  public void Dispose() { reader.Dispose(); }
}

//////////////////////////////

// 何かのメソッドの中
using(var b = new B()) {
  // 何かの処理
}
// ↑のブロックを抜けたときに Dispose メソッドが呼ばれる

//////////////////////////////

// 何かのメソッドの中
var b = new B();
try {
  b.Hoge();
} catch (Exception e) {
  Console.WriteLine(e.Message);
} finally {
  b.Dispose();
}
// 例外をキャッチしてもしなくても finally でリソースを破棄できる
参考にした Web ページ
↑ .Net 5 でファイナライザーが実装されることになった原因

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です