C# イベントの落とし穴

〜 ハンドラの呼び出し順序と匿名ラムダの購読解除 〜

はじめに

C# のイベントは「+=」で簡単に複数の処理を登録できる便利な仕組みです。

button1.Click += HandlerA;
button1.Click += HandlerB;
button1.Click += HandlerC;

これで button1 をクリックすると、3つのメソッドが順番に呼ばれます。

…ところが、呼び出し順序解除の仕組みを正しく理解していないと、意図しない動作やバグにつながります。

今回は「順序」と「匿名ラムダ解除」の2点を実験を交えて解説します。


1. 複数ハンドラの呼び出し順序

実験コード

private void HandlerA(object? sender, EventArgs e)
    => listBoxLog.Items.Add("HandlerA 実行");

private void HandlerB(object? sender, EventArgs e)
    => listBoxLog.Items.Add("HandlerB 実行");

private void HandlerC(object? sender, EventArgs e)
    => listBoxLog.Items.Add("HandlerC 実行");

private void button1_Click(object sender, EventArgs e)
{
    // 一旦クリア
    button1.Click -= HandlerA;
    button1.Click -= HandlerB;
    button1.Click -= HandlerC;

    // 登録順を変えて試す
    button1.Click += HandlerA;
    button1.Click += HandlerB;
    button1.Click += HandlerC;
}

実行結果

  • HandlerA → HandlerB → HandlerC の順で実行される
  • 順序を変えれば、その通りの順で実行される

C# のイベントは「登録順に呼び出される」ことが確認できます。

注意:一つのハンドラ内で例外が出ると、後続のハンドラは呼ばれないので例外設計も重要です。


2. 匿名ラムダの購読解除問題

実験コード

// 匿名ラムダで登録
button1.Click += (s, e) => listBoxLog.Items.Add("匿名ラムダ呼び出し");

// 解除を試みる
button1.Click -= (s, e) => listBoxLog.Items.Add("匿名ラムダ呼び出し");

結果

解除できません!

なぜなら += のときに生成されたラムダは無名のオブジェクトであり、-= で書いたラムダは別のインスタンスだからです。

つまり 同じ見た目のコードでも別物。-= が一致判定できないため解除できないのです。


3. 解決策:変数に保持する

正しい書き方

EventHandler myHandler = (s, e) => listBoxLog.Items.Add("匿名ラムダ呼び出し");

// 登録
button1.Click += myHandler;

// 解除
button1.Click -= myHandler;

変数やフィールドに代入しておけば、同じデリゲートインスタンスを使えるので解除が可能になります。


4. まとめ

  • += で複数登録したハンドラは 登録順に呼ばれる
  • 例外が出ると後続は呼ばれない
  • 匿名ラムダは解除できない(毎回新しいインスタンスだから)
  • 解除したい場合は 変数・フィールドに保持して登録/解除する

5. 演習課題

  1. button1.Click に 3 つのハンドラを登録して、順序を入れ替えて結果を確かめよ。
  2. 匿名ラムダを使って += と -= を試し、「解除できない」ことを確認せよ。
  3. 匿名ラムダを変数に代入して登録/解除し、解除が成功することを確認せよ。
  4. ハンドラ内で例外を投げた場合、残りのハンドラが呼ばれるかを確かめよ。

Form1.cs に追記すれば動きます(WinForms / .NET 6+想定)。各設問は「設定ボタン(btnSetupX)」を押して配線し、その後 button1 をクリックしてログ結果を確認します。


共通ヘルパ(最初に貼る)

// Form1.cs
using System;
using System.Windows.Forms;

