動くコード図鑑技術記事現場の渡り方キャリア論すべての記事About
技術記事

C# の IDisposable を正しく実装する(Dispose パターンと using / ファイナライザ)

バイブス父さん
現役の業務SE
2026年7月14日18 min read広告 (PR) を含む場合があります
C# の IDisposable を正しく実装する(Dispose パターンと using / ファイナライザ)

みなさんこんにちは!ヒロポンです!

SqlConnection やファイルハンドルを自前で抱えるクラスを書いてて、ふと「これ、閉じ忘れたらリークするよな??」って手が止まったこと、ないですか。

業務系だと DB 接続を1本持つ小さなユーティリティを自作する場面、普通にありますよね。そいつを using で使えるようにしたい。要するに C# の IDisposable を実装する Dispose パターンを書きたい、という話です。

で、検索する。すると Dispose(bool disposing) だの GC.SuppressFinalize だの ~クラス名()(ファイナライザ)だのが一気に出てきて、「え、こんなに書くの?」ってなる。俺も最初これで身構えました。

先に結論。多くの業務クラスは、あの重いパターンを全部書く必要はない。抱えてるのがマネージドリソースだけなら単純版で足ります。ここでは Dispose パターンがなぜこの形なのかを、単純版 → フル版 → using の順で積み上げていきます。

忙しい人向けに最初にまとめ

  • 抱えてるのがマネージドリソースだけSqlConnection など、それ自体 IDisposable なオブジェクト)なら → 単純な Dispose で各リソースの Dispose() を呼ぶだけ。ファイナライザも GC.SuppressFinalize も要らない
  • 自分でアンマネージドリソース(ネイティブハンドル等)を確保している時だけ → Dispose(bool) パターン + ファイナライザ + GC.SuppressFinalize
  • 使う側using で包むだけ。ブロックを抜ける時に例外が出ても、自動で Dispose が呼ばれる
  • ハマりどころは「マネージドだけなのにファイナライザを書いて GC 負荷を増やす」パターンと「Dispose を呼び忘れて GC 任せにし、接続プールが枯渇する」パターン

コードは記事の中盤(単純版 → フル版)に全部あります。

そもそも IDisposable は何を解決するのか

C# の IDisposable は、もう使わないリソースを、GC を待たずに「今すぐ」確定的に解放するための仕組みです。定義はたった1メソッド。

public interface IDisposable
{
    void Dispose();
}

なんでこんなものが要るのか。C# はメモリを GC(ガベージコレクタ)が自動で回収してくれる。ん?だったらメモリだけ見れば Dispose は要らないよな、って思いますよね。

引っかかるのはメモリ以外のリソースです。DB 接続、ファイルハンドル、ソケット、ロック。この手のやつは数に上限があったり、開いてる間ずっと他をブロックしたりする。GC がいつ回収するかは不定。「使い終わったら即閉じる」を GC 任せにはできないわけです。

だから .NET は約束を用意した。「後始末を持つオブジェクトは IDisposable を実装してね、使う側は責任を持って Dispose を呼んでね」。これが IDisposable の正体です。

ここまでで分かったこと: IDisposable は「メモリ以外のリソースを確定的に閉じる」ための約束。中身は Dispose() 1個だけ。

パターン1: 単純な Dispose(マネージドリソースだけ)

まずはいちばん多いケース。自分のクラスが抱えてるのが、すでに IDisposable なマネージドオブジェクトだけのとき。

たとえば「DB 接続を1本持って、それ経由で書き込むだけ」のクラス。やることは「抱えてる子の Dispose を呼ぶ」だけです。

動く形で書くと、こんな感じになります(SqlConnection の代わりに、動作確認できるよう StringWriter をマネージドリソース役にしています)。

using System;
using System.IO;

class ManagedResourceHolder : IDisposable
{
    private readonly StringWriter _writer; // マネージドリソース (それ自体 IDisposable)
    private bool _disposed;

    public ManagedResourceHolder()
    {
        _writer = new StringWriter();
        Console.WriteLine("リソースを開いた");
    }

    public void Write(string text) => _writer.Write(text);

    public void Dispose()
    {
        if (_disposed) return;   // 二重 Dispose を無害化 (冪等にする)
        _writer.Dispose();       // 抱えているマネージドリソースを閉じる
        _disposed = true;
        Console.WriteLine("リソースを閉じた");
    }
}

