イベントを await で受け取る

2022/02/15に公開

なぜイベントを await で受け取りたいか

C# プログラミングではイベントのハンドリングが欠かせません。ボタンをクリックした時のイベント、画面が表示された時のイベントなど、様々なイベントに応じて呼び出されるコードを書く方式を、イベント駆動プログラミングと言います。

近頃は、イベントの代わりにコマンドやビヘイビアを用いることも多くなり、以前に比べてイベントの出番は少なくなりましたが、それでもまだイベントハンドラーを書かなくてはならない時は、往々にしてあります。特に INotifyPropertyChangedPropertyChanged イベントなどはよく使用されるでしょう。

ところが、このイベントハンドラーというやつは、呼ばれることを前提に書くために、どうしてもロジックがあちこちに分散することになります

そこで、イベントから呼ばれるのを受動的に待つのではなく、イベントが起こるのを積極的に待つことができれば便利です。

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 が押されました");

上記のコードはどちらも、button1button2 がこの順に押された時だけメッセージを出すものです。

前者の方は、イベントハンドラがいつ呼ばれるかわからないため、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 を書いてください。そうすれば、そのファイルで拡張メソッドが有効になります。

コードの解説は後述します。

WaitEvent.cs
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 です

GetHandlerWaitEvent 内部から呼ばれてイベントハンドラーを作成する役割を持ち、handlerFactoryCache は作成されたイベントハンドラーを再利用するために保存しておく場所です。

イベントハンドラーには EventHandler 型、PropertyChangedEventHandler 型など様々な型があり、それらは相互にコンバートできませんEventHandler 型のシグネチャは void(object sender, EventArgs e) ですが、同じシグネチャの Action<object, EventArgs> を割り当てることができないという不便な仕様となっています。そのため、型に従ったそれぞれのイベントハンドラーを実行時に作成する必要があります

イベントハンドラーの作成には式木[1]を使用していますが、使用するたびに式木をコンパイルしていてはパフォーマンスが悪すぎるので、一度作成したイベントハンドラーは保存して再利用しています。その保存先が handlerFactoryCache というわけです。

handlerFactoryCache の実態は ConcurrentDictionary<TKey, TValue>[2] です。スレッドセーフな辞書ですね。

WaitEvent

handlerFactoryCacheGetHandler の解説はこのくらいに留めておき、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 キーワードがついているので、通常の呼び出し方に加えて、拡張メソッドとして使うことができます。

次の二つは表記が違うだけで同じことをしており、第一引数 senderbutton1 を指定しています。

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 および eventNamenull でないことを保障します。

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]です。ここでは、privateprotected なども含めたインスタンスイベントを検索しています。

イベントの検索に失敗した場合、例外を発生させます。

??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 を呼び出すことで、タスクを操作できるということです。

次のように、cancellationTokenRegister メソッドを使用して、キャンセルされた時の処理を登録できます。

cancellationToken?.Register(() => completionSource.SetCanceled());

?. は null 条件演算子[6]です

この場合は cancellationTokennull でない場合に 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 から得られたもので、SetResultSetCanceled で操作でき、イベントハンドラーからは 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

脚注
  1. 式木(Expression Trees) - C# によるプログラミング入門 | ++C++; // 未確認飛行 C ↩︎

  2. ConcurrentDictionary<TKey,TValue> クラス (System.Collections.Concurrent) | Microsoft Docs ↩︎

  3. BindingFlags 列挙型 (System.Reflection) | Microsoft Docs ↩︎

  4. ?? および ??= 演算子 - C# リファレンス | Microsoft Docs ↩︎

  5. TaskCompletionSource<TResult> クラス (System.Threading.Tasks) | Microsoft Docs ↩︎

  6. Null 条件演算子 ?. および ?[] - C# リファレンス | Microsoft Docs ↩︎

GitHubで編集を提案

Discussion