namespace TeamQuizApp
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            // 画面にセットアップ用のボタンを4つ追加(なければ省略し、任意の既存ボタンに割当でもOK)
            // 例:btnAnswer1~4 があるなら、下の4行をそれぞれに割り当てると楽です。
            btnAnswer1.Click += Setup1_Order;           // 【設問1】順序
            btnAnswer2.Click += Setup2_AnonUnsubFail;   // 【設問2】匿名ラムダ 解除できない
            btnAnswer3.Click += Setup3_AnonUnsubOK;     // 【設問3】匿名ラムダ 変数保持で解除成功
            btnAnswer4.Click += Setup4_ExceptionOrder;  // 【設問4】例外で後続は呼ばれるか

            // 実験対象は button1 とします(Designerで命名を button1 にしている想定)。
            // 命名が異なる場合は、対象コントロール名に置き換えてください。
        }

        void Log(string msg)
        {
            listBoxLog.Items.Add($"{DateTime.Now:HH:mm:ss.fff}  {msg}");
            listBoxLog.TopIndex = listBoxLog.Items.Count - 1;
        }

        // 念のため、対象イベントから事前に外すユーティリティ
        void ClearAllHandlers()
        {
            // イベントから一括で外す API はないため、
            // ここではこのファイルで定義したハンドラだけ個別に外します。
            button1.Click -= A;
            button1.Click -= B;
            button1.Click -= C;
            button1.Click -= After;
            button1.Click -= Throwing;
            if (_cached != null) button1.Click -= _cached;
            if (_lastAnon != null) button1.Click -= _lastAnon;
            // ここで匿名ラムダ(変数に保持しないもの)は外せません(設問2のポイント)。
        }

        // ===== 共通で使うハンドラ =====
        void A(object? s, EventArgs e) => Log("A 実行");
        void B(object? s, EventArgs e) => Log("B 実行");
        void C(object? s, EventArgs e) => Log("C 実行");
        void After(object? s, EventArgs e) => Log("After(後続ハンドラ) 実行");

        void Throwing(object? s, EventArgs e)
        {
            Log("Throwing 実行 → 例外を投げます");
            throw new InvalidOperationException("デモ例外");
        }

【設問1】button1.Click に 3 つのハンドラを登録し、順序を入れ替えて結果を確かめよ

        // 設問1:順序の確認
        private void Setup1_Order(object? s, EventArgs e)
        {
            ClearAllHandlers();
            listBoxLog.Items.Clear();

            // まず A→B→C の順に登録
            button1.Click += A;
            button1.Click += B;
            button1.Click += C;
            Log("【設問1】A→B→C の順に登録しました。→ button1 をクリックして実行順を確認");

            // 2回目にこのセットアップを押したら順序を入れ替える例
            // (授業では一度目の結果を見てからもう一度押して比較)
            if (_toggled1)
            {
                button1.Click -= A;
                button1.Click -= B;
                button1.Click -= C;

                button1.Click += B;
                button1.Click += C;
                button1.Click += A;
                Log("【設問1】順序を B→C→A に入れ替えました。→ 再度 button1 をクリックして比較");
            }
            _toggled1 = !_toggled1;
        }
        private bool _toggled1 = false;

期待される観察結果

  • 初回:A → B → C の順にログが出る
  • 再配線後:B → C → A の順にログが出る→ 結論:C# のイベントは「登録順」に呼ばれる。

【設問2】匿名ラムダを使って += / -= を試し、「解除できない」ことを確認せよ

        // 設問2:匿名ラムダは -= で解除できない(同じ式でも別インスタンスだから)
        private void Setup2_AnonUnsubFail(object? s, EventArgs e)
        {
            ClearAllHandlers();
            listBoxLog.Items.Clear();

            // 匿名ラムダで登録
            button1.Click += (s1, e1) => Log("匿名ラムダ(解除不可デモ) 呼び出し");

            // 同じ見た目の式で -= を書いても別インスタンス扱い → 解除できない
            button1.Click -= (s1, e1) => Log("匿名ラムダ(解除不可デモ) 呼び出し");

            Log("【設問2】匿名ラムダを += 登録し、同じ式で -= を実行しました。");
            Log("→ しかし解除されていないはず。button1 をクリックしてログが出続けることを確認。");
        }

期待される観察結果

  • button1 をクリックすると 毎回「匿名ラムダ(解除不可デモ) 呼び出し」が出続ける→ 結論:匿名ラムダは、+= と同じ式で -= を書いても解除できない。