class Program
{
    static void Main()
    {
        using (var holder = new ManagedResourceHolder())
        {
            holder.Write("処理中...");
        } // ここを抜けると自動で Dispose される
        Console.WriteLine("using を抜けた");
    }
}

実行結果:

ManagedResourceHolder を using で使った実行結果 — リソースを開いた / リソースを閉じた / using を抜けた の3行が出力される

ポイントは3つ。

  1. ファイナライザ(~ManagedResourceHolder())は書いていない。抱えてるのがマネージドだけなら要らない
  2. GC.SuppressFinalize も呼んでいない。ファイナライザが無いので、抑制する対象もないから
  3. _disposed フラグで二重呼び出しを無害化している。Dispose は何回呼ばれても壊れない、が定石

「え、これだけ?」って思うでしょ。これだけです。検索で出てくる重いパターンは、次の「アンマネージドを持つ場合」の話。業務で書く自作クラスの大半は、この単純版で足ります。

継承させる予定がないなら、いっそ sealed class ManagedResourceHolder にしておく。そうすると後で出てくる Dispose(bool) の仮想メソッド版すら考えなくてよくなります。いい感じにシンプルになる。

ここまでで分かったこと: マネージドリソースだけを抱えるなら、Dispose() の中で子の Dispose() を呼び、二重呼び出しをフラグでガードするだけ。ファイナライザは不要。

パターン2: Dispose(bool) パターン(アンマネージド + ファイナライザ)

次。自分で「アンマネージドリソース」を確保している場合です。ネイティブ API から取ったハンドル、Marshal.AllocHGlobal で確保したメモリ、みたいなやつ。

これらは GC が中身を知らない。だから「万が一 Dispose を呼び忘れられても、最後に GC のタイミングで解放したい」という保険がほしくなる。その保険がファイナライザです。

しかも、Dispose から呼ばれた時とファイナライザから呼ばれた時で、やっていい後始末が違う。この2経路を1メソッドにまとめたのが Dispose(bool disposing) です。

using System;
using System.IO;

class FullResource : IDisposable
{
    private IntPtr _handle;          // アンマネージドリソースの代役 (ネイティブハンドル等)
    private StringWriter _managed;   // マネージドリソース
    private bool _disposed;

    public FullResource()
    {
        _handle = new IntPtr(1);     // 「ハンドルを確保した」体
        _managed = new StringWriter();
    }

    // 使う側から呼ばれる公開 Dispose
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);   // 後始末は済んだ。ファイナライザは走らせなくていい
    }

    // 実体。disposing で「誰から呼ばれたか」を分岐する
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            // Dispose() 経由のときだけ、マネージドリソースに触ってよい
            _managed?.Dispose();
            Console.WriteLine("マネージド + アンマネージドを解放");
        }
        else
        {
            Console.WriteLine("アンマネージドだけ解放 (ファイナライザ経由)");
        }

        // アンマネージドは、どちらの経路でも必ず解放する
        if (_handle != IntPtr.Zero) _handle = IntPtr.Zero;

        _disposed = true;
    }

    // ファイナライザ。Dispose 呼び忘れの保険
    ~FullResource() => Dispose(false);
}

disposing の真偽で何が変わるのか。ここが一番の勘所です。

  • Dispose(true)Dispose() から)→ 呼び出し元がまだ生きてる普通の後始末。マネージドもアンマネージドも両方閉じてよい
  • Dispose(false)(ファイナライザから)→ GC が回してる最中。マネージドオブジェクトはもう回収済みかもしれないので触らない。アンマネージドだけ解放する

なんでファイナライザからマネージドに触っちゃダメなのか?? ファイナライザが走る順番は保証されないからです。自分が抱えてる _managed が、自分より先にファイナライズされている可能性がある。そこに触ると未定義の挙動。だから disposing == false の側では、マネージドに一切手を出さない。

動かして両方の経路を見るとこうなります。

class Program
{
    static void Main()
    {
        // (1) using 経由: Dispose(true) が走る
        using (var r = new FullResource())
        {
            Console.WriteLine("using ブロック内");
        }

        // (2) わざと Dispose せず GC に回収させる: ファイナライザ = Dispose(false) が走る
        MakeGarbage();
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine("完了");
    }

    static void MakeGarbage()
    {
        var leaked = new FullResource(); // Dispose を呼ばずに捨てる
    }
}

実行結果:

FullResource の実行結果 — using 経由の Dispose(true) でマネージド+アンマネージド解放、閉じ忘れをファイナライザが Dispose(false) でアンマネージドだけ解放する2経路

