C#を用いたアプリケーション開発において、コンポーネント間の疎結合を実現するために欠かせない機能が「イベント」です。

イベントは、特定の状態変化やユーザー操作が発生した際に、それを他のオブジェクトに通知する仕組みを提供します。

GUIアプリケーションからバックエンドの非同期処理まで、オブジェクト指向設計における「Observer(オブザーバー)パターン」を言語レベルでサポートしているのがC#のイベントの最大の特徴です。

本記事では、C#におけるイベントの基礎から、よく混同されがちなデリゲート(delegate)やActionとの違い、実務で役立つ実装パターン、そしてメモリリークを防ぐための注意点までを網羅的に解説します。

C#におけるイベントの基本概念

C#のイベントとは、クラスやオブジェクトが何らかの「通知」を外部に送るための仕組みです。

この仕組みは、通知を送る側のパブリッシャー(発行者)と、通知を受け取って処理を行うサブスクライバー(購読者)という2つの役割で構成されます。

例えば、ボタンがクリックされたときに実行される処理をイメージしてください。

ボタン(パブリッシャー)は「クリックされた」という事実を通知するだけで、その後に何が起こるか(画面遷移するのか、データを保存するのか)を詳しく知る必要はありません。

一方、受け手側(サブスクライバー)は、その通知をトリガーにして独自の処理を実行します。

このように、イベントを利用することで、呼び出し側と呼び出される側の依存関係を切り離す(疎結合にする)ことが可能になります。

これにより、コードの再利用性やメンテナンス性が飛躍的に向上します。

delegate・Actionとイベントの違い

C#のイベントを理解する上で避けて通れないのが、デリゲート(delegate)や汎用デリゲートであるActionとの関係性です。

イベントの実体はデリゲートの一種ですが、言語仕様として明確な違いが設けられています。

デリゲート(delegate)とは

デリゲートは「メソッドを代入できる型」です。

関数ポインタのような役割を果たし、メソッドを変数として扱ったり、引数として渡したりすることができます。

C#
// デリゲートの定義
public delegate void SampleDelegate(string message);

public class DelegateExample
{
    public void Execute()
    {
        // メソッドを変数に代入
        SampleDelegate del = ShowMessage;
        del("こんにちは");
    }

    private void ShowMessage(string message)
    {
        Console.WriteLine(message);
    }
}

イベント(event)との違い

デリゲートをそのまま公開(publicに)して使用することも可能ですが、それではカプセル化の観点で問題があります。

デリゲート変数を直接公開すると、外部から=演算子を使って既存の購読者をすべて上書きして消去したり、外部から勝手にイベントを発生(発火)させたりすることができてしまうためです。

これに対し、デリゲート宣言に event キーワードを付与することで、以下の制約が課せられます。

  1. 外部からは +=(購読)と -=(解除)しか行えない。
  2. イベントを発火(呼び出し)できるのは、そのイベントを定義したクラス内部のみに限定される。

Action・Funcとの使い分け

C#では、あらかじめ定義された汎用デリゲートとして Action(戻り値なし)や Func(戻り値あり)が用意されています。

種類特徴主な用途
delegate独自の型定義が必要特殊なシグネチャが必要な場合やレガシーコード
Action / Func型定義が不要で簡潔メソッドの引数としてコールバックを渡す場合
eventdelegateやActionをラップするオブザーバーパターンの実装、クラスの状態通知

実務においては、「通知」を目的とする場合はeventを使用し、「処理の委譲」や「コールバック」を目的とする場合はAction/Funcを使用するのが一般的な設計指針です。

イベントの基本的な実装方法

それでは、C#でイベントを実装する具体的な手順を見ていきましょう。

最もシンプルな形式から解説します。

ステップ1:デリゲート(またはAction)の定義

まずは、どのようなメソッドを呼び出すかを決めるデリゲートを定義します。

現在は定義の手間を省くため、Action を利用することが多いです。

ステップ2:イベントの宣言

event キーワードを使ってイベントを宣言します。

ステップ3:イベントの発火

クラス内の適切なタイミングでイベントを呼び出します。

この際、購読者が誰もいない場合にNullReferenceExceptionが発生するのを防ぐため、nullチェックが必須となります。

以下に、温度センサーが一定温度を超えたら通知するサンプルコードを示します。

