Finalizable ObjectsとDisposable Objectsで、Finalize() と Dispose() それぞれの役割を整理しました。実際のコードでは、両者を組み合わせた 「Dispose パターン」 として使うのが定石です。
この記事では、状況に応じた 4 つのパターンを 動くサンプルコード で示し、それぞれの「書き方の意図」を解説します。
パターン 1:マネージドのみのシンプルな Disposable
最も基本的なケースです。アンマネージドリソースを直接持たず、IDisposable フィールドだけを持つ場合、ファイナライザは不要です。
public sealed class LogWriter : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public LogWriter(string path)
{
_writer = new StreamWriter(path, append: true);
}
public void WriteLine(string message)
{
ThrowIfDisposed();
_writer.WriteLine($"{DateTime.UtcNow:O} {message}");
}
public void Dispose()
{
if (_disposed) return;
_writer.Dispose();
_disposed = true;
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(LogWriter));
}
}
ポイント
sealedにすることで、継承による Dispose の挙動の乱れを防ぐ_disposedフラグ で多重 Dispose を安全に- ファイナライザは書かない:
StreamWriter自身が責任を持つので二重管理しない - 使用後は
ObjectDisposedExceptionを投げる
このパターンが 最も多く書くケース です。「アンマネージドリソース=持っていない」と確信できるなら、ファイナライザを書く理由はありません。
using var log = new LogWriter("app.log");
log.WriteLine("Hello, world!");
パターン 2:SafeHandle を使うアンマネージド対応(推奨)
アンマネージドリソース(OS ハンドルなど)を扱う場合、自前でファイナライザを書くより SafeHandle を使う のが現代の推奨です。SafeHandle 自身が CriticalFinalizerObject を継承しており、確実な解放と安全性が保証されています。
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
internal sealed class MyFileHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public MyFileHandle() : base(ownsHandle: true) { }
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
protected override bool ReleaseHandle()
{
return CloseHandle(handle);
}
}
public sealed class NativeFile : IDisposable
{
private readonly MyFileHandle _handle;
private bool _disposed;
public NativeFile(string path)
{
_handle = NativeMethods.OpenFile(path); // SafeHandle を返す P/Invoke
}
public void Dispose()
{
if (_disposed) return;
_handle.Dispose(); // SafeHandle が確実に閉じる
_disposed = true;
}
}
ポイント
NativeFile自身にファイナライザは不要SafeHandleがファイナライザを内蔵しているので、Dispose忘れにも対応- アンマネージドリソースを持つ型は
SafeHandleでラップするのが最初の選択肢
SafeHandle の詳細も参照してください。
パターン 3:継承を考慮した完全な Dispose パターン
SafeHandle が使えない、あるいは派生クラスでも追加リソースを解放したい——そんなときに使う、「教科書通り」の Dispose パターン です。protected virtual Dispose(bool disposing) を中心とした古典的な構造になります。
public class ResourceHolder : IDisposable
{
// アンマネージド
private IntPtr _nativePtr;
// マネージド
private FileStream? _stream;
private bool _disposed;
public ResourceHolder(string path, int nativeSize)
{
_nativePtr = Marshal.AllocHGlobal(nativeSize);
_stream = new FileStream(path, FileMode.OpenOrCreate);
}
// 派生クラスはここをオーバーライドして拡張する
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// マネージドリソース:Dispose() 経由のときだけ触れる
_stream?.Dispose();
_stream = null;
}
// アンマネージドリソース:ファイナライザ経由でも安全に触れる
if (_nativePtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativePtr);
_nativePtr = IntPtr.Zero;
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // ファイナライザを抑制
}
~ResourceHolder()
{
Dispose(disposing: false);
}
}
disposing パラメータの意味
| 値 | 呼び出し元 | できること |
|---|---|---|
true |
Dispose() から |
マネージド・アンマネージド両方 |
false |
ファイナライザから | アンマネージドのみ |
ファイナライザ経由(disposing == false)では、マネージドフィールドが既にファイナライズされている可能性がある ため、それらに触れてはいけません。これが disposing フラグの存在理由です。
GC.SuppressFinalize(this) の意味
Dispose() で正しく解放されたなら、ファイナライザを呼ぶ必要はありません。GC.SuppressFinalize(this) を呼ぶことで、
- ファイナライズキューから外される
- 世代昇格が起きない
- GC 負荷が下がる
という効果が得られます。Dispose() の最後に必ず書く のが定石です。
派生クラスの書き方
public sealed class ExtendedHolder : ResourceHolder
{
private readonly Timer _timer;
public ExtendedHolder(string path, int size, TimerCallback cb)
: base(path, size)
{
_timer = new Timer(cb);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_timer.Dispose();
}
base.Dispose(disposing); // 必ず親に委譲
}
}
派生側は Dispose(bool) をオーバーライドし、最後に base.Dispose(disposing) を必ず呼ぶ のがルールです。
パターン 4:IAsyncDisposable も実装する
非同期で解放処理を行うリソース(DB 接続、ネットワークソケット、バッファのフラッシュなど)では、IAsyncDisposable も併せて実装します。
public sealed class AsyncBuffer : IDisposable, IAsyncDisposable
{
private readonly Stream _stream;
private bool _disposed;
public AsyncBuffer(Stream stream) => _stream = stream;
public void Dispose()
{
if (_disposed) return;
_stream.Dispose();
_disposed = true;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
await _stream.DisposeAsync().ConfigureAwait(false);
_disposed = true;
GC.SuppressFinalize(this);
}
}
使い方は await using です。
public async Task UseAsync()
{
await using var buffer = new AsyncBuffer(stream);
// ...
} // ここで await buffer.DisposeAsync()
両方を実装したクラスでは、非同期コンテキストでは await using を優先、同期コンテキストでは using を使う、と使い分けます。
どのパターンを選ぶか
実務での選択フローは次のとおりです。
アンマネージドリソースを直接持つ?
├── No → パターン 1(シンプル)
└── Yes
├── SafeHandle でラップできる?
│ ├── Yes → パターン 2(SafeHandle、推奨)
│ └── No → パターン 3(完全パターン)
└── 非同期解放が必要?
└── Yes → パターン 4(IAsyncDisposable 併用)
ほとんどのケースはパターン 1 か 2 で済みます。 パターン 3 は本当にアンマネージドを直接扱うフレームワーク・ライブラリ層で書くもので、アプリケーションコードで頻出するわけではありません。
チェックリスト
最後に、Dispose パターンを書くときの自己チェック項目です。
- 多重
Dispose()を防ぐフラグはあるか -
Dispose()後の使用でObjectDisposedExceptionを投げているか - ファイナライザを書いたなら
GC.SuppressFinalize(this)を呼んでいるか -
Dispose(false)ではマネージドフィールドに触れていないか - 派生クラスは
base.Dispose(disposing)を呼んでいるか - アンマネージドを
SafeHandleで置き換えられないか検討したか - 非同期解放が必要なら
IAsyncDisposableを実装したか
Finalize と Dispose を正しく組み合わせれば、リソース解放は 「保険」と「正規ルート」 の二段構えで安全になります。次の記事では、こうしたリソース管理とは別軸の最適化テクニックである Lazy Object Instantiation(遅延初期化) を扱います。