C# のファイナライザ、Dispose() メソッド、IDisposable インターフェースについて

2021/09/02に公開

C# のメモリ解放

プログラムでメモリを解放しなくて良い

C 言語でプログラミングを学ばれた方は、メモリの解放について神経を使われているのではないかと思います。
malloc() などで確保したメモリは必ず解放しなければなりません。
そうでなければ、使われていないのに解放されないメモリが徐々にたまっていき、メモリ使用量が増えていく現象(メモリリーク)が発生します。

しかし、C# の場合はメモリを明示的に解放する必要はありません。
ガベージコレクションという仕組みにより、不要になったメモリは自動的に解放されます。

ガベージコレクションのタイミング

ではガベージコレクションはいつ起こり、メモリはいつ解放されるのでしょうか?
ガベージコレクションの条件によると、以下のいずれかの条件に当てはまる場合、ガベージコレクションが発生するとされています。

  • システムの物理メモリが少ない場合。 OS からのメモリ不足通知またはホストによって示されたメモリ不足のいずれかによって検出されます。
  • マネージド ヒープで割り当てられたオブジェクトによって使用されているメモリが、許容されるしきい値を超える場合。 このしきい値は、プロセスの進行に合わせて絶えず調整されます。
  • GC.Collect メソッドが呼び出された場合。 ほとんどの場合、ガベージ コレクターは継続して実行されるため、このメソッドを呼び出す必要はありません。 このメソッドは、主に特別な状況やテストで使用されます。

つまり、メモリが不足した場合には自動的に発生し、また GC.Collect メソッドを呼び出すことで任意のタイミングで発生させることもできるということですね。
これにより、ユーザーはわずらわしいメモリの解放を気にすることなくプログラミングに集中できるということです。

破棄可能オブジェクト

IDisposable インターフェース

.NET Framework には IDisposable というインターフェースが存在します。
このインターフェースは Dispose メソッドを持ちます。

IDisposable インターフェースを実装するオブジェクトは、使い終わった時に必ず Dispose メソッドを呼び出して破棄しなければなりません。
Task のように IDisposable を実装していながら破棄しなくて良いものもありますが、これは例外と考えてください。
TaskDispose について詳しく知りたい場合は次を参照してください。
Do I need to dispose of Tasks?

次のコードは破棄可能オブジェクトを破棄する典型的な例です。

var stream = new FileStream("foo.txt", FileMode.Open);
try
{
	// stream を使用する
}
finally
{
	stream.Dispose();
}

この例のように、オブジェクトを作成したら try finally 構文を使って確実に破棄されるようにします。
また、このコードを簡単に書けるように using 構文が用意されています。
下記のコードは上記と全く同じように働きます。

using (var stream = new FileStream("foo.txt", FileMode.Open))
{
	// stream を使用する
}

メモリの解放とオブジェクトの破棄との混同

オブジェクトを作成し、破棄するというこの一連の流れは、メモリを確保し、解放するという C 言語で多用される流れとよく似ています。
そのため、この二つを混同して Dispose メソッドによりメモリが解放されると思ってしまう人がいます。

しかし、これは間違いです。
先ほど説明したように、メモリの解放はガベージコレクションによって自動的に行われます。
それは IDisposable インターフェースを実装するオブジェクトも例外ではありません。

Dispose メソッドの役割は、メモリを解放することではなく、使い終わったオブジェクトの後処理をすることです。
例えば FileStream の場合は new によってファイルが開かれるので、Dispose はそのファイルを閉じる役割を果たします。
これを「リソースの解放」と呼びます。
開いたファイルを別のプログラムが書き換えようとすると失敗しますから、それを閉じて別のプログラムから書き換えることができるようにするのがファイルリソースを解放するということになります。

オブジェクトの破棄とは

