C#デリゲートのコールバック設計:進捗・キャンセル・チェーンの例外
C#のコールバックを.NET 10で設計する。ActionとIProgress<T>、コンソールでProgress<T>の順序が崩れる理由、CancellationToken、チェーン途中の例外とGetInvocationList。
2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語向けに書き直したものです。例をすべて .NET 10.0.14(C# 14)でコンパイルし直したところ、完成した例4つのうち、原文のままコンパイルできたものは1つもありませんでした。デリゲートを
MtDelegateで宣言してMyDelegateで使っていた(error CS0246)、ConsoleをComsoleやColsoleと誤記していた(error CS0103)、ジェネリックの例に3.3 fと空白入りのリテラルがあった、などです。宣言例のdelegate void typeA(void);は C の書き方で、C# では error CS1536 になります。typeBの変数にnew typeA(funcB)を代入していた箇所も error CS0123 の対象です。誤字を直すと出力は原文の説明どおりでした。デリゲートの基本は手短にまとめ、原文が「コールバック」という言葉で触れただけだった実務でのコールバック設計を中心に書き直しています。
この記事は、「メソッドにデリゲートを渡せる」ことは知っていて、進捗を知らせる・キャンセルを受け付ける・複数の相手に通知するといったコールバックの API を自分で設計する必要が出てきた人向けです。先に結論を書くと、進捗は IProgress<T>、キャンセルは CancellationToken で受け取り、チェーンについては2つだけ覚えておけば足ります。例外が出ると残りのハンドラーは実行されないこと、そして戻り値は最後のものしか残らないことです。どちらも GetInvocationList() で解決できます。デリゲートの基本と event キーワードは C#のデリゲートとイベント、Func/Action と呼び出しコストは FuncとActionデリゲート で扱っています。
2021年の例を直して動かし直す
デリゲート型は、参照するメソッドと同じシグネチャで宣言します。原文は宣言の例を C 風に書いていました。
delegate void typeA(void); → error CS1536: Invalid parameter type 'void'
delegate void typeB(int); → error CS1001: Identifier expected
C# に (void) という引数リストはなく、空のリストは () です。またデリゲートの引数には名前が必要です(delegate void typeB(int value);)。シグネチャの合わないメソッドはコンパイル時に弾かれます。int を受け取る funcB で new typeA(funcB) を作ると、error CS0123: No overload for 'funcB' matches delegate 'typeA' でした。
MtDelegate と Comsole を直した最初の例の出力です。
11 + 22 = 33
22 - 11 = 11
コールバックの例は、計算方法を Calculator に渡す構造です。今なら new MyDelegate(...) を書かず、メソッド名かラムダをそのまま渡します。
Calculator(11, 22, Plus); // 33
Calculator(33, 22, (a, b) => a - b); // 11
Calculator(11, 22, (a, b) => a * b); // 242
static void Calculator(int a, int b, Func<int, int, int> op) => Console.WriteLine(op(a, b));
static int Plus(int a, int b) => a + b;
ジェネリックデリゲート delegate T MyDelegate<T>(T a, T b) の例は 33、7.7、12.1 を出力しました。原文になかった点をひとつ。plus が3つオーバーロードされていても、new MyDelegate<int>(plus) はデリゲートのシグネチャから正しいオーバーロードを選びます。現在はジェネリックデリゲートを自前で宣言するより Func<T, T, T> を使うのが普通です。
コールバック設計①:進捗の通知
原文はコールバックを受け取るメソッドを「コールバックメソッド」と呼んでいました。実務で一番よく出てくるコールバックは進捗の通知です。いちばん単純なのは Action<int> を受け取る形です。
static void Process(int items, Action<int> onProgress, CancellationToken ct)
{
for (int i = 1; i <= items; i++)
{
ct.ThrowIfCancellationRequested();
Thread.Sleep(1); // 実際の処理の代わり
onProgress(i * 100 / items);
}
}
Process(5, p => Console.Write($"{p}% "), CancellationToken.None);
Console.WriteLine("| done (Action)");
20% 40% 60% 80% 100% | done (Action)
コールバックは処理スレッド上で同期的に、順番どおり実行されます。コンソールツールなら問題ありませんが、UI アプリではバックグラウンドスレッドから画面のコントロールに触ることになってしまいます。
.NET の標準的な答えが IProgress<T> と、その実装の Progress<T> です。Progress<T> は生成時の SynchronizationContext を捕まえ、通知をそこへ Post します。WPF・WinForms・MAUI なら UI スレッドなので、まさに望みどおりです。ところがコンソールアプリや ASP.NET Core にはコンテキストがないので、通知はスレッドプールに回されます。同じループに Progress<T> を渡した結果です。
20% 40% 60% 80% | done (Progress<T>)
100%
メソッドが終わって done が出たあとで、最後の通知が実行されました。Thread.Sleep(1) を外して10回の通知を5回実行すると、もっとはっきりします。
IProgress<int> ip = new Progress<int>(p => Console.Write($"{p} "));
for (int i = 1; i <= 10; i++) ip.Report(i);
Console.WriteLine("| loop finished");
| loop finished
6 5 8 2 4 10 9 7 3 1
| loop finished
7 6 4 8 1 5 3 9 2 10
| loop finished
2 8 4 7 3 1 6 9 10 5
実行のたびに順番が変わり、通知はすべてループが終わってから届きました。それでも引数の型は IProgress<T> で受けるのが正解です。UI 側は Progress<T> を、コンソールやテスト側は小さな同期実装(class SyncProgress<T>(Action<T> a) : IProgress<T> { public void Report(T v) => a(v); })を渡せばよいからです。このクラスで5回通知すると、1 2 3 4 5 | done と順番どおりに出ました。
通知が多すぎるときのコストも測りました。1つのループから100万回通知し、すべてのコールバックが終わるまでの時間です。条件:.NET 10.0.14、Release ビルド、Stopwatch、ウォームアップ1回のあと3回計測、Apple Silicon Mac(M1 Pro)。
| コールバック | 1回目 | 2回目 | 3回目 |
|---|---|---|---|
Action<int> 100万回 |
2.91 ms | 2.23 ms | 3.82 ms |
Progress<T> 100万回、全部終わるまで |
250.7 ms | 246.9 ms | 214.8 ms |
Progress<T>、% が変わったときだけ(100回) |
1.74 ms | 1.12 ms | 1.07 ms |
Progress<T> は通知1回あたり約64バイトを割り当て(1回目は89バイト、GC.GetTotalAllocatedBytes で計測)、直接呼び出しの約80倍かかりました。対策はデリゲートとは関係なく簡単で、画面に見える値が変わったときだけ通知することです。
コールバック設計②:CancellationToken でキャンセル
呼び出し側が処理を止める手段も必要です。標準的なのは CancellationToken を受け取り、ループの中で確認するやり方です。次は進捗コールバック自身が40%でキャンセルをかける例です。
using var cts = new CancellationTokenSource();
try
{
Process(5, p => { Console.Write($"{p}% "); if (p >= 40) cts.Cancel(); }, cts.Token);
}
catch (OperationCanceledException e)
{
Console.WriteLine($"| {e.GetType().Name}: {e.Message}");
}
20% 40% | OperationCanceledException: The operation was canceled.
守っておきたい慣習は3つです。トークンは最後の引数で受け取る(公開 API なら既定値 default)。処理の単位ごとに ThrowIfCancellationRequested() で確認する。呼び出す非同期 API(Task.Delay(ms, ct)、HttpClient.GetAsync(url, ct))にそのまま渡す。Func<bool> shouldStop のような独自コールバックを作る必要はありません。トークンはタイムアウト(cts.CancelAfter)やリンクしたソースとそのまま組み合わせられます。
デリゲートチェーン:途中で例外が出たら
誤字を直した2021年のチェーンの例は、原文の説明どおりに出力しました(出力文字列は英語に置き換えています)。
first second third
second
原文になかった点が2つあります。1つは、-= で最後のメソッドまで外すと変数が null になることです。確かめると null になっており、null 許容が有効なプロジェクトではその途中で警告 CS8601、CS8602 が出ます。チェーンは ?.Invoke で呼び出します。
もっと大事なのは、ハンドラーの1つが例外を投げたときです。ログ、失敗するハンドラー、キャッシュの無効化をつないだ「ファイル保存済み」のチェーンを作りました。
Action<string> onSaved = name => Console.WriteLine($" [log] saved {name}");
onSaved += name => throw new IOException("disk full");
onSaved += name => Console.WriteLine($" [cache] invalidate {name}");
try { onSaved("a.txt"); }
catch (Exception e) { Console.WriteLine($" caught {e.GetType().Name}: {e.Message}"); }
[log] saved a.txt
caught IOException: disk full
キャッシュは無効化されませんでした。マルチキャストの呼び出しは try のない単純なループなので、最初の例外がループを抜けて呼び出し元へ行き、残りのハンドラーは飛ばされます。書いたログが取り消されることもありません。実際のアプリでは、原因から遠く離れたところで「ときどきキャッシュが古い」という症状として現れます。
全ハンドラーを必ず実行したいなら、GetInvocationList() を自分で回して失敗を集めます。
static void InvokeAll<T>(Action<T>? handlers, T arg)
{
if (handlers is null) return;
List<Exception>? errors = null;
foreach (Action<T> h in handlers.GetInvocationList())
{
try { h(arg); }
catch (Exception e) { (errors ??= []).Add(e); }
}
if (errors is not null) throw new AggregateException(errors);
}
[log] saved a.txt
[cache] invalidate a.txt
1 failure(s): disk full
3つとも実行され、呼び出し側も失敗があったことを知ることができます。GetInvocationList() は呼ぶたびに新しい配列を作り、そのコストは デリゲートとイベントの記事 で計測しました。「ファイル保存済み」程度の通知なら気にする必要はありません。
GetInvocationList で全部の戻り値を受け取る
Func のチェーンは全メソッドを実行しますが、戻り値は最後のものだけです。検証関数ではこれが原因でエラーが黙って消えます。次の検証関数は、エラーメッセージか null を返します。
Func<string, string?> validate = s => s.Length < 8 ? "too short" : null;
validate += s => s.Any(char.IsDigit) ? null : "no digit";
validate += s => s.Contains(' ') ? "contains space" : null;
Console.WriteLine(validate("ab c") ?? "null");
var errors = validate.GetInvocationList()
.Cast<Func<string, string?>>()
.Select(v => v("ab c"))
.OfType<string>()
.ToList();
Console.WriteLine(string.Join(", ", errors));
Console.WriteLine(validate("abcdefgh") ?? "null");
contains space
too short, no digit, contains space
null
危ないのは最後の行です。"abcdefgh" には数字がないので2番目の検証は "no digit" を返しましたが、最後の検証が通ったため、普通の呼び出しの結果は null でした。validate(s) is null で判定するコードは、不正なパスワードを通してしまいます。全部の結果が必要なら上のように GetInvocationList() を回すか、最初から List<Func<string, string?>> に入れておくほうが意図がはっきりします。
コールバックの形の選び方
| 状況 | 選ぶもの |
|---|---|
| 呼び出し側から関数を1つ受け取る(演算、フィルター) | Func<…> / Action<…> 引数 |
| 長い処理の進捗を知らせる | IProgress<T> 引数 |
| コンソールやテストで順番どおり受けたい | 自作の同期 IProgress<T> |
| 呼び出し側が処理を止められるように | 最後の引数に CancellationToken |
| 購読者が複数、発行側は相手を知らなくてよい | event(基本、応用) |
| 1つ失敗しても全部実行したい | GetInvocationList() + try、最後に AggregateException |
| すべての戻り値が要る | GetInvocationList() のループか List<Func<…>> |
| 1秒あたりの通知が多い | 見える値が変わったときだけ通知 |
検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple Silicon、M1 Pro)。
よくある質問
時間のかかるメソッドの進捗コールバックは Action<int> と IProgress<int> のどちらで受けるべき?
IProgress<int> で受けるのがおすすめです。呼び出し側は new Progress<int>(...) を渡しても、自作の小さなクラスを渡しても構いません。ただし Progress<T> は SynchronizationContext を捕まえる仕組みで、コンソールアプリやサーバーにはそれがないため、通知はスレッドプールに回されます。検証では10回の通知が実行のたびに違う順番で表示され、メソッドが終わったあとに最後の通知が届きました。
デリゲートチェーンの途中で例外が出たら、残りのメソッドはどうなる?
例外を投げたところでチェーンが止まり、それ以降のハンドラーは実行されないまま例外が呼び出し元に届きます。すでに実行されたハンドラーが取り消されることもありません。全部実行したいなら GetInvocationList() を回して1件ずつ try/catch し、最後に AggregateException にまとめて投げます。
マルチキャストデリゲートにつないだ全メソッドの戻り値を受け取るには?
普通に呼ぶと最後のメソッドの値しか返りません。GetInvocationList() で1つずつ呼び出します。検証用の関数を3つつないだとき、普通の呼び出しは "contains space" だけを返し、ループは3つのメッセージをすべて返しました。2番目の検証がエラーを見つけていたのに、普通の呼び出しが null を返したケースもありました。