Menu

C#のイベント:eventキーワード、EventHandler、購読と発生

C#のイベントの仕組みを解説します。EventHandlerとEventHandler<T>によるイベントの宣言、独自のEventArgsクラス、+=と-=による購読と解除、?.Invokeによる安全なイベントの発生、publicなデリゲートのフィールドよりイベントが優れている理由、そして解除し忘れによるメモリリークを扱います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

イベントを使うと、クラスは誰が聞いているかを知らずに、何かが起きたことを知らせられます。他のオブジェクトは += で購読し、クラスがイベントを発生させると、すべての購読者のメソッドが実行されます。

出力:

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop は、領収書やプレゼントが存在することを知りません。OrderPlaced を発生させ、購読しているものが反応します。新しい反応(ポイント、分析)を加えるには購読者を追加するだけで、PlaceOrder を編集する必要はありません。

イベントの宣言

イベントの宣言は、event キーワードを付けたデリゲート型のメンバーです。

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

デリゲート型がハンドラーのシグネチャを決めます。.NETのコードはほぼ常に2つの組み込みの型のどちらかを使い、この慣例に従うと、他の.NET開発者にとってなじみのあるイベントになります。

  • EventHandler:void (object sender, EventArgs e)。データを持たないイベント用です。EventArgs.Empty で発生させます。
  • EventHandler<TEventArgs>:void (object sender, TEventArgs e)。TEventArgs はデータを持つ独自のクラスです。

これらにまつわる慣例:

  • sender はイベントを発生させたオブジェクトで、通常は this です。
  • データのクラスは {Something}EventArgs という名前にし、EventArgs から派生させ(.NET 4.5以降は任意ですが、今も慣例です)、読み取り専用のプロパティを公開します。
  • イベントには起きたことを表す名前を付けます:OrderPlaced、Closed、PriceChanged。...ing の名前(Closing)は、動作の前に発生するイベントに使われ、多くの場合はキャンセルの手段を伴います。
  • 継承されることを想定したクラスでは、protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) メソッドを通してイベントを発生させ、派生クラスが処理を差し込めるようにします。

購読と解除

+= はハンドラーを追加し、-= は削除します。ハンドラーはメソッドでもラムダでもかまいません。後でラムダを削除するには変数に保持しておきます。同じラムダをもう一度書くと別のデリゲートが作られ、-= はそれを見つけられないからです。

出力:

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

追加していないハンドラーを削除してもエラーにはならず、何も起きません。EventHandler<int> からわかるように、現代の.NETでは型引数が EventArgs から派生している必要はありません。ただし専用のクラスにしておくと、購読者を壊さずに後からフィールドを追加する余地が残ります。

ハンドラーは、イベントを発生させたスレッドで、購読した順に同期的に実行されます。あるハンドラーが例外を投げると、残りのハンドラーは実行されず、例外はイベントを発生させたコードへ伝わります。

イベントを安全に発生させる

購読者のいないイベントは null を保持しています。直接呼び出すと NullReferenceException が投げられるので、null条件演算子で発生させます。

OrderPlaced?.Invoke(this, args);

C# 6より前は、フィールドをローカル変数にコピーし、それを確認して、コピーを呼び出すのが定番の形でした。マルチスレッドのコードではコピーが重要です。OrderPlaced != null を確認してから OrderPlaced(...) を呼び出すとフィールドを2回読むことになり、その間に別のスレッドが最後のハンドラーを削除するかもしれません。?. は1回しか読まないので、短く、しかも正しく書けます。

publicなデリゲートのフィールドではなくeventを使う理由

event キーワードがなくても、publicなデリゲートのフィールドで購読はできますが、すべての購読者に完全な制御を渡してしまいます。

出力:

analytics ping
analytics ping

+= の代わりに = と打ち間違えただけで、それまでの2つのハンドラーが何も言わずに削除され、クリックがなくても外部のコードが「クリック」を発生させられました。フィールドに event を付けると、どちらの行もコンパイルエラーになります。

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

Button の中では、イベントは通常のデリゲートのフィールドと同じように振る舞うので、クラスはそれを呼び出したり、null かどうかを確認したりできます。

解除し忘れによるリーク

購読すると、イベントはデリゲートを保存し、そのデリゲートは購読者のオブジェクトへの参照を持ちます。発行者が生きていてハンドラーが付いている限り、購読者はガベージコレクションの対象にならず、使い終わった後もイベントを受け取り続けます。

出力:

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

leaky を null にしても、フィードには何の影響もありません。ハンドラーのリストがまだそのウィジェットを参照しているので、「閉じた」ウィジェットは更新され続け、メモリに残ります。using ブロックのウィジェットは Dispose で解除したので、受け取りが止まりました。これは.NETのデスクトップやサーバーのコードで最もよくあるメモリリークの1つで、プロセスが終わるまで生き続ける静的イベントやアプリ全体のサービスで特に起こりがちです。

ルール:自分より長生きするオブジェクトのイベントを購読した側は、通常は Dispose の中で解除しなければなりません(using文を参照)。フォームとそのボタンのように、発行者と購読者の寿命が同じなら、解除は不要です。

独自のaddとremoveアクセサー

イベントでは、プロパティの get と set のように、+= と -= の動作を定義できます。まれですが、フレームワークがハンドラーを共有の辞書に保存したり、別のオブジェクトに転送したりするのはこの方法です。

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

独自のアクセサーを使う場合、イベント自体はもう格納場所を持たないので、クラスはバッキングフィールドを通してイベントを発生させます(closed?.Invoke(this, EventArgs.Empty))。

よくある質問

C#のイベントとは何ですか?

イベントは、何かが起きたときに通知を受けたいと他のオブジェクトが申し出るためのクラスのメンバーです。マルチキャストデリゲートに支えられていますが、外部のコードができるのはハンドラーの追加(+=)と削除(-=)だけです。イベントを発生させられるのは、それを宣言したクラスだけです。

C#のイベントとデリゲートの違いは何ですか?

デリゲートはメソッドを参照する型です。イベントはデリゲート型と event キーワードで宣言されるメンバーで、アクセスが制限されます。クラスの外からは、呼び出すことも、ハンドラーのリストを読むことも、= で代入することもできず、購読と解除しかできません。publicなデリゲートのフィールドではそのすべてが可能なので、どの購読者も他の購読者を消したり、イベントを発生させたりできてしまいます。

C#のイベントでデータを渡すには?

データを読み取り専用のプロパティとして持つ、EventArgs から派生したクラスを作り(public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } })、イベントを EventHandler<OrderPlacedEventArgs> として宣言します。OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total)); で発生させます。

C#でイベントを安全に発生させるには?

MyEvent?.Invoke(this, args); を使います。購読者のいないイベントは null なので、直接呼び出すと NullReferenceException が投げられます。?. 演算子はフィールドを1回だけ読むので、nullチェックと呼び出しの間に別のスレッドが最後のハンドラーを削除する競合も避けられます。

C#のイベントはメモリリークを起こしますか?

起こします。購読すると購読者を参照するデリゲートが保存されるので、発行者が生きている限り購読者も生かされ続けます。寿命の長い発行者(静的イベント、アプリ全体のサービス)に、解除しない寿命の短い購読者がいると、そのすべてがメモリに残ります。購読者が不要になったら、通常は Dispose の中で -= によって解除します。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める