ここまで説明してきたように、オブジェクトの破棄とはオブジェクトによって占められているメモリを解放することではありません。
もう使用しないオブジェクトの後処理をして、いつでもメモリを解放できる状態にすることを破棄と呼んでいるだけです。

IDisposable を実装しなければならない場合

Dispose はメモリの解放ではないということは、通常のオブジェクトは IDisposable を実装しなくて良いし、Dispose を呼ばなくていいことを示します。
では、どのような時にこれを実装しなければならないのでしょうか。
それは次の二つの理由のいずれかがある場合に実装してください。

  1. そのオブジェクトが有効である間、マネージリソースを保持する場合
  2. そのオブジェクトが有効である間、アンマネージリソースを保持する場合

マネージリソースを保持する場合

マネージリソースとは、IDisposable を実装するほかのオブジェクトのことです。
(マネージリソースという言葉自身には別の意味もありますが、IDisposable を語る上でマネージリソースと呼ばれているのは IDisposable を実装する他のオブジェクトのことだと思ってください)
例えば次のようなクラスを作った場合

class Class1 : IDisposable
{
    public Class1(string fileName)
    {
        Stream = new FileStream(fileName, FileMode.Open);
    }

    public Stream Stream { get; private set; }

    public void Dispose()
    {
        Stream?.Close();
        Stream = null;
    }
}

このクラスは作成されると FileStream を作成し、保持します。
FileStreamIDisposable を実装していますので、使い終わった場合には Dispose を呼ばなくてはなりません。
FileStream の場合、Close を呼ぶと Dispose が呼ばれますので、Close を呼ばなくてはなりません。

確実に FileStreamClose を呼ぶことができるよう、Class1IDisposable を実装し、使い終わった時点で Close を呼べるようにします。
これを「マネージリソースの解放」と呼びます。

先ほど述べたように、マネージリソースとは IDisposable を実装するオブジェクトのことですから、「マネージリソースを解放する」とは「保持している IDisposable を実装するオブジェクトの Dispose を呼ぶこと」に他なりません。

アンマネージリソースを保持する場合

アンマネージリソースとは、.NET オブジェクトではなく、OS やその他のフレームワークなどの保持するオブジェクトのことです。
たとえば、ファイルハンドル・ウィンドウハンドル・COM オブジェクトなどがアンマネージリソースです。
これらを使い終わった後は閉じなければなりません。
これを「アンマネージリソースの解放」と呼びます。

これらを確実に解放できるよう、アンマネージリソースを保持するクラスには IDisposable を実装します。

ファイナライザ

ガベージコレクションやプログラムの終了によってオブジェクトのメモリが解放される直前、ファイナライザと呼ばれる特別なメソッドが呼ばれます。
ファイナライザは次のように実装します。

class Class1
{
    ~Class1()
    {
        System.Diagnostics.Debug.WriteLine("メモリが解放されます。みなさんさようなら");
    }
}

このクラスのインスタンスを作るコードを書いて Visual Studio でデバッグ実行すると、そのメモリが解放される直前、出力ウィンドウに「メモリが解放されます。みなさんさようなら」と表示されます。
コンソールアプリを作り、次のコードで確かめてください。

using System.Diagnostics;

namespace ConsoleApp1
{
	class Program
	{
		static void Main(string[] args)
		{
			new Class1();
		}
	}

	class Class1
	{
		~Class1()
		{
			Debug.WriteLine("メモリが解放されます。みなさんさようなら");
		}
	}
}

これを実行するとすぐに終了し、出力ウィンドウにメッセージが残されます。

2019年10月29日追記
.NET Core 3.0 の場合、終了速度を上げるためだと思いますが、アプリ終了時にファイナライザが実行されません。そのため、このコードもメッセージを出力しません。

ファイナライザの抑制

メモリ解放時にファイナライザを実行しないよう指示するには次のように GC.SuppressFinalize を使います。

using System;
using System.Diagnostics;