C#
using System;

namespace EventSample
{
    // パブリッシャー:温度センサー
    public class TemperatureSensor
    {
        // ステップ1&2:イベントの宣言 (Actionを使用)
        public event Action<int> OnTemperatureChanged;

        public void Monitor(int currentTemp)
        {
            Console.WriteLine($"現在の温度: {currentTemp}度");

            // 30度を超えたらイベントを発火
            if (currentTemp > 30)
            {
                // ステップ3:イベントの発火
                // ?.Invoke() は nullチェックの糖衣構文
                OnTemperatureChanged?.Invoke(currentTemp);
            }
        }
    }

    // サブスクライバー:警報システム
    public class AlarmSystem
    {
        public void Connect(TemperatureSensor sensor)
        {
            // イベントの購読 (+=)
            sensor.OnTemperatureChanged += HandleAlert;
        }

        private void HandleAlert(int temp)
        {
            Console.WriteLine($"【警告】異常高温を検知しました: {temp}度!");
        }
    }

    class Program
    {
        static void Main()
        {
            var sensor = new TemperatureSensor();
            var alarm = new AlarmSystem();

            alarm.Connect(sensor);

            // センサーの値をシミュレート
            sensor.Monitor(25);
            sensor.Monitor(32);
        }
    }
}
実行結果
現在の温度: 25度
現在の温度: 32度
【警告】異常高温を検知しました: 32度!

このコードでは、OnTemperatureChanged?.Invoke(currentTemp) という記述を使っています。

これは C# 6.0 以降で導入された「null条件演算子」で、「イベントに誰も登録していなければ何もしない、登録されていれば実行する」という安全な呼び出しを簡潔に記述できるため、現在の開発では必須の書き方です。

標準的なEventHandlerの活用

C# .NET の標準ライブラリでは、イベントのシグネチャを統一するために EventHandler という型が推奨されています。

独自のデリゲートや Action を使うよりも、.NETの標準的な作法に従うことで、他の開発者にとって読みやすいコードになります。

EventHandlerの構成

標準的なイベントハンドラは、以下の2つの引数を持ちます。

  1. object sender: イベントを発生させたオブジェクト自体。
  2. TEventArgs e: イベントに関する詳細データを含むオブジェクト。

実装例:カスタムEventArgsの使用

イベントで渡したいデータが多い場合は、EventArgs クラスを継承したカスタムクラスを作成します。

C#
using System;

// 1. イベント引数の定義
public class OrderEventArgs : EventArgs
{
    public string ProductName { get; }
    public int Price { get; }

    public OrderEventArgs(string name, int price)
    {
        ProductName = name;
        Price = price;
    }
}

// 2. パブリッシャー
public class OrderProcessor
{
    // ジェネリック版のEventHandlerを使用
    public event EventHandler<OrderEventArgs> OrderCompleted;

    public void Process(string name, int price)
    {
        Console.WriteLine($"{name} の注文を処理中...");
        
        // 処理完了後に通知
        OnOrderCompleted(new OrderEventArgs(name, price));
    }

    // イベント発火用の保護メソッド (慣習的に On+イベント名)
    protected virtual void OnOrderCompleted(OrderEventArgs e)
    {
        OrderCompleted?.Invoke(this, e);
    }
}

// 3. サブスクライバー
public class InventoryManager
{
    public void OnOrderCompleted(object sender, OrderEventArgs e)
    {
        Console.WriteLine($"在庫管理システム: {e.ProductName} の在庫を減らします。");
    }
}

class Program
{
    static void Main()
    {
        var processor = new OrderProcessor();
        var inventory = new InventoryManager();

        // 購読
        processor.OrderCompleted += inventory.OnOrderCompleted;

        processor.Process("ノートPC", 150000);
    }
}
実行結果
ノートPC の注文を処理中...
在庫管理システム: ノートPC の在庫を減らします。

このパターンでは、「誰が(sender)」「何を(e)」したのかが一目でわかるため、大規模なシステムにおいて非常に強力なデバッグ・設計の助けとなります。

イベント実装時の注意点とベストプラクティス

イベントは便利な機能ですが、不適切な使い方をするとメモリリークや意図しない動作の原因となります。

特に重要な注意点をいくつか挙げます。

1. メモリリーク(強い参照)の防止