(1) は Dispose() から Dispose(true)、(2) は閉じ忘れをファイナライザが Dispose(false) で拾ってる。ちゃんと経路が分かれてる!!

ここまでで分かったこと: アンマネージドを持つ時だけ、ファイナライザで保険をかける。disposing フラグで「マネージドに触っていいか」を分岐する。

GC.SuppressFinalize は何をしているのか

Dispose() の中に1行だけ出てきた GC.SuppressFinalize(this)。これをサラッと飛ばす記事が多いんだけど、業務SEがいちばん「?」ってなる行なので、ちゃんと言います。

ファイナライザを持つオブジェクトは、GC が1回では回収できない。いったんファイナライズ用のキュー(finalization queue)に載せ、専用スレッドでファイナライザを走らせ、その次の GC でようやく本体を回収する。つまり回収に GC が2回かかり、世代も1つ上がる。地味にコストです。

でも Dispose() を呼んだ時点で後始末はもう終わってる。だったらファイナライザを走らせる意味はない。そこで GC.SuppressFinalize(this) を呼んで、「こいつのファイナライザはもう要らないから、キューに載せないで」と GC に伝える。これで2回コレクションのコストが消える。

まとめるとこういう対応関係です。

Dispose 呼び出し時とファイナライザ経路の後始末の違いと GC.SuppressFinalize の役割

だから理想は「毎回ちゃんと Dispose を呼んで、ファイナライザは一度も走らせない」。ファイナライザはあくまで最後の保険であって、常用ルートじゃない。ここを勘違いして「ファイナライザがあるから閉じ忘れても大丈夫」と思い込むと、後で出てくるハマりに直行します。

あと、パターン1(単純版)では GC.SuppressFinalize は呼ばなくていい。抑制すべきファイナライザがそもそも無いから。「Dispose には必ず SuppressFinalize を書く」と機械的に覚えると、単純版で無駄な1行を足すことになる。要るのはフルパターンの時だけです。

パターン3: using で自動的に閉じる(使う側の話)

ここまではクラスを作る側の話でした。作ったクラスを使う側は、using で包むだけです。

using はコンパイル時に try / finally へ展開されます。手で書くと抜け漏れるやつを、コンパイラが確実にやってくれる。

手動 try-finally で Close する書き方から using ステートメントへの置き換え

after 側が裏でやってることを図にすると、こう。

using ステートメントが try-finally に展開され、正常時も例外時も Dispose が呼ばれる流れ

例外が飛んでも finally で必ず Dispose される。これが using の効きどころです。手動の try / catch だと、途中で return したり別の例外が重なったりで閉じ忘れる。あなたも早期 return で finally を素通りさせた経験、ありませんか。using ならそこを気にしなくていい。

ちなみに C# 8.0 からは、ブロックすら書かない using 宣言も使えます。

using var conn = new SqlConnection(connStr);
conn.Open();
// ... 処理 ...
// メソッド (スコープ) を抜ける時に自動で Dispose

スコープ末尾で Dispose されるので、ネストが浅くなって、いい感じに読める。ただし using var は C# 8.0 以降の構文。C# 7.3 のままの現場では従来の using (...) { } ブロックを使ってください。使い分けの詳しい話は「C# using の3形態」の記事にまとめてあります。

ここまでで分かったこと: 作る側は IDisposable を実装、使う側は using で包む。using は try / finally 展開なので、例外時も確実に閉じる。

適用判断 — 結局どれを書けばいいのか

3つ出てきたので、いつどれを使うのか一枚で整理します。

IDisposable 実装 3パターン (単純 Dispose / Dispose(bool) フル / using で使う側) の観点別使い分け

言いたいのはこれ。業務で自作するクラスの大半は「単純 Dispose」で終わるSqlConnectionStreamReaderHttpClient も、中でアンマネージドを抱えて後始末してくれるのは .NET 側の仕事。俺たちが書くラッパークラスは、その子たちの Dispose を呼ぶだけでいい。

自分でネイティブハンドルを P/Invoke で握る……みたいな低レベルなことをして初めて、フルパターンの出番になる。業務系でそこまでやる頻度は、正直かなり低いです。

俺の現場ではこう踏んだ

概念は分かった。で、ちゃんと閉じないと実際に何が起きるのか。ここは俺がやらかした話をします。

流通系の基幹保守をやってた頃、夜間バッチ用に DB 接続を持つ小さなヘルパークラスを自作した。IDisposable は実装せず、コンストラクタで Open、あとは呼び出し側が適当なタイミングで捨てる作り。今思うと雑でした。