namespace ConsoleApp1
{
	class Program
	{
		static void Main(string[] args)
		{
			var class1 = new Class1();
			GC.SuppressFinalize(class1);
		}
	}

	class Class1
	{
		~Class1()
		{
			Debug.WriteLine("メモリが解放されます。みなさんさようなら");
		}
	}
}

このコードを実行すると、先ほどのコードと同じようにすぐ終了しますが、メッセージは残されません。

アンマネージリソースはファイナライザでも解放する

繰り返し述べているように、IDisposable を実装したオブジェクトを作った時は最終的に必ず破棄しなければなりません。
しかし、プログラムにバグはつきもので、破棄されないままメモリが解放されてしまうことがあります。すると、そのアンマネージリソースを解放できるオブジェクトが存在していないので、プログラムが終了するまで解放できなくなります。これをリソースリークと呼びます。
このような事態に対処するため、アンマネージリソースはファイナライザでも解放するようにしましょう。

マネージリソースはファイナライザで解放しない

逆にマネージリソースはファイナライザで解放してはいけません。
解放しなくていい理由としてはいけない理由がありますが、まずしなくていい理由から説明します。

マネージリソースをファイナライザで解放しなくていい理由

すでに書いたように、マネージリソースは IDisposable を実装した .NET オブジェクトで、解放とはその Dispose メソッドを呼ぶことです。
マネージリソースが .NET オブジェクトであるということは、ファイナライザを持っていて、必要な処理を自分ですることができるということです。
だからわざわざ他のオブジェクトから破棄しなくても自分自身で破棄することができるのです。
逆にアンマネージリソースはファイナライザを持っていないので、それを保持するクラスのファイナライザで解放する必要があります。

マネージリソースをファイナライザで解放してはいけない理由

オブジェクトの解放される順序は不定です。
つまり、マネージリソースを解放しようとする時、それがすでに解放されている可能性があります。
すでに解放されたオブジェクトのメソッドを呼び出そうすると、無効なメモリにアクセスすることになります。
このため、マネージリソースはファイナライザで解放してはいけません。

Dispose パターン

Visual Studio で次のコードを書いてください。

class Class1 : IDisposable
{
}

そして class Class1 : IDisposable の行にカーソルを移動し、[Alt]+[Enter] を押してください。
するとメニューが現れ、その中に「Dispose パターンを使ってインターフェースを実装します」というものがあります。
それを選択すると、コードが次のように書き換えられます。

class Class1 : IDisposable
{
	#region IDisposable Support
	private bool disposedValue = false; // 重複する呼び出しを検出するには

	protected virtual void Dispose(bool disposing)
	{
		if (!disposedValue)
		{
			if (disposing)
			{
				// TODO: マネージド状態を破棄します (マネージド オブジェクト)。
			}

			// TODO: アンマネージド リソース (アンマネージド オブジェクト) を解放し、下のファイナライザーをオーバーライドします。
			// TODO: 大きなフィールドを null に設定します。

			disposedValue = true;
		}
	}

	// TODO: 上の Dispose(bool disposing) にアンマネージド リソースを解放するコードが含まれる場合にのみ、ファイナライザーをオーバーライドします。
	// ~Class1() {
	//   // このコードを変更しないでください。クリーンアップ コードを上の Dispose(bool disposing) に記述します。
	//   Dispose(false);
	// }

	// このコードは、破棄可能なパターンを正しく実装できるように追加されました。
	public void Dispose()
	{
		// このコードを変更しないでください。クリーンアップ コードを上の Dispose(bool disposing) に記述します。
		Dispose(true);
		// TODO: 上のファイナライザーがオーバーライドされる場合は、次の行のコメントを解除してください。
		// GC.SuppressFinalize(this);
	}
	#endregion
}

このコードについてこれまで説明してきました。

Class1IDisposable を実装しています。
つまりそれは public void Dispose() を実装しているということです。
このメソッドを呼び出すと後処理がなされます。

