C# のデリゲートは「メソッドを変数として扱う」というだけではありません。MulticastDelegate の内部構造・マルチキャスト時の戻り値と例外の挙動・EventHandler<TEventArgs> による標準パターン・add/remove アクセサ・スレッドセーフなイベント発火・イベント購読によるメモリリークなど、実務で必ず直面する深い話題があります。
本記事ではデリゲートの基本から、イベント設計・スレッド安全・メモリ管理・設計判断まで体系的に解説します。ラムダ式での Func<T>/Action<T> との使い分けはラムダ式完全ガイドを参照してください。
デリゲートの基本 — メソッドを型として扱う
デリゲートは delegate キーワードで宣言する「メソッドの型」です。特定のシグネチャ(引数型・戻り値型)を持つメソッドを参照できます。
// デリゲート型の宣言(クラス・名前空間レベルで行う)
public delegate int Transformer(int value); // int を受け取り int を返す
public delegate void Logger(string message); // string を受け取り void を返す
public delegate bool Predicate<T>(T item); // ジェネリックデリゲート
// デリゲート変数への代入(メソッドグループ / ラムダ)
Transformer doubler = x => x * 2;
Transformer squarer = x => x * x;
Logger consoleLog = msg => Console.WriteLine($"[LOG] {msg}");
int result = doubler(5); // 10
consoleLog("起動"); // [LOG] 起動
// メソッドグループ変換
static int Triple(int x) => x * 3;
Transformer tripler = Triple; // メソッドグループ
Console.WriteLine(tripler(4)); // 12
// デリゲートを引数に渡す(コールバックパターン)
static int Apply(int value, Transformer transform) => transform(value);
Console.WriteLine(Apply(5, doubler)); // 10
Console.WriteLine(Apply(5, squarer)); // 25
Console.WriteLine(Apply(5, Triple)); // 15(メソッドグループ)
現代の C# では
Func<T, TResult>・Action<T>・Predicate<T> が標準デリゲートとして提供されており、新規コードでカスタムデリゲートを宣言する必要は多くありません。カスタムデリゲートが必要な典型的な場面は① ref/out 引数を持つ場合、② params 引数を持つ場合、③ 読みやすさのためにドメイン固有の名前をつけたい場合です。Delegate クラス階層と内部構造
C# でデリゲートを宣言すると、コンパイラは System.MulticastDelegate を継承するクラスを自動生成します。この継承構造を理解すると、マルチキャストや GetInvocationList() の挙動が理解しやすくなります。
// コンパイラが自動生成する(擬似コード)
// sealed class Transformer : System.MulticastDelegate
// {
// public int Invoke(int value) { ... } // 同期呼び出し
// public IAsyncResult BeginInvoke(...) { ... } // 非推奨(.NET Core 未対応)
// public int EndInvoke(...) { ... } // 非推奨
// }
// デリゲートは参照型: 変数は Transformer のインスタンスへの参照
Transformer d1 = x => x * 2;
Transformer d2 = x => x * 3;
Transformer combined = d1 + d2; // マルチキャスト(新しいインスタンスを返す)
// GetInvocationList(): 登録されたデリゲートの配列を取得
Delegate[] list = combined.GetInvocationList();
Console.WriteLine(list.Length); // 2
// 各デリゲートを個別に呼び出す
foreach (Transformer t in list.Cast<Transformer>())
Console.Write(t(5) + " "); // 10 15
// == / != の比較: 同じメソッドを参照しているかどうか
Transformer a = Console.WriteLine;
Transformer b = Console.WriteLine;
Console.WriteLine(a == b); // True(同じ静的メソッドを参照)
static void MyMethod(int x) => Console.WriteLine(x);
Transformer c = MyMethod;
Transformer cc = MyMethod;
Console.WriteLine(c == cc); // True(同じインスタンスメソッドはターゲットも比較)
マルチキャストデリゲート — 戻り値・例外の挙動
+= で複数のメソッドを登録したデリゲートを呼び出すと、登録順に実行されます。しかし戻り値と例外処理には注意が必要です。
public delegate int Calculator(int x);
Calculator calc = x => { Console.Write("A "); return x + 1; };
calc += x => { Console.Write("B "); return x + 10; };
calc += x => { Console.Write("C "); return x + 100; };
// マルチキャスト呼び出し: 戻り値は最後のデリゲートのものだけ残る
int result = calc(0);
// 出力: A B C
Console.WriteLine(result); // 100(最後の C の戻り値)
// → 戻り値が必要なら GetInvocationList() で個別に呼び出すこと
// 例外: 途中で例外が発生すると残りのデリゲートは実行されない
Calculator dangerous = x => { Console.Write("X "); return x; };
dangerous += x => { throw new InvalidOperationException("エラー発生"); };
dangerous += x => { Console.Write("Y "); return x; }; // 実行されない!
try
{
dangerous(1); // X の後で例外 → Y は呼ばれない
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message); // エラー発生
}
// 解決策: GetInvocationList() で個別に呼び出し、例外を収集する
static void InvokeAll(Calculator d, int arg)
{
var exceptions = new List<Exception>();
foreach (Calculator item in d.GetInvocationList().Cast<Calculator>())
{
try { item(arg); }
catch (Exception ex) { exceptions.Add(ex); }
}
if (exceptions.Count > 0)
throw new AggregateException(exceptions);
}
① 戻り値: マルチキャスト呼び出しの戻り値は最後のデリゲートの値だけです。複数の戻り値を収集したい場合は
GetInvocationList() で個別に呼び出してください。② 例外: 途中のデリゲートが例外を投げると、以降のデリゲートは実行されません。すべてのデリゲートを確実に実行したい場合は
GetInvocationList() + try-catch で個別に呼ぶパターンを使います。EventHandler<TEventArgs> — 標準イベントパターン
C# のイベントには「標準パターン」があります。sender(発行者)と EventArgs(イベントデータ)を引数に持つEventHandler<TEventArgs> シグネチャを使うことで、フレームワークとの相互運用性と一貫性が高まります。
// カスタム EventArgs: イベントに渡したいデータを定義
public class OrderPlacedEventArgs : EventArgs
{
public int OrderId { get; }
public string Product { get; }
public int Quantity { get; }
public DateTime PlacedAt { get; }
public OrderPlacedEventArgs(int orderId, string product, int quantity)
{
OrderId = orderId;
Product = product;
Quantity = quantity;
PlacedAt = DateTime.Now;
}
}
// 発行クラス(Publisher)
public class OrderService
{
// EventHandler<T>: T は EventArgs の派生型
// public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public void PlaceOrder(int orderId, string product, int qty)
{
// 注文処理ロジック
Console.WriteLine($"注文受付: {product} x{qty}");
// イベント発火: OnXxx という protected virtual メソッドを経由するのが慣習
OnOrderPlaced(new OrderPlacedEventArgs(orderId, product, qty));
}
// protected virtual にすることで派生クラスがオーバーライドできる
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e)
=> OrderPlaced?.Invoke(this, e); // スレッドセーフな null 条件付き呼び出し
}
// 購読クラス(Subscriber)
public class OrderLogger
{
public void Subscribe(OrderService service)
=> service.OrderPlaced += HandleOrderPlaced;
public void Unsubscribe(OrderService service)
=> service.OrderPlaced -= HandleOrderPlaced;
private void HandleOrderPlaced(object? sender, OrderPlacedEventArgs e)
=> Console.WriteLine(
$"[LOG] OrderID={e.OrderId} | {e.Product} x{e.Quantity} @ {e.PlacedAt:HH:mm:ss}");
}
// 使い方
var service = new OrderService();
var logger = new OrderLogger();
logger.Subscribe(service);
service.PlaceOrder(101, "MacBook Pro", 1);
// 注文受付: MacBook Pro x1
// [LOG] OrderID=101 | MacBook Pro x1 @ 14:30:15
logger.Unsubscribe(service);
service.PlaceOrder(102, "iPhone", 2);
// 注文受付: iPhone x2
// (ログは出ない: 購読解除済み)
| 要素 | 役割 | 慣習 |
|---|---|---|
sender |
イベントを発行したオブジェクト | 型は object?(後から型チェック可能) |
EventArgs e |
イベントに関するデータ | EventArgs を継承したカスタムクラス |
OnXxx メソッド |
イベント発火のラッパー | protected virtual にして派生クラスが拡張可能に |
EventName? |
null 許容で宣言 | 購読者がゼロのとき null なので ?.Invoke() を使う |
event キーワードの意味 — public デリゲートとの違い
// event なし: public デリゲートフィールド(危険)
public class DangerousButton
{
public Action? Clicked; // 外部から直接呼べてしまう!
}
var dangerousBtn = new DangerousButton();
dangerousBtn.Clicked += () => Console.WriteLine("ハンドラ1");
dangerousBtn.Clicked += () => Console.WriteLine("ハンドラ2");
// NG: 外部から全ハンドラを消去できてしまう
dangerousBtn.Clicked = () => Console.WriteLine("上書き"); // ハンドラ1,2が消える!
// NG: 外部から直接呼び出せてしまう
dangerousBtn.Clicked?.Invoke(); // 外部から発火できる(不正)
// event あり: 購読(+=)と解除(-=)だけを外部に公開
public class SafeButton
{
public event Action? Clicked; // event キーワードを付ける
public void SimulateClick()
=> Clicked?.Invoke(); // 発火はクラス内部からのみ
}
var safeBtn = new SafeButton();
safeBtn.Clicked += () => Console.WriteLine("ハンドラ1");
safeBtn.Clicked += () => Console.WriteLine("ハンドラ2");
// safeBtn.Clicked = () => ...; // コンパイルエラー: = 代入は外部不可
// safeBtn.Clicked?.Invoke(); // コンパイルエラー: 外部からの発火不可
safeBtn.SimulateClick();
// ハンドラ1
// ハンドラ2
add/remove アクセサ — スレッドセーフなイベント実装
C# の event にはプロパティの get/set に相当する add/remove アクセサがあります。スレッドセーフなイベントを実装したり、購読者リストをカスタム管理したりするときに使います。
public class ThreadSafePublisher
{
// バッキングフィールドとしてデリゲートを保持
private EventHandler<string>? _dataReceived;
private readonly object _lock = new();
// 明示的 add/remove: 購読・解除を lock で保護
public event EventHandler<string>? DataReceived
{
add
{
lock (_lock) { _dataReceived += value; }
}
remove
{
lock (_lock) { _dataReceived -= value; }
}
}
// イベント発火はスナップショットを取ってから呼び出す
public void Receive(string data)
{
EventHandler<string>? handler;
lock (_lock) { handler = _dataReceived; } // スナップショット取得
handler?.Invoke(this, data); // lock の外で呼び出す(デッドロック防止)
}
}
// add/remove を使う別のユースケース: 購読者数の管理
public class LimitedEvent
{
private Action? _handlers;
private int _subscriberCount = 0;
private const int MaxSubscribers = 3;
public event Action? Triggered
{
add
{
if (_subscriberCount >= MaxSubscribers)
throw new InvalidOperationException($"購読者は最大 {MaxSubscribers} 人です");
_handlers += value;
_subscriberCount++;
}
remove
{
_handlers -= value;
_subscriberCount--;
}
}
public void Fire() => _handlers?.Invoke();
}
イベントのバッキングフィールドへのアクセスはスレッドセーフではありません。マルチスレッド環境でのイベント発火には、
① バッキングフィールドをローカル変数にスナップショットしてから呼び出す(
var h = _event; h?.Invoke(...))②
add/remove アクセサを lock で保護する③
lock の外でハンドラを呼び出す(lock 内でのコールバック呼び出しはデッドロックの原因)?.Invoke() — スレッドセーフな null チェックの必然性
// NG: 旧来のパターン(スレッドセーフでない)
public event EventHandler? LegacyEvent;
void FireLegacy()
{
// null チェックと呼び出しの間に別スレッドが -= で全購読解除すると
// NullReferenceException が発生する(TOCTOU 競合)
if (LegacyEvent != null) // チェック時は非 null
LegacyEvent(this, EventArgs.Empty); // ← ここで null になる可能性
}
// OK: ?.Invoke() パターン(スレッドセーフ)
public event EventHandler? ModernEvent;
void FireModern()
{
// ローカル変数にコピーしてからスナップショット呼び出し
// コピー時点での参照が保持されるので TOCTOU 競合がない
ModernEvent?.Invoke(this, EventArgs.Empty);
// 等価な書き方:
// var handler = ModernEvent;
// handler?.Invoke(this, EventArgs.Empty);
}
// ?.Invoke() の展開(コンパイラが生成するコード):
// var tmp = ModernEvent; // スナップショット(アトミック参照コピー)
// if (tmp != null) tmp.Invoke(this, EventArgs.Empty);
デリゲートの共変性と反変性
デリゲートの型は戻り値型・引数型の継承関係によって、互換性のある別のデリゲート型に代入できます(ジェネリックの in/out と同じ概念)。
class Animal { public string Name => "Animal"; }
class Dog : Animal { public new string Name => "Dog"; }
// 戻り値の共変性: より派生した型を返すメソッドを代入できる
delegate Animal AnimalFactory(); // Animal を返すデリゲート型
static Dog CreateDog() => new Dog(); // Dog は Animal の派生型
AnimalFactory factory = CreateDog; // OK: Dog を返すが Animal として受け取れる
Animal a = factory(); // Dog インスタンスが Animal として返る
Console.WriteLine(a.GetType().Name); // Dog
// 引数の反変性: より基底の型を受け取るメソッドを代入できる
delegate void DogProcessor(Dog dog);
static void ProcessAnimal(Animal animal)
=> Console.WriteLine($"処理: {animal.GetType().Name}");
DogProcessor process = ProcessAnimal; // OK: Animal を受け取るので Dog も受け取れる
process(new Dog()); // 処理: Dog
// Func/Action でも同様
Func<Dog> dogFunc = CreateDog;
Func<Animal> animalFunc = dogFunc; // 共変: Func<Dog> → Func<Animal>
Action<Animal> animalAction = ProcessAnimal;
Action<Dog> dogAction = animalAction; // 反変: Action<Animal> → Action<Dog>
イベント購読によるメモリリーク
イベントは最もよくあるメモリリークの原因の1つです。購読者が発行者より先に不要になっても、イベント登録が残っていると GC はオブジェクトを回収できません。
// NG: 購読解除を忘れるとメモリリーク
public class DataService
{
public event EventHandler<string>? DataChanged;
public void NotifyChange(string data) => DataChanged?.Invoke(this, data);
}
public class DataView
{
private readonly DataService _service;
public DataView(DataService service)
{
_service = service;
_service.DataChanged += OnDataChanged; // 購読: DataView への強参照が残る
}
private void OnDataChanged(object? sender, string data)
=> Console.WriteLine($"表示更新: {data}");
// DataView が不要になっても DataService が生きている限り GC されない
// → 大量に生成・破棄を繰り返すと OOM の原因
}
// ✓ 対策①: IDisposable で購読解除
public class DataViewDisposable : IDisposable
{
private readonly DataService _service;
private bool _disposed = false;
public DataViewDisposable(DataService service)
{
_service = service;
_service.DataChanged += OnDataChanged;
}
private void OnDataChanged(object? sender, string data)
=> Console.WriteLine($"表示更新: {data}");
public void Dispose()
{
if (!_disposed)
{
_service.DataChanged -= OnDataChanged; // 必ず解除
_disposed = true;
}
}
}
// 使い方: using でスコープを限定
var service = new DataService();
using (var view = new DataViewDisposable(service))
{
service.NotifyChange("データ1"); // 表示更新: データ1
}
// using を抜けると Dispose() が呼ばれ、イベント解除される
service.NotifyChange("データ2"); // (ハンドラなし: 出力なし)
// 弱参照ホルダー: 購読者への参照を WeakReference で保持
public class WeakEventSource<TEventArgs> where TEventArgs : EventArgs
{
private readonly List<WeakReference<EventHandler<TEventArgs>>> _handlers = new();
public void Subscribe(EventHandler<TEventArgs> handler)
=> _handlers.Add(new WeakReference<EventHandler<TEventArgs>>(handler));
public void Raise(object sender, TEventArgs args)
{
_handlers.RemoveAll(wr => !wr.TryGetTarget(out _)); // 死んだ参照を掃除
foreach (var wr in _handlers.ToList())
{
if (wr.TryGetTarget(out var handler))
handler(sender, args);
}
}
}
// 使い方: 購読者が GC されると自動的に解除される
var weakEvent = new WeakEventSource<EventArgs>();
var handler = new EventHandler<EventArgs>((s, e) => Console.WriteLine("弱参照ハンドラ"));
weakEvent.Subscribe(handler);
weakEvent.Raise(new object(), EventArgs.Empty); // 弱参照ハンドラ
handler = null; // 参照を切る
GC.Collect(); // 強制 GC
weakEvent.Raise(new object(), EventArgs.Empty); // (ハンドラが GC されたため何も出ない)
① 購読解除忘れ: 発行者が購読者への強参照を持つため、購読者が GC されない。→
IDisposable + using で解除を保証。② 静的イベントへの購読: 静的イベントの発行者はアプリ全体で1つ。購読解除しないと購読者がアプリ終了まで生存。
③ ラムダ式の購読解除不可: 匿名ラムダを
+= で登録した場合、変数に保存していないと -= で解除できない。イベント解除が必要な場合はメソッドグループか変数に保存したラムダを使う。設計判断 — event vs Func/Action コールバック
| 観点 | event EventHandler<T> |
Func<T>/Action<T> コールバック |
|---|---|---|
| 購読者数 | 複数(マルチキャスト) | 1つ(単一コールバック) |
| 外部からの発火 | 不可(event で保護) |
可能(デリゲート変数を呼べる) |
| null 安全 | ? で宣言すれば発火で安全 |
?. で null チェック必要 |
| 解除の仕組み | -= で解除 |
変数の再代入または null 代入 |
| 標準パターン | sender + EventArgs |
シグネチャ自由 |
| 適した場面 | UI イベント・ドメインイベント・複数購読者が想定される通知 | 単一コールバック・内部コールバック・LINQ・高階関数 |
// Action コールバック: 単一の完了通知や変換処理
public class FileDownloader
{
// 単一コールバック(完了したら呼ぶだけ)
public Task DownloadAsync(string url, Action<byte[]> onComplete)
{
// ダウンロード処理...
byte[] data = Array.Empty<byte>();
onComplete(data);
return Task.CompletedTask;
}
}
// event: 複数の購読者が存在し得る通知
public class StockPriceMonitor
{
// 複数のウィジェット・ロガーが同じ株価変更を受け取る
public event EventHandler<decimal>? PriceChanged;
private decimal _price;
public decimal Price
{
get => _price;
set
{
if (_price != value)
{
_price = value;
PriceChanged?.Invoke(this, value);
}
}
}
}
// 使い方: 複数のハンドラが同じイベントを受け取る
var monitor = new StockPriceMonitor();
monitor.PriceChanged += (_, price) => Console.WriteLine($"チャート更新: {price:C}");
monitor.PriceChanged += (_, price) => Console.WriteLine($"アラート確認: {price:C}");
monitor.PriceChanged += (_, price) => Console.WriteLine($"ログ記録: {price:C}");
monitor.Price = 15000m;
// チャート更新: ¥15,000
// アラート確認: ¥15,000
// ログ記録: ¥15,000
よくある質問
Dispose() 実行など)されても、イベントのバッキングフィールドが残っている間は購読者への参照が保持されます。発行者の Dispose() 内でイベントを null 相当に初期化する(MyEvent = null; ただしこれはクラス内部でのみ可)か、発行者のデストラクタで購読者に通知するパターンを使います。より安全なのは発行者も IDisposable を実装し、Dispose() でイベントを無効化することです。+= で登録したラムダと -= で指定するラムダは「別のオブジェクト」として扱われます。解除したい場合はラムダを変数に保存しておくか(EventHandler h = (s, e) => ...; ev += h; ev -= h;)、名前付きメソッドのメソッドグループ変換を使ってください。EventArgs.Empty と new EventArgs() の違いは?EventArgs.Empty は静的フィールドに格納された共有インスタンスです。データを渡さないイベントでは new EventArgs() のようにインスタンスを都度生成するよりも EventArgs.Empty を使う方がアロケーションを節約できます。IComparer<T> のような単一メソッドのインターフェースは歴史的経緯ですが、現代では Comparison<T>(デリゲート)でも同じことができます。まとめ
| 機能・概念 | ポイント |
|---|---|
| カスタムデリゲート | delegate キーワードで宣言。ref/out が必要な場合に使う。通常は Func/Action で代替 |
| MulticastDelegate | += で複数メソッドを登録。戻り値は最後のもののみ。例外で残りは止まる |
| GetInvocationList() | 登録されたデリゲートを個別に処理(戻り値収集・例外ごとの処理) |
| EventHandler<T> | sender + EventArgs 標準パターン。OnXxx メソッドを protected virtual にする |
| event キーワード | public デリゲートフィールドを保護。外部からの発火・上書き代入を禁止 |
| add/remove アクセサ | lock による購読スレッドセーフ・購読者数の制限などカスタム制御 |
| ?.Invoke() | TOCTOU 競合を防ぐスレッドセーフパターン。null チェック代わりに常用 |
| メモリリーク対策 | IDisposable で解除を保証。ラムダは変数保存か名前付きメソッドで登録 |
| 共変性・反変性 | 戻り値は共変(派生型OK)・引数は反変(基底型OK) |
| event vs Action | 複数購読者 → event。単一コールバック・内部処理 → Action/Func |
ラムダ式・クロージャ・Func/Action の詳細はラムダ式完全ガイド、イベントと親和性の高い IDisposable によるリソース管理はIDisposable・using完全ガイドを参照してください。
