C# のコールバック・デリゲート・イベントの違いを業務SEが30分で腹落ちさせる
みなさんこんにちは!ヒロポンです!
C# 触ってると、Button.Click += handler みたいな書き方は毎日使うのに、いざ「コールバックとデリゲートとイベントって何が違うの?」と聞かれると言葉に詰まる、ってこと、ないっすか?
俺も最初の正社員時代、event キーワードを自前で使う場面がしばらくなくて、Click や FormClosing は書けるけど その仕組み自体は説明できない という状態が結構長かった。LINQ で Where(x => x.Active) のラムダ式を Func<T,bool> に渡してるのも、なんとなく動くから書いてたけど中身は曖昧だった。
この記事では C# 7.3・.NET Framework 4.7.2 の現場で実際に書いてるコードを使って、コールバック → デリゲート → イベント の順で頭の中の整理を一気にやる。30分くらいで「あ、3つの関係性こうなってたのか」って腹落ちするはず。
💡 基本だけサクッと押さえたい人 は別記事 C#のコールバックとデリゲートの違いはなんなのか! で先に概念だけ確認できる。本記事はそこに
eventとFunc<T,bool>の話まで足した整理版 だ。
3行で結論:
- コールバック は概念(処理を後で呼んでもらう仕組み)。言語機能じゃない
- デリゲート はメソッドを変数として渡せる「型」。
delegate/Action/Funcの3形態がある- イベント はデリゲートを 多重登録&カプセル化 した仕組み(
eventキーワード++=/-=)
コールバックは「概念」、C# 以前から存在する
最初に分けておきたいのは、コールバックは 言語機能じゃない ということ。
「コールバック」って言葉が指してるのは「処理を相手に渡しておいて、しかるべきタイミングで呼んでもらう」という設計上の振る舞いのことで、C と JavaScript と C# と Python で全部同じ意味で通じる。各言語が用意してる「コールバックを書くための道具」が違うだけだ。
C# でいうと、コールバックを書く道具が デリゲート(と、その派生の event / Action / Func / ラムダ式)だ。
// 抽象的なコールバックの構造
// 「呼ぶ側」が「呼ばれる側」に処理を渡しておく
void DoSomething(コールバック型 callback)
{
// ... 何か仕事をする ...
callback(); // ← 渡されたやつを後で呼ぶ
}
ここで コールバック型 の場所に Action とか Func を入れるのが C# 流の書き方になる。コールバックは概念、デリゲートは C# でそれを表現する 型 だ、と覚えておくと混乱しない。
デリゲートは「メソッドを変数として渡せる型」
デリゲートは メソッドの参照を変数として持てる 仕組みだ。C# だと書き方が3形態あって、それぞれ用途が違う。
① 自前 delegate 宣言
一番伝統的な書き方。delegate キーワードで型を宣言してから使う。
// メソッドのシグネチャを型として宣言
public delegate bool ValidationFunc(string input);
public class Validator
{
public bool Run(string text, ValidationFunc check)
{
return check(text); // ← 渡されたメソッドを呼ぶ
}
}
// 使う側
var v = new Validator();
bool ok = v.Run("hello", IsNotEmpty);
bool IsNotEmpty(string s) => !string.IsNullOrEmpty(s);
業務系のコードベースで public delegate ... が出てくるのは、独自の意味を持つコールバック型に名前を付けたいケース。「これは検証用」「これは保存後フック用」みたいに型名で意図を伝えられる。
ただし欠点として、似たシグネチャのデリゲート型をコードベースのあちこちで宣言すると 同じ型なのに名前違いで微妙に互換性ない という混乱が起きる。bool (string) のデリゲートを2回宣言すると別物として扱われる。
② Action / Func(標準ジェネリックデリゲート)
C# 3.5 以降は Action と Func を使うのが基本。delegate 宣言を書かずにそのままシグネチャをジェネリック型で表現できる。
// Action: 戻り値なし
Action<int> log = n => Console.WriteLine($"value={n}");
log(42);
// Func: 戻り値あり(最後の型引数が戻り値)
Func<string, bool> isEmpty = s => string.IsNullOrEmpty(s);
bool result = isEmpty("hello");
// 引数を取らないなら
Action greet = () => Console.WriteLine("hi");
Func<int> giveAnswer = () => 42;
使い分けは単純で:
- 戻り値ない →
Action/Action<T>/Action<T1,T2>… - 戻り値ある →
Func<TResult>/Func<T,TResult>/Func<T1,T2,TResult>…
業務コードで自前 delegate を新規宣言する場面はほぼ無くなった。まず Action / Func を使って、それで意図が伝わらなくなった時だけ delegate で名前を付ける、くらいの判断で十分だ。
ただし欠点として、Func<int, int, bool> みたいに引数が増えると 何が何の引数か型シグネチャから読み取れなくなる。引数3つを超えたら自前 delegate で名前付けるか、専用クラス+メソッドにするほうが読める。
③ ラムダ式・匿名メソッド
その場で書き捨てるコールバック。LINQ や WinForms のイベントハンドラで一番よく見る。
// 1行ラムダ
Func<int, int> square = x => x * x;
// 複数行ラムダ
Action<List<string>> print = list =>
{
foreach (var s in list)
Console.WriteLine(s);
};
// LINQ 文脈での Func<T, bool>
var actives = users.Where(u => u.IsActive).ToList();
// ↑ これが Func<User, bool> として渡されている
Where に渡してる u => u.IsActive の正体が Func<User, bool> だと気づくと、LINQ がただの「メソッドにラムダを渡す呼び出し」だと見えてくる。
「LINQ は魔法じゃなくて Func を渡してるだけ」と気づくと、Where/Select/OrderBy の挙動が一気に予測可能になる。覚える価値がある観点だ。
イベントは「デリゲートを多重キャスト&カプセル化したもの」
ここまではコールバックを 1対1 で渡す話だった。イベント(event キーワード)は同じデリゲートを 複数の購読者 に配る仕組みになる。
+= / -= の正体
Button.Click += handler のあれは、Click というデリゲート型のフィールドに対して「購読者を追加」している だけだ。
// イベントを生やすクラス側
public class FileWatcher
{
public event Action<string> FileChanged; // ← デリゲート型 + event キーワード
public void NotifyChange(string path)
{
FileChanged?.Invoke(path); // 登録されてる全 handler を順に呼ぶ
}
}
// 購読する側
var watcher = new FileWatcher();
watcher.FileChanged += path => Console.WriteLine($"changed: {path}");
watcher.FileChanged += path => SendMail(path);
watcher.FileChanged += MyLogger.Log; // メソッド参照でも OK
// どこかで通知すると、登録された3つの handler が全部呼ばれる
watcher.NotifyChange("/tmp/a.txt");
ポイントは2つ:
+=で何個でも登録できる — 1つのイベントに対して複数の購読者がぶら下がれる(多重キャスト)eventキーワードがあると、外部からは+=/-=しか触れない — 直接代入やInvokeの呼び出しは外からはできない(カプセル化)
event キーワードを外して public Action<string> FileChanged; と書いても 動きはする。でも外部から watcher.FileChanged = null; で全 handler を吹っ飛ばせちゃうし、外部から勝手に Invoke できる。event を付けるのは「他のクラスに購読だけ許す、発火制御は自分が握る」という意思表示だ。
ただし欠点として、event 付きフィールドは クラス外からテスト用に直接 Invoke を叩けない。テスト時は専用の RaiseFileChanged() メソッドを internal で生やすか、InternalsVisibleTo でフレンドアセンブリ指定するなどの追加の手間が要る。
EventHandler 系の慣習
WinForms / ASP.NET Web Forms 系の標準イベントは Action<T> じゃなく EventHandler / EventHandler<T> を使う。
// 標準形
public event EventHandler<MyEventArgs> ItemSelected;
// 発火
ItemSelected?.Invoke(this, new MyEventArgs(selectedId));
// 購読側
form.ItemSelected += (sender, e) => Console.WriteLine(e.Id);
シグネチャは (object sender, TEventArgs e) で固定。これは .NET 設計ガイドラインの慣習で、Button.Click とか FormClosing と同じ形にすると 既存の Visual Studio デザイナと相性が良い からだ。新規にイベント定義する時は EventHandler<T> から検討する方が無難。
LINQ の Func<T,bool> も同じ仕組み
ここまで読んだ後で LINQ のコードを見ると、見え方が変わるはず。
var actives = users
.Where(u => u.IsActive) // Func<User, bool>
.Select(u => new { u.Id, u.Name }) // Func<User, anonymous>
.OrderBy(u => u.Name) // Func<anonymous, string>
.ToList();
Where の中で Func<User, bool> を呼んで true/false を判定して、Select の中で Func<User, T> を呼んで投影して… という、ただの デリゲート呼び出しのチェーン だ。
業務系の C# でラムダを書いてる時、頭の中で「これは Func を渡してるだけ」と意識すると、デバッガで止めた時の挙動も読みやすくなる。LINQ の遅延評価で Where の中身が後から呼ばれるのも、要は「デリゲート参照を覚えておいて、ToList() でやっと呼ばれる」だけの話だ。
ハマりポイント3つ — 俺が踏んだやつ
① イベント解除忘れでメモリリーク
+= で登録したハンドラは、クラスを破棄しない限り -= で外さないと残り続ける。寿命の長いオブジェクトが、寿命の短いオブジェクトのハンドラを購読 してると、短い側が GC されない。
// ❌ NG: ChildForm を閉じた後も購読が残って ChildForm が GC されない
appWideManager.DataUpdated += childForm.OnDataUpdated;
// ✅ OK: ChildForm が閉じる時に解除する
childForm.FormClosed += (_, __) =>
{
appWideManager.DataUpdated -= childForm.OnDataUpdated;
};
これ、流通系の基幹システムで実際に踏んだ。長時間動いてる業務アプリで「夕方になるとメモリ使用量がじわじわ積み上がってる」みたいな症状が出て、原因はアプリ常駐のマネージャクラスから子フォームのハンドラが解放されてなかった、というやつ。寿命の長い側が短い側を見てたら、短い側のクローズ時に対で解除する、というのを後輩に教える時の最初のチェック項目にしてた。
② ラムダで this を意図せずキャプチャ
ラムダ式はスコープ内の変数を 捕まえる ので、this も自動的にキャプチャされる。これがイベントの解除を難しくする。
// ❌ NG: ラムダで登録すると、解除しようとしても同じ参照が作れない
manager.DataUpdated += data => this.Process(data);
// ↓ これだと別のラムダ → 解除できない
manager.DataUpdated -= data => this.Process(data);
// ✅ OK: メソッド参照で登録すれば同じ参照で解除できる
manager.DataUpdated += this.Process;
manager.DataUpdated -= this.Process;
// ✅ OK: ラムダを変数に保存しておいて同じ参照を使う
Action<DataPayload> handler = data => this.Process(data);
manager.DataUpdated += handler;
// あとで
manager.DataUpdated -= handler;
「ラムダ式は同じコードを書いても別物の参照になる」というのが直感に反するので最初は混乱する。解除する予定があるイベントは、ラムダじゃなくメソッド参照 or 保存ラムダで登録する、と決めておくと事故が減る。
③ -= が効いてないのに気付かない
-= は対象の handler が登録されてなくても 例外を出さない。なので「外したつもりが外せてない」という失敗が静かに起こる。
manager.DataUpdated -= someHandler;
// ↑ someHandler が登録されてなくても何も言わない
// 多重登録されてた場合も「1個だけ」外す挙動なので、登録回数と解除回数を揃える必要がある
+= を if (!subscribed) { ... += ...; subscribed = true; } でガードする、もしくは購読を コンストラクタで1回だけ 行ってクラス破棄時に解除、みたいな構造にしておくと事故が起きにくい。
業務系のコードで += ... -= ... を画面のロード/クローズで呼ぶ書き方をしてると、画面を素早く閉じ→開きを繰り返した時にハンドラが多重登録される、みたいな目に見えにくいバグが入り込む。
著者の現場メモ — 流通系SIer時代の event 整理
最初の正社員時代、流通系SIer の受託で C# WinForms を2年触ってた。VS2019・.NET Framework 4.7.2・C# 7.3 の構成で、業務ロジックは DataAdapter + DataTable + 生SQL という、客先常駐で C# を回してる現場の人なら見覚えあるスタックだ。
入った最初の頃、Button.Click += ... と LINQ ラムダは書けるのに、自前で event を立てる必要が出た時に止まった、というのが俺の最初のつまずきポイントだった。「Click はどこの誰が event 宣言してるんだろう」が分からず、Visual Studio の F12(定義へ移動)で Button クラスの中の public event EventHandler Click; を見て初めて「ああ、これと同じ宣言を自分でやればいいのか」と腹落ちした。
そこから、業務系で「マスタ更新したら関連する複数画面に通知したい」みたいな要件が出た時に event で発火元1つ・購読側複数の構造 を組むのを後輩と一緒にやった。逆に「マスタ参照ダイアログから親に値を1個返すだけ」なら event を使うほどでもなく、サブフォーム側に public プロパティを生やすだけで済む。通知先が1つなら Action、複数になりそうなら event を判断軸にするのを規約にした。
業務SE が C# のイベントを触る時、文法より「いつどっちを使うか」の判断軸を1回決めておくと長く効く。デリゲート3形態とイベントの構造を1回頭の中で整理しておくのは、現場で迷う時間が確実に減る投資だ。
まとめ
ここまでで C# のコールバック・デリゲート・イベントの関係はだいたい押さえたと思う。要点をもう一度:
- コールバック = 概念。「処理を渡して後で呼んでもらう」設計上の振る舞い
- デリゲート = C# の言語機能。
delegate宣言/Action/Funcの3形態。基本はAction/Func、名前を付けたい時だけ自前delegate - イベント = デリゲートを多重キャスト&カプセル化したもの。
eventキーワードで+=/-=しか外から触らせない - LINQ のラムダも全部
Func<T,TResult>を渡してるだけ。魔法じゃない - 判断軸: 通知先が1つなら
Action/Funcを直接渡す、複数になりそうならeventで多重キャスト
C# 7.3・.NET Framework 4.7.2 の業務系コードでも、最新の .NET 8 でも、この3つの関係性は変わらない。C# を触り続ける限り何度も使う知識 なので、1回整理しておく価値が高い。
よくある質問
Q1. delegate を自前で宣言する場面って今でもある?
A. 引数3つを超える複雑なシグネチャに意味のある名前を付けたい時に使う。例えば「保存後フック」用に public delegate Task PostSaveHook(EntityId id, ChangeSet changes, IUserContext user); のように宣言すると、Func<EntityId, ChangeSet, IUserContext, Task> よりコードベース全体で意図が読みやすい。
逆にシンプルな (string) => bool 程度なら Func<string, bool> で済ませる方が今は普通。
Q2. Action と Func、どっちを使うべきか迷った時は?
A. 戻り値が要るかどうか で機械的に決まる。
- 「ログ書く」「メール送る」みたいに副作用だけなら
Action - 「true/false 返す」「変換した値を返す」みたいに 何か計算結果が要る なら
Func
迷うとしたら「Action にして外側で結果を変数に書き戻す vs Func で戻り値返す」だけど、後者の方がテストしやすいし、副作用が減るので可能なら Func 寄せが基本。
Q3. event を internal で発火させたい時はどうする?
A. パターンは2つある。
-
専用の発火メソッドを
protectedorinternalで生やす:public event EventHandler<DataChangedArgs> DataChanged; protected virtual void OnDataChanged(DataChangedArgs e) => DataChanged?.Invoke(this, e);外部からは購読のみ、自分(とサブクラス)は
OnDataChangedで発火する。 -
テスト時は
InternalsVisibleToで発火メソッドへのアクセスを開放 する。
WinForms / WPF の標準コントロールも全部 OnXxx パターンを採用してるので、独自イベントを定義する時もこの形にしておくと一貫性がある。
Q4. LINQ の Where(x => ...) のラムダはいつ実行されるの?
A. ToList()/First()/foreach の時点で初めて実行される(遅延評価)。Where を呼んだ瞬間にはラムダの中身は走らず、デリゲートとして覚えられてるだけ。
var query = users.Where(u => Heavy(u)); // ここではまだ Heavy は呼ばれない
var list = query.ToList(); // ここで初めて Heavy が users 全件分走る
これを知ってると「LINQ をデバッガで止めても中身が動かない理由」「同じ Where の query を2回 ToList() すると2回走る理由」が分かる。
ここまでで Action / Func / event の3軸はだいたい揃った。次は フォーム間で event を使った通知パターン を実装で見たい人は、関連記事側に進むと自然な流れだ。
関連記事
- C#のコールバックとデリゲートの違いはなんなのか! — 概念だけ最短で再確認したい時に効く(本記事の前段にあたる基本編)
以上!


コメント