Dispose の中では Dispose(true)GC.SuppressFinalize(this) の二つを呼び出しています。
このうち GC.SuppressFinalize についてはコメントアウトされています。
これはメモリが解放されるときにファイナライザを呼び出さないようにするメソッドですので、コメントアウトを外すとメモリ解放時にファイナライザが走らなくなります。

ファイナライザはアンマネージリソースを解放する時に使いますので、Dispose(true) を呼び出した際には既に処理が終わっており、必要ありません。

さて、ファイナライザも同じくコメントアウトされています。
先ほど述べたように、ほとんどの場合必要ないからです。

そして、Dispose() およびファイナライザから呼び出されている、主となる処理が Dispose(bool disposing) です。
Dispose() から呼び出された時には引数 disposing が true でファイナライザから呼び出された時には disposing は false です。
よって、disposing が true の時のみマネージリソースを解放してください。
true の時も false の時もアンマネージリソースはともに解放します。

disposedValue は、Dispose(bool disposing) がすでに呼び出された時 true となるフラグです。
すでに呼び出された時にはリソースの解放は終わっているのでそれ以上する必要はありません。

さて、ややこしい Dispose パターンですが、これはアンマネージリソースがある場合に使うパターンです。
無い場合はもっと簡単に書けます。

class Class1 : IDisposable
{
	#region IDisposable Support
	private bool disposedValue = false; // 重複する呼び出しを検出するには

	public virtual void Dispose()
	{
		// すでに処理されている場合は何もしない
		if (disposedValue)
		{
			return;
		}

		// ここでマネージリソースを解放

		disposedValue = true;
	}
	#endregion
}

このパターンだけでほとんどの場合に対応できます。
また、disposedValue を使わなくても、前の方で書いた

Stream?.Close();
Stream = null;

のようにマネージリソースの数が少ない場合には、プロパティ に null を入れることによってフラグとする方法もあります。

2019 年 10 月 29 日追記
Visual Studio 2019 では、継承可能なオブジェクトに Dispose メソッドを実装した場合、Dispose(bool) メソッドを実装しなければ警告が出ます。

Dispose を呼び出さなければならない理由

先ほど述べたように、IDisposable を実装するクラスはファイナライザによって後始末されるので、Dispose を明示的に呼び出さなくてもガベージコレクションによってメモリが解放される時に自動的に後処理が行われます。
それならば usingtry/finally を使ってわざわざ明示的に Dispose を呼び出さなくてもメモリと同じくガベージコレクションに任せればいいじゃないかと思われるかもしれません。

しかし、たとえば一つのプログラムの実行中に同じファイルを二回開かなければならなくなったとしましょう。
ガベージコレクションはメモリが足りなくなるまで行われませんが、メモリをそれほど大量に使わないプログラムの場合、終了するまでファイナライザが呼び出されないことはよくあることです。
破棄をファイナライザに任せた場合、同じファイルを二回目に開こうとしたとき、そのリソースが解放されていないために開けないということになります。

オブジェクトの使用が終わったら速やかに破棄しなければならないというのは、リソースを確実に解放して再利用できるようにするためです。

まとめ

  • オブジェクトの破棄はメモリの解放ではなくリソースの占有をやめることである
  • Dispose メソッドはリソースの占有をやめるメソッドであり、ファイナライザはメモリ解放の直前に実行されるもので、この二つは全く別のものである
  • マネージ(管理)リソースとは IDisposable を実装したオブジェクトのことである
  • アンマネージ(非管理)リソースとはファイルハンドルやウィンドウハンドルなどの「.NET で管理されないリソース」のことである。
  • マネージリソースはファイナライザで破棄してはならない
  • アンマネージリソースはファイナライザで破棄しなくてはならない
  • アンマネージリソースを持たない場合、Dispose パターンを使う必要はない

執筆日: 2018/09/21

GitHubで編集を提案

Discussion