【C#】デリゲートとイベント完全ガイド|MulticastDelegate・EventHandler・addアクセサ・メモリリーク対策まで

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() の挙動が理解しやすくなります。

デリゲートの型階層と 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);
}
マルチキャストデリゲートの2つの落とし穴
戻り値: マルチキャスト呼び出しの戻り値は最後のデリゲートの値だけです。複数の戻り値を収集したい場合は GetInvocationList() で個別に呼び出してください。
例外: 途中のデリゲートが例外を投げると、以降のデリゲートは実行されません。すべてのデリゲートを確実に実行したい場合は GetInvocationList() + try-catch で個別に呼ぶパターンを使います。

EventHandler<TEventArgs> — 標準イベントパターン

C# のイベントには「標準パターン」があります。sender(発行者)と EventArgs(イベントデータ)を引数に持つEventHandler<TEventArgs> シグネチャを使うことで、フレームワークとの相互運用性と一貫性が高まります。

カスタム EventArgs とイベント標準パターン
// カスタム 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 なし vs event あり
// 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 アクセサがあります。スレッドセーフなイベントを実装したり、購読者リストをカスタム管理したりするときに使います。

明示的 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 チェックの必然性

?.Invoke() vs 旧来の 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 を使った弱参照イベント
// 弱参照ホルダー: 購読者への参照を 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 されたため何も出ない)
イベントメモリリークの3パターン
購読解除忘れ: 発行者が購読者への強参照を持つため、購読者が GC されない。→ IDisposable + using で解除を保証。
静的イベントへの購読: 静的イベントの発行者はアプリ全体で1つ。購読解除しないと購読者がアプリ終了まで生存。
ラムダ式の購読解除不可: 匿名ラムダを += で登録した場合、変数に保存していないと -= で解除できない。イベント解除が必要な場合はメソッドグループか変数に保存したラムダを使う。

設計判断 — event vs Func/Action コールバック

観点 event EventHandler<T> Func<T>/Action<T> コールバック
購読者数 複数(マルチキャスト) 1つ(単一コールバック)
外部からの発火 不可(event で保護) 可能(デリゲート変数を呼べる)
null 安全 ? で宣言すれば発火で安全 ?. で null チェック必要
解除の仕組み -= で解除 変数の再代入または null 代入
標準パターン sender + EventArgs シグネチャ自由
適した場面 UI イベント・ドメインイベント・複数購読者が想定される通知 単一コールバック・内部コールバック・LINQ・高階関数
event vs Action コールバックの使い分け
// 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

よくある質問

Qイベントの発行者が先に破棄された場合、購読者への影響は?
A発行者が破棄(Dispose() 実行など)されても、イベントのバッキングフィールドが残っている間は購読者への参照が保持されます。発行者の Dispose() 内でイベントを null 相当に初期化する(MyEvent = null; ただしこれはクラス内部でのみ可)か、発行者のデストラクタで購読者に通知するパターンを使います。より安全なのは発行者も IDisposable を実装し、Dispose() でイベントを無効化することです。
Qラムダ式で登録したイベントを解除できないのはなぜですか?
Aラムダ式は呼び出しのたびに新しいデリゲートインスタンスを生成するため、+= で登録したラムダと -= で指定するラムダは「別のオブジェクト」として扱われます。解除したい場合はラムダを変数に保存しておくか(EventHandler h = (s, e) => ...; ev += h; ev -= h;)、名前付きメソッドのメソッドグループ変換を使ってください。
QEventArgs.Emptynew EventArgs() の違いは?
AEventArgs.Empty は静的フィールドに格納された共有インスタンスです。データを渡さないイベントでは new EventArgs() のようにインスタンスを都度生成するよりも EventArgs.Empty を使う方がアロケーションを節約できます。
Qデリゲートと interface の使い分けは?
A単一のメソッドシグネチャを抽象化したいだけならデリゲートで十分です。関連する複数のメソッドを一まとめにしたい場合や、オブジェクトのアイデンティティが重要な場合はインターフェースを使います。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完全ガイドを参照してください。