C# のコールバック・デリゲート・イベントの違いを業務SEが30分で腹落ちさせる

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#のコールバックとデリゲートの違いはなんなのか!で先に概念だけ確認できる。今回はそこにeventFunc<T,bool>の話まで足した整理版だ。

3行で結論:

  • コールバックは概念(処理を後で呼んでもらう仕組み)。言語機能じゃない
  • デリゲートはメソッドを変数として渡せる「型」。delegateActionFuncの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以降はActionFuncを使うのが基本。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;

使い分けは単純で:

  • 戻り値ないActionAction<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を渡してるだけ」と気づくと、WhereSelectOrderByの挙動が一気に予測可能になる。覚える価値がある観点だ。

イベントは「デリゲートを多重キャスト&カプセル化したもの」

ここまではコールバックを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宣言/ActionFuncの3形態。基本はActionFunc、名前を付けたい時だけ自前delegate
  • イベント =デリゲートを多重キャスト&カプセル化したもの。eventキーワードで+=-=しか外から触らせない
  • LINQのラムダも全部Func<T,TResult>を渡してるだけ。魔法じゃない
  • 判断軸:通知先が1つならActionFuncを直接渡す、複数になりそうなら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. ActionFunc、どっちを使うべきか迷った時は?

A. 戻り値が要るかどうかで機械的に決まる。

  • 「ログ書く」「メール送る」みたいに副作用だけならAction
  • 「true/false返す」「変換した値を返す」みたいに何か計算結果が要るならFunc

迷うとしたら「Actionにして外側で結果を変数に書き戻すvs Funcで戻り値返す」だけど、後者の方がテストしやすいし、副作用が減るので可能ならFunc寄せが基本。

Q3. eventinternalで発火させたい時はどうする?

A.パターンは2つある。

  1. 専用の発火メソッドをprotected or internalで生やす:

    public event EventHandler<DataChangedArgs> DataChanged;
    protected virtual void OnDataChanged(DataChangedArgs e)=> DataChanged?.Invoke(this, e);
    

    外部からは購読のみ、自分(とサブクラス)はOnDataChangedで発火する。

  2. テスト時は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を使った通知パターンを実装で見たい人は、関連記事側に進むと自然な流れだ。

関連記事

以上!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次