1. はじめに
C# でデリゲートとイベントを扱うのは心地よく便利です — 言語が多くを肩代わりしてくれます。でも、その裏には多くの落とし穴があります:見えないメモリリーク、二重登録による奇妙なバグ、ハンドラの非同期化のずれ、通知配信中の突然の例外など。 デリゲートとイベントは強力ですが、オブジェクトのライフサイクルに注意し、スレッドの理解と呼び出し・解除の仕組みを知っておく必要があります。 イベントが「ほとんど動く」けど時々動かない、あるいは変なエラーを出すなら、あなたは決して一人ではありません!どこで経験者でもミスしやすいか、そしてどう防ぐかを見ていきましょう。
主要なミスの一覧
| ミス | 結果 | 回避方法 |
|---|---|---|
| 二重登録 | ハンドラが複数回呼ばれる | 登録箇所を管理し、追加前に解除する |
| 解除忘れ(メモリリーク) | サブスクライバがメモリに残る、「ゾンビ」 | 常に解除する、IDisposable を使う |
| ハンドラ内の例外 | 残りのハンドラが呼ばれない | ハンドラ内で try/catch、または手動で回避 |
| 配信中の購読変更 | 呼び逃しや二重呼び出し | ハンドラのコピーを使って巡回(GetInvocationList()) |
| MyEvent == null のときの呼び出し | NullReferenceException | null をチェックする、?.Invoke を使う |
| ハンドラのシグネチャ不一致 | コンパイルエラー | シグネチャを確認する |
| 外部からのイベント呼び出し | コンパイルエラー | OnEventName 経由でのみ呼ぶ |
| static イベントの誤用 | 購読が混ざる | 大きな理由がない限り static にしない |
| ラムダでの closure の問題 | 予期しない値 | 変数のコピーを作る |
2. 複数登録と複数呼び出し
ミスの本質
同じハンドラを同じイベントに何度も登録すると、各 += がデリゲートのキューにメソッドを追加します。結果として、そのハンドラは追加された回数だけ呼ばれます。
どう現れるか?
たとえばボタンとクリックハンドラがあるとします:
Button btn = new Button();
btn.Click += OnButtonClick; // 登録
btn.Click += OnButtonClick; // 二重登録!
これでボタンを押すたびに OnButtonClick が2回呼ばれます。ハンドラ内でカウンタを更新したりログを追加していると、結果が二重になります。
どう直すか?
通常、二重登録はコード構造の誤りが原因です — 例えば += を複数回呼ぶ可能性のあるメソッドに置いてしまうことなど。
- 登録がどこで行われているかを把握する。
- += をライフサイクル上何度も呼ばれる段階に置かない。
- 場合によっては「ユニークな」登録を使う — 追加前に一度解除する:
myEvent -= MyHandler; // 念のため解除
myEvent += MyHandler; // 再登録
これは安全です:もしハンドラがまだ登録されていなければ、-= は何もしません。
3. ゾンビ購読者
ミスの本質
もしサブスクライバが長寿命のパブリッシャに登録していて解除しないと、ガベージコレクタはサブスクライバを回収できません:パブリッシャがデリゲートを通じてサブスクライバのオブジェクトを参照し続けるからです。その結果メモリリークが発生します。
典型的な例
public class TemporaryPopup : IDisposable
{
private Window _hostWindow;
public TemporaryPopup(Window window)
{
_hostWindow = window;
_hostWindow.Closed += OnHostClosed;
}
private void OnHostClosed(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_hostWindow.Closed -= OnHostClosed; // 解除を忘れないこと!
}
}
Dispose() を忘れたり呼ばなかったりすると、たとえ TemporaryPopup への参照を全部取り除いても、ウィンドウがまだそのハンドラを参照しているためオブジェクトは破棄されません。
どう防ぐか?
- サブスクライバがパブリッシャより寿命が短いなら、サブスクライバで IDisposable を実装する。
- using パターンや明示的な Dispose() 呼び出しを使う:
using (var popup = new TemporaryPopup(mainWindow))
{
// ...
} // ここで Dispose が自動的に呼ばれる
GUI アプリではウィンドウやフォームのクローズ時に解除する(例えばクローズハンドラやフォームの Dispose() 内で)。
4. ハンドラ内の例外処理
ミスの本質
イベントが多数のハンドラを呼ぶとき、1つのハンドラが例外を投げると残りのハンドラは実行されません。
デモ
public event EventHandler MyEvent;
public void Raise()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
もし MyEvent に登録されたメソッドのどれかで例外が発生すると、残りは呼ばれません — 連鎖が中断されます。
どう扱うか?
- イベントハンドラ内で例外をローカルに処理する(try/catch)か、意図的に投げる。
- 複雑なケースでは購読者リストを手動で回して各ハンドラのエラーを隔離する:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
try
{
handler.DynamicInvoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
// ロギングやフェイルオーバー処理
}
}
5. 配信中の購読リスト変更
ミスの本質
ハンドラがイベントの内部で自分自身や他を解除すると、呼び出し順序が歪む:一部のハンドラがスキップされたり重複して呼ばれたりします。
どう避けるか?
- ハンドラの内部で購読を変更しない。
- 必要ならデリゲートのコピーを回す:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
handler(this, EventArgs.Empty);
}
6. デリゲートの null(購読者がいない)混乱
ミスの本質
誰もイベントに登録していないと、そのデリゲートは null になります。チェックなしで呼ぶと NullReferenceException になります。
ダメな例
public event EventHandler MyEvent;
public void Raise()
{
MyEvent(this, EventArgs.Empty); // 購読者がいなければ例外!
}
正しい方法
- 安全呼び出しを使う:MyEvent?.Invoke(this, EventArgs.Empty)。
- または古典的なスレッドセーフの手法:デリゲートをローカル変数にコピーしてから呼ぶ。
7. 異なるシグネチャのデリゲート混在
ミスの本質
デリゲートは厳密に型付けされています。ハンドラのシグネチャがイベントのデリゲートと合わないとコンパイルエラーになります。
例
public event EventHandler<string> TextChanged;
void WrongHandler(object sender, int number) { /* ... */ }
TextChanged += WrongHandler; // コンパイルエラー!
標準のデリゲート EventHandler や EventHandler<T> を使い、シグネチャが正確に一致していることを確認してください。
8. パブリッシャ外からイベントを呼ぼうとする
ミスの本質
event はカプセル化されたデリゲートです:外部コードはそれを直接呼べません(追加/削除のみ可能)。
例
public class MyPublisher
{
public event EventHandler SomethingHappened;
}
var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // エラー!
正しいやり方
通常、呼び出しはパブリッシャの保護された/公開メソッド経由(通常は OnEventName)で行います。外部からは +=/-= のみ可能です。
9. カスタムアクセサ add と remove のミス
ミスの本質
イベントのカスタムアクセサは購読をコントロールできますが、呼び出し順やスレッド安全性を簡単に壊してしまいます。
例
public event EventHandler MyEvent
{
add { /* ... */ }
remove { /* ... */ }
}
確信がなければ標準のイベント実装を使ってください。手動実装する場合はドキュメントを参照し、同期処理を考慮すること。
10. static と非 static コンテキストからのイベントアクセス
ミスの本質
インスタンスに属すべきイベントを誤って static にしてしまうと、すべてのオブジェクトが購読キューを共有してしまいます。
例
public static event EventHandler GlobalEvent; // あらら!
// インスタンスごとの個別性が失われ、購読がひとまとめになる
回避法
グローバルなレベル(例えばグローバルなログ)で本当に必要な場合のみ static にする。その他はインスタンス単位でカプセル化する。
11. ラムダ式での変数キャプチャの問題
ミスの本質
ラムダは変数を参照でキャプチャします。ループ内だと「最後の値」だけになることがよくあります。
例
for (int i = 0; i < 5; i++)
{
button.Click += (s, e) => Console.WriteLine(i);
}
// 全てのハンドラが "5" を出力する
正しいやり方
for (int i = 0; i < 5; i++)
{
int copy = i; // ローカルコピー
button.Click += (s, e) => Console.WriteLine(copy);
}
12. 弱参照と強参照の混同:Advanced “Weak Events”
大規模アプリ(例えば WPF)では、パブリッシャがサブスクライバへの弱い参照を保持する「弱いイベント」メカニズムを使います。これによりガベージコレクタの妨げにならずリークを防げますが、サブスクライバが回収されてイベントを受け取れなくなることもあります。
詳細: Weak Event Patterns (MSDN)
13. 命名規約とシグネチャ標準の欠如
ミスの本質
標準的なシグネチャと命名を守ってください:イベント名は過去形(Changed, Closed, Completed)にし、イベント引数は EventArgs を継承させるべきです。
「間違い」の例:
public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;
「正しい」の例:
public event EventHandler<EventArgs> SomethingHappened;
独自イベントでもほとんどの場合 EventHandler や EventHandler<T> を使ってください。将来の自分や同僚が助かります。
GO TO FULL VERSION