【設問3】匿名ラムダを変数に代入して登録/解除し、解除が成功することを確認せよ

        // 設問3:匿名ラムダでも「同じデリゲートインスタンス」を保持していれば解除できる
        EventHandler? _cached;     // 変数(フィールド)に保持する
        EventHandler? _lastAnon;   // 設問4と衝突しないように別途保持

        private void Setup3_AnonUnsubOK(object? s, EventArgs e)
        {
            ClearAllHandlers();
            listBoxLog.Items.Clear();

            // キャッシュして登録
            _cached = (s2, e2) => Log("キャッシュ済み匿名ラムダ 呼び出し");
            button1.Click += _cached;
            Log("【設問3】キャッシュ済み匿名ラムダを += 登録 → button1 をクリックして呼び出しを確認。");

            // もう一度このセットアップを押すと解除してみせる
            if (_toggled3)
            {
                button1.Click -= _cached; // 成功する
                Log("【設問3】同じインスタンスを -= で解除しました。→ button1 をクリックして出力されないことを確認。");
                _cached = null;
            }
            _toggled3 = !_toggled3;
        }
        private bool _toggled3 = false;

期待される観察結果

  • 登録直後:button1 クリックで「キャッシュ済み匿名ラムダ 呼び出し」
  • 解除後:button1 クリックしても 何も出ない→ 結論:匿名ラムダでも、同じインスタンスを保持すれば解除できる。

【設問4】ハンドラ内で例外を投げた場合、残りのハンドラが呼ばれるかを確かめよ

        // 設問4:上流で例外が投げられると、後続ハンドラは呼ばれない(同期呼び出しで伝播)
        private void Setup4_ExceptionOrder(object? s, EventArgs e)
        {
            ClearAllHandlers();
            listBoxLog.Items.Clear();

            // 1: Throwing → After の順で登録(Throwing が例外を投げる)
            button1.Click += Throwing;
            button1.Click += After;

            Log("【設問4】Throwing → After の順で登録。→ button1 をクリック");
            Log("期待:Throwing のログ後に例外 → After は呼ばれない(UIが例外で止まる可能性)");

            // 2回目にこのセットアップを押したら、Throwingを try/catch で包んだ版に差し替え
            if (_toggled4)
            {
                button1.Click -= Throwing;
                button1.Click -= After;

                // try/catch で握る版(匿名ラムダだが変数に保持しておく)
                _lastAnon = (s3, e3) =>
                {
                    try
                    {
                        Throwing(s3, e3);
                    }
                    catch (Exception ex)
                    {
                        Log($"[捕捉] {ex.GetType().Name}: {ex.Message}");
                        // 例外を握ったので後続に進める
                    }
                };
                button1.Click += _lastAnon;
                button1.Click += After;

                Log("【設問4】Throwing を try/catch で包んだ版に置換 → 再度 button1 をクリック");
                Log("期待:Throwing→[捕捉]→After の順で実行され、アプリは落ちない");
            }
            _toggled4 = !_toggled4;
        }
        private bool _toggled4 = false;
    }
}

期待される観察結果

  • try/catch なし:Throwing 実行→例外 が出て、After は呼ばれない(未捕捉例外はUIを止める可能性)
  • try/catch あり:Throwing 実行→[捕捉] … → After 実行(後続が動く)→ 結論:イベントは登録順に同期実行。途中で未捕捉例外が出ると後続は呼ばれない。

口頭試問のポイント(答案の要旨)

  1. 順序:イベントは登録順に呼ばれる。
  2. 匿名ラムダ解除失敗:同じコードでも -= は別インスタンス扱いで解除できない。
  3. 解除成功の条件同じデリゲートインスタンスを変数/フィールドに保持して -=。
  4. 例外の影響未捕捉例外が出た時点で後続は実行されない。必要なら上流で try/catch。

おわりに

イベントは便利ですが、順序保証解除の仕組みは意外と知られていません。

特に匿名ラムダの解除は実務でも落とし穴になりやすいポイントです。

WinForms の教材や業務アプリでも、「順序」「例外」「解除」を理解しておくことで、より堅牢なコードが書けるようになります。


訪問数 2 回, 今日の訪問数 2回

C#,イベント

Posted by hidepon