イベントを await で受け取る
なぜイベントを await で受け取りたいか
C# プログラミングではイベントのハンドリングが欠かせません。ボタンをクリックした時のイベント、画面が表示された時のイベントなど、様々なイベントに応じて呼び出されるコードを書く方式を、イベント駆動プログラミングと言います。
近頃は、イベントの代わりにコマンドやビヘイビアを用いることも多くなり、以前に比べてイベントの出番は少なくなりましたが、それでもまだイベントハンドラーを書かなくてはならない時は、往々にしてあります。特に INotifyPropertyChanged
の PropertyChanged
イベントなどはよく使用されるでしょう。
ところが、このイベントハンドラーというやつは、呼ばれることを前提に書くために、どうしてもロジックがあちこちに分散することになります。
そこで、イベントから呼ばれるのを受動的に待つのではなく、イベントが起こるのを積極的に待つことができれば便利です。
private int state = 0;
private void Button1_Click(object sender, EventArgs e)
{
if (state != 0) return;
MessageBox.Show("button1 が押されました");
state = 1;
}
private void Button2_Click(object sender, EventArgs e)
{
if (state != 1) return;
MessageBox.Show("button2 が押されました");
state = 2;
}
await button1.Click;
MessageBox.Show("button1 が押されました");
await button2.Click;
MessageBox.Show("button2 が押されました");
上記のコードはどちらも、button1
と button2
がこの順に押された時だけメッセージを出すものです。
前者の方は、イベントハンドラがいつ呼ばれるかわからないため、state
という名前の状態を表す変数を用意し、その値を制御しています。いわゆる「フラグ管理」と呼ばれる方法です。それに比べて後者の方は、特定のイベントが特定のタイミングで起きるのを待つだけなので、前者に比べてはるかに直感的にプログラミングできます。
System.Reactive
を用いれば、同じようにイベントをシーケンシャルに扱うことができるのですが、これはこれで概念を理解するのがやや難しく、敬遠されがちです。慣れればこんなに便利なものはなかなかないのですが。
そこで、このようにイベントを待機することのできる拡張メソッドを実装する拡張メソッドを用意しました。この拡張メソッドを使えば……まあ残念ながら先ほどのイメージほど短くは書けませんが、次のように書くことができます。
await button1.WaitEvent<EventArgs>(nameof(button1.Click));
MessageBox.Show("button1 が押されました");
await button2.WaitEvent<EventArgs>(nameof(button2.Click));
MessageBox.Show("button2 が押されました");
また、ドラッグは次のように書きます。
private async Task<(Point Start, Point End)> Drag()
{
// マウスボタンが押されるのを待つ
// 待っている間、CPU を使用しないので、他の処理の裏で動くことができる
var start = await this.WaitEvent<MouseEventArgs>(nameof(MouseDown));
// マウスボタンが放されるのを待つ
var end = await this.WaitEvent<MouseEventArgs>(nameof(MouseUp));
// 押したときと放したときの位置をそれぞれ Point 構造体に保存
var startPoint = new Point(start.EventArgs.X, start.EventArgs.Y);
var endPoint = new Point(end.EventArgs.X, end.EventArgs.Y);
// それらをタプルにまとめて返す
return (startPoint, endPoint);
}
// ウィンドウが表示される前に呼ばれるイベント
private async void Form1_Load(object sender, EventArgs e)
{
// 非同期なので、このループはその後のウィンドウの表示やサイズ変更などを何も妨げない
while (true)
{
// Drag メソッドが戻ってくるのを非同期で待つ
// もちろん待機に CPU は使用しない
var (startPoint, endPoint) = await Drag();
// ドラッグが終わったところでメッセージを表示
MessageBox.Show($"{startPoint}→{endPoint}");
}
}
変化したプロパティを知るには次のように書きます。
var viewModel = new BindableBase(); // INotifyPropertyChanged を実装したオブジェクト
PropertyChangedEventArgs args;
do
{
(_, args) = await viewModel.WaitEvent<PropertyChangedEventArgs>(nameof(viewModel.PropertyChanged));
} while (args.PropertyName != nameof(viewModel.PropertyYouWantToKnow));
コード
以下のコードを WaitEvent.cs という名前でプロジェクトに含め、拡張メソッドを使用したいファイルで using Zuishin.WaitEvent
を書いてください。そうすれば、そのファイルで拡張メソッドが有効になります。
コードの解説は後述します。
using System;
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Zuishin.WaitEvent
{
static class WaitEventExtensions
{
private static readonly ConcurrentDictionary<Type, Delegate> handlerFactoryCache = new ConcurrentDictionary<Type, Delegate>();
public static Task<(object Sender, TEventArgs EventArgs)> WaitEvent<TEventArgs>(
this object sender,
string eventName,
CancellationToken? cancellationToken = null)
{
if (sender is null) throw new ArgumentNullException(nameof(sender));
if (eventName is null) throw new ArgumentNullException(nameof(eventName));
var targetEvent = sender
.GetType()
.GetEvent(eventName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?? throw new ArgumentException($"{nameof(sender)} has no event named {eventName}.");
var completionSource = new TaskCompletionSource<(object, TEventArgs)>();
cancellationToken?.Register(() => completionSource.SetCanceled());
var handler = GetHandler(targetEvent.EventHandlerType, completionSource);
targetEvent.AddEventHandler(sender, handler);
return completionSource.Task.ContinueWith(task =>
{
targetEvent.RemoveEventHandler(sender, handler);
return task.Result;
});
}
private static Delegate GetHandler<TEventArgs>(Type eventHandlerType, TaskCompletionSource<(object, TEventArgs)> completionSource)
{
var factory = handlerFactoryCache.GetOrAdd(eventHandlerType, _ =>
{
var taskCompletionSourceType = typeof(TaskCompletionSource<(object, TEventArgs)>);
var taskCompletionSourceExpression = Expression.Parameter(taskCompletionSourceType);
var setResult = taskCompletionSourceType.GetMethod(nameof(TaskCompletionSource<(object, TEventArgs)>.SetResult));
var sExpression = Expression.Parameter(typeof(object));
var eExpression = Expression.Parameter(typeof(TEventArgs));
var tupleConstructor = typeof(ValueTuple<object, TEventArgs>).GetConstructor(new[] { typeof(object), typeof(TEventArgs) });
var expression = Expression.Lambda<Func<TaskCompletionSource<(object, TEventArgs)>, Delegate>>(
Expression.Lambda(
eventHandlerType,
Expression.Call(
taskCompletionSourceExpression,
setResult,
Expression.New(tupleConstructor, sExpression, eExpression)),
sExpression,
eExpression),
taskCompletionSourceExpression);
return expression.Compile();
});
return ((Func<TaskCompletionSource<(object, TEventArgs)>, Delegate>)factory)(completionSource);
}
}
}
メモリリークに注意
WaitEvent
を使用すると、対象オブジェクトのイベントにイベントハンドラーを登録します。
イベントハンドラーはイベントを受け取ると自動的に登録解除されるので、問題になることは少ないと思いますが、イベントが発生しなかった時には、対象オブジェクトが消えるまでそのまま残り、イベントを待ち続けます。
したがって、寿命の長いオブジェクトに大量の WaitEvent
を使用し、イベントが発生しないまま追加し続けると、メモリリークが生じます。そのような事態が想定される時は、次のように CancellationToken
を使用して破棄してください。
var cts = new CancellationTokenSource(10000);
try
{
await button1.WaitEvent<EventArgs>(nameof(button1.Click), cts.Token);
}
catch(AggregateException ae)
{
ae.Handle(e => e is TaskCanceledException);
}
上記コードでは、10 秒後にキャンセル信号を発信する CancellationToken
を作成しています。そのトークンを WaitEvent
に渡すことにより、10 秒以上クリックされなかった場合はイベント登録が解除され、更に例外が発生します。
発生する例外は TaskCanceledException
ですが、非同期の場合はこれが AggregateException
に集約されるため、AggregateException
をキャッチし、TaskCanceledException
だけ握りつぶすよう、try
catch
で処理します。
あるいは cts.Cancel()
を呼び出すことにより、10 秒経たなくても同様にイベントを破棄できます。
コードの解説
概要
コードには一つの private
フィールド handlerFactoryCache
、一つの public
メソッド WaitEvent
、一つの private
メソッド GetHandler
があります。
このうち、拡張メソッドを実装しているのは WaitEvent
です。
GetHandler
は WaitEvent
内部から呼ばれてイベントハンドラーを作成する役割を持ち、handlerFactoryCache
は作成されたイベントハンドラーを再利用するために保存しておく場所です。
イベントハンドラーには EventHandler
型、PropertyChangedEventHandler
型など様々な型があり、それらは相互にコンバートできません。EventHandler
型のシグネチャは void(object sender, EventArgs e)
ですが、同じシグネチャの Action<object, EventArgs>
を割り当てることができないという不便な仕様となっています。そのため、型に従ったそれぞれのイベントハンドラーを実行時に作成する必要があります。
イベントハンドラーの作成には式木[1]を使用していますが、使用するたびに式木をコンパイルしていてはパフォーマンスが悪すぎるので、一度作成したイベントハンドラーは保存して再利用しています。その保存先が handlerFactoryCache
というわけです。
handlerFactoryCache
の実態は ConcurrentDictionary<TKey, TValue>
[2] です。スレッドセーフな辞書ですね。
WaitEvent
handlerFactoryCache
と GetHandler
の解説はこのくらいに留めておき、WaitEvent
をもう少しだけ詳しく解説します。
以下はシグネチャです。
public static Task<(object Sender, TEventArgs EventArgs)> WaitEvent<TEventArgs>(
this object sender,
string eventName,
CancellationToken? cancellationToken = null)
戻り値の型は Task<(object Sender, TEventArgs EventArgs)>
です。await
で受け取る場合には (object Sender, TEventArgs EventArgs)
が戻り値になります。
タプルなので次のように分解して受け取ることができます。
object sender;
EventArgs args;
(sender, args) = await button1.WaitEvent<EventArgs>(nameof(button1.Click));
// 受け取るのが EventArgs だけで良い場合
(_, args) = await button1.WaitEvent<EventArgs>(nameof(button1.Click));
シグネチャの話に戻ります。
第一引数はイベントを発生させるオブジェクトです。this
キーワードがついているので、通常の呼び出し方に加えて、拡張メソッドとして使うことができます。
次の二つは表記が違うだけで同じことをしており、第一引数 sender
に button1
を指定しています。
await WaitEventExtensions.WaitEvent<EventArgs>(button1, nameof(button1.Click));
await button1.WaitEvent<EventArgs>(nameof(button1.Click));
第二引数はイベント名です。string
型なので文字列リテラルで指定することもできますが、nameof
演算子を使うと、コンパイル時のスペルチェックも効き、開発時のイベント名の変更にも強いので推奨します。
次の二つは同じものです。
await button1.WaitEvent<EventArgs>("Click");
await button1.WaitEvent<EventArgs>(nameof(button1.Click));
第三引数は CancellationToken
です。これは前述した通り、イベントのハンドルを中断して登録解除するために使用します。キャンセル時に例外が発生するため、使用する場合は try
catch
で囲んでください。
例外が発生することにより、戻り値が意図しない値になることを防ぐことができます。
中身の解説に入ります。
以下は、ガードです。sender
および eventName
が null
でないことを保障します。
if (sender is null) throw new ArgumentNullException(nameof(sender));
if (eventName is null) throw new ArgumentNullException(nameof(eventName));
以下は、リフレクションでイベントを取得しています。
var targetEvent = sender
.GetType()
.GetEvent(eventName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?? throw new ArgumentException($"{nameof(sender)} has no event named {eventName}.");
sender
はイベントを発生させるオブジェクトです。sender.GetType()
によりその型を取得し、型の GetEvent()
を呼び出すことでイベントを取得しています。
GetEvent()
の第一引数はイベント名で、第二引数はイベントの検索条件として使うバインディングフラッグ[3]です。ここでは、private
や protected
なども含めたインスタンスイベントを検索しています。
イベントの検索に失敗した場合、例外を発生させます。
??
は null
合体演算子[4]です。これは左辺が null
でなかった場合は左辺を返し、null
であった場合は右辺を返す演算子ですが、右辺に throw
式を持ってきた場合、左辺が null
であれば例外を発生させるという意味になります。
たとえば次のコードの場合、
var s = "Alice" ?? "Bob"; // ①
var s = null ?? "Bob"; // ②
var s = null ?? throw new Exception(); // ③
①は ??
の左辺の "Alice"
が null
でないので、変数 s
には "Alice"
が入り、②は左辺が null
なので s
には "Bob"
が入ります。そして③は、左辺が null
なので s
には右辺の値が入るはずですが、右辺が throw
式で例外が発生するため、実行が中断され、s
には何も入りません。
話を戻すと、GetEvent()
は該当するイベントが見当たらなかった場合に null
を返します。その際に例外を発生させているわけです。
次に移ります。
var completionSource = new TaskCompletionSource<(object, TEventArgs)>();
TaskCompletionSource<TResult>
[5] のインスタンスを作成しています。
このオブジェクトの重要なメンバーは、Task
プロパティ、SetResult
メソッド、SetCanceled
メソッドです。
Task
プロパティからはタスクが得られます。WaitEvent
の戻り値は、このタスクです。
SetResult
を使用すると、タスクの戻り値を設定してタスクを終了させることができます。戻り値の型は TResult
で、この場合は (object, TEventArgs)
を TResult
にしています。
また、SetCanceled
を使用すると、タスクを中断させることができます。
つまり、イベントが発生した時に SetResult
を呼び出し、キャンセルされた時に SetCanceled
を呼び出すことで、タスクを操作できるということです。
次のように、cancellationToken
の Register
メソッドを使用して、キャンセルされた時の処理を登録できます。
cancellationToken?.Register(() => completionSource.SetCanceled());
?.
は null 条件演算子[6]です。
この場合は cancellationToken
が null
でない場合に Register
メソッドを呼び出し、null
であった時には何もしないという意味になります。
さてその次ですが、まず GetHandler
を呼び出してイベントハンドラーを作成し、それを targetEvent
つまり目的のイベントに登録しています。
var handler = GetHandler(targetEvent.EventHandlerType, completionSource);
targetEvent.AddEventHandler(sender, handler);
GetHandler
は、前述したように、指定した型のイベントハンドラーを作成するメソッドです。
最後に、タスクを返して終了します。
return completionSource.Task.ContinueWith(task =>
{
targetEvent.RemoveEventHandler(sender, handler);
return task.Result;
});
このタスクは completionSource
から得られたもので、SetResult
や SetCanceled
で操作でき、イベントハンドラーからは SetResult
が呼ばれ、cancellationToken
からは SetCanceled
が呼ばれます。
そのタスクが終了した時にイベントから削除する処理を ContinueWith
で加えたものが戻り値になります。
C# 10.0 (nullable enabled) 版
null 許容参照型を有効にした場合のコードも置いておきます。今後、新しいプロジェクトを作成する時には null 許容参照型はデフォルトで有効になるため、移行していきましょう。
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Zuishin.WaitEvent;
static class WaitEventExtensions
{
private static readonly ConcurrentDictionary<Type, Delegate> handlerFactoryCache = new();
public static Task<(object? Sender, TEventArgs EventArgs)> WaitEvent<TEventArgs>(
this object sender,
string eventName,
CancellationToken? cancellationToken = null)
{
if (sender is null) throw new ArgumentNullException(nameof(sender));
if (eventName is null) throw new ArgumentNullException(nameof(eventName));
var targetEvent = sender
.GetType()
.GetEvent(eventName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?? throw new ArgumentException($"{nameof(sender)} has no event named {eventName}.");
var completionSource = new TaskCompletionSource<(object?, TEventArgs)>();
cancellationToken?.Register(() => completionSource.SetCanceled());
var handler = GetHandler(targetEvent.EventHandlerType, completionSource);
targetEvent.AddEventHandler(sender, handler);
return completionSource.Task.ContinueWith(task =>
{
targetEvent.RemoveEventHandler(sender, handler);
return task.Result;
});
}
private static Delegate GetHandler<TEventArgs>(Type eventHandlerType, TaskCompletionSource<(object?, TEventArgs)> completionSource)
{
var factory = handlerFactoryCache.GetOrAdd(eventHandlerType, _ =>
{
var taskCompletionSourceType = typeof(TaskCompletionSource<(object?, TEventArgs)>);
var taskCompletionSourceExpression = Expression.Parameter(taskCompletionSourceType);
var setResult = taskCompletionSourceType.GetMethod(nameof(TaskCompletionSource<(object?, TEventArgs)>.SetResult))!;
var sExpression = Expression.Parameter(typeof(object));
var eExpression = Expression.Parameter(typeof(TEventArgs));
var tupleConstructor = typeof(ValueTuple<object?, TEventArgs>).GetConstructor(new[] { typeof(object), typeof(TEventArgs) })!;
var expression = Expression.Lambda<Func<TaskCompletionSource<(object?, TEventArgs)>, Delegate>>(
Expression.Lambda(
eventHandlerType,
Expression.Call(
taskCompletionSourceExpression,
setResult,
Expression.New(tupleConstructor, sExpression, eExpression)),
sExpression,
eExpression),
taskCompletionSourceExpression);
return expression.Compile();
});
return ((Func<TaskCompletionSource<(object?, TEventArgs)>, Delegate>)factory)(completionSource);
}
}
執筆 2022/02/15
Discussion