1. はじめに
C#のイベントは、ただデリゲートを入れておく変数ってだけじゃないよ。購読者のリストとして保護されていて、イベントのオーナーだけが実行をトリガーできる。外部のコードはただ自分のリアクションを追加(+=)したり外したり(-=)できるだけなんだ。
例えば、こんな風にイベントに登録するよ:
worker.WorkCompleted += Worker_WorkCompleted;
一見すると普通の加算っぽいけど、実際は違う動きをする。裏ではイベントは呼び出しのチェーン(invocation list)を持っていて、イベントが発火したときに呼ぶべきデリゲートの集合になっている。+= と書くと、新しいハンドラがそのリストに追加されるんだ。
では、内部で何が起きるかをもう少し詳しく見ていこう。チェーンがどう作られるか、購読でどんな微妙な挙動が出るかを説明するよ。
デリゲートのチェーンって何?
思い出してみると、C#のデリゲートは「multicast」なんだ:複数のメソッドを割り当てられて、デリゲートを呼ぶと順番に全部が実行される。イベントはこの仕組みを使っている:イベントの値は実質的に、ハンドラのリストを持ったデリゲートなんだ。
コードで言うと:
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
誰かが購読すると:
worker.WorkCompleted += MyHandler;
C#は裏でだいたいこんなことをしてるよ:
- 現在のデリゲート(ハンドラのリスト)を取る。
- それに対して Delegate.Combine を呼んでハンドラを結合する。
- 更新されたチェーンをイベント変数に書き戻す。
図で見ると:
| 操作 | イベントの内部ハンドラリスト |
|---|---|
| 前 | null または [Handler1] |
| += で Handler2 を追加した後 | [Handler1, Handler2] |
| さらに += で H3 を追加した後 | [Handler1, Handler2, Handler3] |
面白い事実:multicast delegate の仕組みは「魔法」じゃなくて、内部的には呼び出すメソッドの配列を保持しているという具体的な実装だよ。
2. 購読の動き:ざっくり説明
具体例で見ていこう。発行者(Worker)と購読者(Logger, Notifier)がいるとする:
public class Worker
{
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
public void DoWork()
{
// ... 仕事 ...
OnWorkCompleted("作業が完了しました!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class Logger
{
public void LogWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("ログ: " + e.Message);
}
}
public class Notifier
{
public void ShowNotification(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("通知: " + e.Message);
}
}
Main の中では:
var worker = new Worker();
var logger = new Logger();
var notifier = new Notifier();
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += notifier.ShowNotification;
// 作業開始
worker.DoWork();
OnWorkCompleted が呼ばれると、まず logger.LogWorkCompleted が呼ばれ、その後に notifier.ShowNotification が呼ばれるよ(登録した順)。
3. 実務で押さえておくといいポイント
可視化:イベントがハンドラをどう保持しているか
+---------------------+
| Worker |
|---------------------|
| WorkCompleted Event | ---> [ LogWorkCompleted, ShowNotification ]
+---------------------+
購読すると、新しいハンドラが既存のリストに"くっつく"。イベント発火時にデリゲートが順番に登録されたメソッドを呼ぶ、って仕組みだよ。
同じメソッドを複数回購読する
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += logger.LogWorkCompleted; // 二回!
この場合、ハンドラは登録された回数だけ呼ばれる。ここでは二回連続で呼ばれるってことだね。
ラムダで購読する
worker.WorkCompleted += (sender, e) => Console.WriteLine("匿名ハンドラ: " + e.Message);
こんなラムダを何度も登録すると、同じく何度も呼ばれるよ。ただし気をつけて:各ラムダは別々のデリゲートオブジェクトになるから、簡単には解除できないことがある(詳しくはレクチャー260以降で)。
購読の内部動作:低レベルで見ると
イベントは add/remove の二つのアクセサを持つ特殊なプロパティで、+= と -= が使われたときにそれらが呼ばれる。単純化すると、コンパイラはだいたいこんなコードを生成するよ:
// だいたいこんな感じ(簡略化)
public event EventHandler<WorkCompletedEventArgs> WorkCompleted
{
add { /* ハンドラ追加のコード */ }
remove { /* ハンドラ削除のコード */ }
}
既定の実装では、デリゲートの結合に Delegate.Combine、削除に Delegate.Remove が使われるよ。
これによってイベントは保護されている:外部からイベントを直接呼べない(つまり worker.WorkCompleted(...); は出来ない)、外部は購読/解除しかできないんだ。
購読の仕組み:図で描くと
+----------------------+
| |
v v
+--------------------+ +----------------------+
| LogWorkCompleted | | ShowNotification |
+--------------------+ +----------------------+
^ ^
\______________________/
^
|
WorkCompleted Event
これがいわゆる "Invocation List" — 呼び出しのチェーンだね。
なんでこれが重要なの?実用的な意義
購読の仕組みを理解することは、オブジェクト同士の結びつきをうまく管理するための鍵だよ。コンポーネントが動的にイベントに登録・解除できるシステムを作れば、強い結合を作らずに柔軟に振る舞える。UIフレームワーク、ゲームエンジン、サーバーアプリ、最近のマイクロサービス的な設計(そっちはキューが主体だけどアイディアは同じ)でもよく使うパターンだね。
面接でも、C#のイベントモデルがどう柔軟さを生むか、購読のライフサイクルをどう管理するかはよく聞かれるポイントだよ。
4. クラスの外から何ができて何ができないか
できること
- イベントに登録する(+=)
- 登録を解除する(-=)
できないこと
- イベントを直接呼び出す
- イベントにデリゲートを直接代入する(worker.WorkCompleted = ... — エラーになる!)
これらの制限はキーワード event によって実現されているんだ。もしデリゲートを普通のフィールドとして宣言したら:
public EventHandler<WorkCompletedEventArgs> WorkCompleted; // event じゃない!
— 誰でも好き勝手にいじれて、ハンドラを全部クリアしたりできてしまい、混乱やバグの元になる。だから普通は 必ず event を使うべきだよ!
同じメソッドを複数のイベントに登録できる?
できるよ!これを "マルチサブスクライブ" と呼ぶ。例えば:
worker.WorkCompleted += logger.LogWorkCompleted;
anotherWorker.WorkCompleted += logger.LogWorkCompleted;
同じメソッドを別のオブジェクトのイベントに登録すると、どちらのイベントでもハンドラが動く。ハンドラ内で誰がイベントを起こしたかは sender パラメータで判別できるよ。
実例:動的な購読
ユーザーが複数のタスクを並列に走らせられるアプリを想像してみて。新しいタスクごとに Worker インスタンスが作られて、同じハンドラ(例えば logger.LogWorkCompleted)をイベントに登録することがあるよね:
var allWorkers = new List<Worker>();
for (int i = 0; i < 10; i++)
{
var w = new Worker();
w.WorkCompleted += logger.LogWorkCompleted;
allWorkers.Add(w);
}
こうすると、どのタスクが終わってもロガーが通知を受けて記録してくれる、ってわけ。
実務メモ:イベントの購読者を確認する方法
普通のコードからは、イベントに何人登録されているか直接見ることはできない(イベントはデリゲートを隠蔽する)。でも発行者クラスの内部ではデリゲートに直接アクセスできるから、GetInvocationList() を使えば確認できるよ:
// 発行者クラス内でだけ
var handlers = WorkCompleted?.GetInvocationList();
if (handlers != null)
Console.WriteLine($"購読者数: {handlers.Length}");
デバッグや特殊な送信ロジックを作るときには便利だけど、実務ではあまり乱用しない方がいい(学習目的で使うのはアリだよ)。
5. よくあるミスと注意点
ハンドラを登録してない?そのハンドラは呼ばれないよ!
登録していなければ呼ばれることはない。イベントを呼ぶ前には null チェックを入れておかないと NullReferenceException に遭遇するよ。
複数登録になってない?
同じメソッドを何回も登録すると、その分だけハンドラが実行される。意図せず += を重複して呼んでしまうと、1回の通知が複数回発生する落とし穴になるから注意してね。
static と instance のメソッド
static メソッドでもインスタンスメソッドでも登録できる。重要なのはシグネチャが正しいことだけだよ。
public static void StaticHandler(object? sender, WorkCompletedEventArgs e) { /* ... */ }
worker.WorkCompleted += StaticHandler;
ループ内でのラムダ登録
もしループの中でラムダを登録するなら、ラムダがキャプチャする変数に注意して。予期しない値をキャプチャしてしまうことがあるからね。
GO TO FULL VERSION