イベントの最大の落とし穴は、「イベントを購読したままオブジェクトを破棄しようとしても、パブリッシャーがサブスクライバーへの参照を持ち続けているため、GC(ガベージコレクション)で解放されない」という問題です。

これを防ぐためには、オブジェクトが不要になったタイミングで必ず -= 演算子を用いてイベントの購読を解除する必要があります。

C#
public class Subscriber : IDisposable
{
    private readonly Publisher _publisher;

    public Subscriber(Publisher pub)
    {
        _publisher = pub;
        _publisher.Notify += HandleEvent;
    }

    public void Dispose()
    {
        // 破棄時に必ず解除する
        _publisher.Notify -= HandleEvent;
    }

    private void HandleEvent(object sender, EventArgs e) { /* ... */ }
}

特に、ライフサイクルが長いオブジェクト(メイン画面など)のイベントを、寿命の短いオブジェクトが購読する場合は細心の注意を払ってください。

2. スレッドセーフな発火

マルチスレッド環境において、イベントの発火直前に購読が解除されると、nullチェックを通過した後にイベントがnullになり、例外が発生する可能性があります。

前述した event?.Invoke() という書き方は、内部的にデリゲートをローカル変数にコピーしてからチェックと実行を行うため、スレッドセーフ(安全)です。

古いC#の書き方である if (handler != null) handler(this, e); は、マルチスレッド環境では推奨されません。

3. イベントの例外ハンドリング

イベントに複数のメソッドが登録されている場合、そのうちの1つで例外が発生すると、後続のメソッドは実行されません。

もし全てのハンドラを確実に実行したい場合は、GetInvocationList() メソッドを使用して、個別に呼び出しと try-catch を行う必要があります。

C#
protected virtual void OnSafeNotify(EventArgs e)
{
    if (Notify == null) return;

    foreach (Delegate handler in Notify.GetInvocationList())
    {
        try
        {
            handler.DynamicInvoke(this, e);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"ハンドラでエラーが発生しました: {ex.Message}");
        }
    }
}

4. ラムダ式による購読の注意点

ラムダ式を使って手軽にイベントを購読することができますが、ラムダ式は名前がないため、後から個別に解除することが困難です。

C#
// 解除できない例
publisher.Notify += (s, e) => Console.WriteLine("通知されました");

// 解除が必要な場合は、匿名関数ではなくメソッドを使用するか、変数に保持する
EventHandler handler = (s, e) => Console.WriteLine("通知されました");
publisher.Notify += handler;
// 後で
publisher.Notify -= handler;

使い捨てのオブジェクトであれば問題ありませんが、長期的に生存するオブジェクトではメソッドとして定義するべきです。

イベントと非同期処理(async/await)

モダンなC#開発では、イベントハンドラ内で非同期処理を行いたい場面が増えています。

しかし、イベントのシグネチャは通常 void を返すため、いくつかの注意点があります。

async void の使用

イベントハンドラは例外的に async void が許容される場所です。

C#
public async void OnButtonClicked(object sender, EventArgs e)
{
    // 非同期処理を待機できる
    await Task.Run(() => LongRunningProcess());
    Console.WriteLine("完了");
}

ただし、async void 内で発生した例外は、呼び出し元でキャッチできないという性質があります。

そのため、メソッド内部で必ず try-catch を行い、アプリケーションがクラッシュしないように保護することが重要です。

まとめ

C#のイベントは、クラス間の結合度を下げ、柔軟で拡張性の高い設計を実現するための強力なツールです。

デリゲートをベースにしながらも、event キーワードによるカプセル化によって、安全な通知の仕組みが保証されています。

実装にあたっては、以下のポイントを常に意識しましょう。

  • 疎結合の維持: パブリッシャーはサブスクライバーの具体的な中身を知らないように設計する。
  • 標準の遵守: EventHandler<TEventArgs> を使用して、一貫性のあるインターフェースを提供する。
  • 安全な発火: ?.Invoke() を使い、nullチェックとスレッド安全性を確保する。
  • リソース管理: 不要になったイベントは必ず -= で解除し、メモリリークを防止する。

これらの原則を守ることで、堅牢でメンテナンスしやすいC#プログラムを記述できるようになります。

イベントの仕組みを正しく理解し、モダンなアプリケーション開発に活かしていきましょう。