これが数日は普通に動くんですよ。で、忘れた頃に夜間バッチが「接続プールがいっぱいです」系のエラーで落ちる。接続プールの上限は既定で100。閉じてない接続がじわじわ溜まって、ある晩ついに上限を超えた。原因にたどり着くまで半日、ログとにらめっこ。落ちた翌朝に上長から「昨夜のバッチ止まってるけど」と声をかけられて、血の気が引いたやつです。

犯人は単純で、接続を GC 任せにしていたこと。GC はメモリが逼迫するまで回さないから、接続はずっと開いたまま。ファイナライザすら書いてなかったので保険もゼロ。打つ手なしでした。

直したのは2箇所だけ。ヘルパーに IDisposable を実装して各接続の Dispose を呼ぶようにし、呼び出し側を全部 using で包んだ。それ以降、プール枯渇は一度も出ていません。

教訓としては、「閉じる」を人間の意志に頼らない。using で構文に閉じてもらう。この記事の単純版パターンで十分に防げた事故でした。

まとめ

Dispose パターンがなぜあの形なのか、積み上げで見てきました。

  • IDisposable は「メモリ以外のリソースを確定的に閉じる」約束。中身は Dispose() 1個
  • 抱えてるのがマネージドだけなら → 単純 Dispose(子の Dispose を呼ぶ + 冪等ガード)。ファイナライザも SuppressFinalize も要らない
  • 自分でアンマネージドを持つ時だけ → Dispose(bool) フルパターン + ファイナライザ + GC.SuppressFinalize
  • disposing フラグは「マネージドに触っていい経路か」を分ける印
  • 使う側は using で包む。例外時も確実に閉じる

要は、検索で最初に出てくる重いパターンに気圧される必要はないってことです。大半の業務クラスは単純版で足りるし、using で閉じる習慣さえ付けば、リソースリークは目に見えて減る。定石を定石として実装しておくだけで、夜間バッチが接続枯渇で落ちる、みたいな地味に痛い事故が現場から消えます。そこが Dispose パターンの一番の効きどころです。

よくある質問

マネージドリソースだけのクラスにファイナライザは要りますか

要りません。むしろ書かない方がいい。ファイナライザを付けると、そのオブジェクトは回収に GC が2回かかるようになり、GC 負荷が増えます。抱えてるのが IDisposable なマネージドオブジェクトだけなら、単純 Dispose でそれぞれの Dispose() を呼ぶだけで十分です。

Dispose を2回呼んでしまったら例外になりますか

きちんと実装されていれば、なりません。_disposed フラグで2回目以降を無害化する(冪等にする)のが定石だからです。逆に、Dispose 済みのオブジェクトの通常メソッドを呼んだ場合は ObjectDisposedException を投げるのが行儀のいい作りになります。

using を使えば Dispose の実装は不要ですか

役割が別です。using使う側の書き方で、既に IDisposable を実装済みのクラスを自動で閉じてくれます。自分で後始末が要るクラスを作る側は、IDisposable を実装して Dispose() を書く必要があります。作る側と使う側、両方の話だと思ってください。

GC.SuppressFinalize を単純版でも呼んだ方が安全ですか

安全性は変わりません。単純版にはファイナライザが無いので、抑制する対象がなく、呼んでも実質何も起きません。「Dispose には必ず書くもの」と機械的に覚えると混乱するので、ファイナライザを書いた時だけセットで呼ぶ、と理解しておくのが正確です。

動作確認メモ

この記事の C# コード(単純 Dispose / Dispose(bool) フルパターン / using デモ)はコンテナ上の .NET でコンパイル・実行し、記載の出力を確認しています。ネイティブハンドルは動作確認のため IntPtr の代役で表現しているので、実際のアンマネージド解放処理(CloseHandle 等)はお使いの環境に合わせて差し替えてください。

次に読むべき記事

以上!

同じ「閉じ忘れ」で夜を溶かした人がいたら、どんどんシェア待ってるぜ!!


執筆者

バイブス父さん — 業務 SE 7 年 (SIer 正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SIer の正社員からフリーランスに転じ、 複数のエージェント経由で案件を回してきた経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


この記事のコードと手順は ぜんぶ動作検証済み。 安心して現場で試してくれ。
バイブス父さん

現役の業務SE。C# / SQL Server 保守の現場から、コードも人もキャリアも全部書く。 実体験ベース。

運営者について