2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

K26:【鍛錬】いまさら聞けないラムダ式入門 ── delegate / Action / Func、ループとイベントで踏むクロージャ事故

Last updated at Posted at 2026-01-14

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

コードを読んでいて x => ... が出てくると、意味が取れずに手が止まることがある。
止まる理由の多くは単純で、ラムダ単体だと「何を受けて何を返すか(型)」が省略されるため。

このページで押さえることは2つだけ。

  • x => ...受け取る側の型 で形が決まる
  • 落とし穴は ループ変数 / イベント購読 / async void 化 の3つ

※ 後半は落とし穴まで触れるが、まずは x => ... の読み方だけ拾えば十分。
string? のような ? は nullable 参照型注釈(C# 8+ / Nullable 有効化時)。


まず逆引き(症状→原因→対策)

症状 だいたいの原因 対策(第一手)
x => ... が読めず止まる 受け取る側の型が見えていない 代入先 / 渡し先の型を見る
引数の型が見えない 推論元が見えていない (string s) => ... のように型を明示
戻り値が見えない Action/Func の規則が曖昧 Func は最後が戻り値、Action は戻り値なし
右側が重くて追えない 1ブロックに論点が多い 一時変数へ逃がす / ローカル関数へ戻す
イベント解除できない その場のラムダで購読した 参照を保持して -= する / メソッド名へ戻す
Expression が出てきて急に難しく見える 実行ではなく構造を渡している IQueryable(式ツリー)かどうかを確認

関連リンク

  • S00 シリーズ総合Index(読む順・公開済リンクが最新)
  • R04 例外設計(握り潰し禁止 / throw;)
  • R05 ログ設計(証拠を残す / 構造化ログ)

チートシート(受け取る側→形)

注:

  • TSource は列(シーケンス)の要素型
  • TResult は戻り値の型
受け取る側(要求される形) ラムダの形 よく見る例
Func<T, TResult> (T x) => TResult .Select((x) => x.Length)
Func<T, bool> (T x) => bool .Where((x) => x.IsActive)
Action<T> (T x) => void .ForEach((x) => Console.WriteLine(x))
EventHandler (object? sender, EventArgs e) => void button.Click += (sender, e) => { ... };
Expression<Func<T, bool>> (T x) => bool の“構造” q.Where((x) => x > 0)(IQueryable)

1. ラムダ式は2種類だけ(式ラムダ / 文ラムダ)

ラムダ式は 引数 => 本体 の形で「その場で小さい処理(関数)を作って渡す」記法。

  • 式ラムダ: => の右が1つの式(値を返す)
  • 文ラムダ: => { ... } のブロック(複数行、戻り値があるなら return

※ 引数は1つでも (x) と書ける。括弧付きで統一すると視認性が上がりやすい。

// 式ラムダ: よく見るのは LINQ の中
var names = new[] { "A", "", "BB" };

var lengths = names
    .Where((text) => text != "")
    .Select((text) => text.Length)
    .ToArray();
// 文ラムダ: ブロックで書ける(戻り値があるなら return)
// 現場でよく見るのは 2引数(比較・ソート・集計など)
var values = new List<int> { -3, 2, -1, 10 };

values.Sort((x, y) =>
{
    return Math.Abs(x).CompareTo(Math.Abs(y));
});

2. x => ... の解読は「受け取る側」を見る

止まる理由はだいたい「型が省略されている」だけ。
ラムダの引数型 / 戻り値型は、ラムダを受け取る側(代入先、メソッド引数、LINQ、イベント)で決まる。

2-1. 代入先で読む(型が見えれば止まらない)

現場で多いのは「代入して保持」より、「その場で渡す」形。
ただし、名前を付けて再利用したい場面では代入が出てくる。

Func<string?, bool> isEmpty = (text) => string.IsNullOrEmpty(text);

var input = "";
if (isEmpty(input))
{
    Console.WriteLine("empty");
}

型を書きたくないなら、ローカル関数へ戻す手もある。

static bool IsEmpty(string? text) => string.IsNullOrEmpty(text);

2-2. メソッド引数で読む(渡し先が答え)

static void Apply(Func<int, int> transform)
{
    Console.WriteLine(transform(10));
}

Apply((x) => x * 2); // transform が Func<int,int> なので、(x) は int で戻り値も int

2-3. LINQで読む(Where/Selectが要求する形)

LINQは「要求する形」が決まっている。
右側は「そこに当てはまる形」として読む。

var filtered = names
    .Where((text) => !string.IsNullOrEmpty(text)) // Func<string, bool>
    .Select((text) => text.Length)                // Func<string, int>
    .ToArray();

2-4. イベントで読む(イベント型が答え)

WinForms の ClickEventHandler
つまり (object? sender, EventArgs e) => void の形になる。

button.Click += (sender, e) =>
{
    Console.WriteLine("clicked");
};

2-5. それでも読みづらい時の手当て(その場で解像度を上げる)

状態 手当て
引数型が見えない 引数型を明示 (string text) => ...
省略が多くて追えない 一時変数へ逃がす Func<string, bool> p = (s) => ...;
引数名が意味不明 役割に寄せて改名 (order) => ... / (line) => ...
ラムダが長くなった メソッド名へ戻す Where(IsTarget) / button.Click += OnClick;

3. delegate / Action / Func は「シグネチャ」で読む

現場で多く見るのは「Func/Action を書いて代入」より、
LINQ / イベント / メソッド引数の中に出てくるラムダ。

それでも迷ったら、受け取る側の型へ戻すだけで読める。

3-1. Func / Action の最小ルール

  • Func最後の型引数が戻り値(それ以外は引数)
  • Action戻り値なし(引数だけ)
Func<int, int, int> add = (a, b) => a + b;
Action<string> log = (message) => Console.WriteLine(message);

3-2. <> の規則(型引数の読み方)

  • T, T1, T2: 任意の型
  • TSource: 列(シーケンス)の要素型を表すことが多い
  • TResult: 戻り値の型
  • TKey / TValue: 辞書やキー・値の型を表すことが多い

3-3. Predicate<T> / Comparison<T>(現場APIとの接点)

Action/Func だけ覚えるより、現場でよく当たる型と繋げる方が理解が安定する。

var list = new List<int> { 3, 1, 2 };

// Predicate<T>: bool を返す
int found = list.Find((x) => x >= 2);

// Comparison<T>: int を返す(-1/0/1 の順序)
list.Sort((a, b) => a.CompareTo(b));

見た目はラムダでも、受け取る側が要求するシグネチャへ当てはまっているだけ。


4. Expression(式ツリー)は「実行」ではなく「構造」

Expression<Func<...>> は、処理を実行するためではなく「式の構造」を渡すために使う。
典型は IQueryable 系で、式ツリーを解析して SQL などへ変換する。

using System.Linq.Expressions;

Expression<Func<int, bool>> expr = (x) => x > 10;

Console.WriteLine(expr.Body); // x > 10

Func<int, bool> f = expr.Compile();
Console.WriteLine(f(11)); // True

4-1. IEnumerableIQueryable(同じWhereでも受け取りが違う)

同じ Where に見えても、対象が違うと「受け取り型」が変わる。

  • IEnumerable<T>: Func<T,bool>(実行するための関数)
  • IQueryable<T>: Expression<Func<T,bool>>(解析するための構造)
IEnumerable<int> xs = new[] { 1, 2, 3 };
var a = xs.Where((x) => x > 1); // Func<int,bool>

IQueryable<int> q = new[] { 1, 2, 3 }.AsQueryable();
var b = q.Where((x) => x > 1);  // Expression<Func<int,bool>>

「どこで実行されるか」が変わる場面(DB・外部クエリなど)では、式ツリーの意味が一気に重要になる。


5. クロージャ(キャプチャ)落とし穴3つ

ラムダの中で外側の変数を参照すると、コンパイラは値を保持する仕組みを作る。
これがキャプチャ(closure)。

踏みやすいのは次の3つ。

  • ループ変数: 実行時点の値になり、想定とズレる
  • イベント購読: 解除できず参照が残り続ける
  • Action + async: async void になり、待てず・拾えず・合成できない

5-1. ループ変数(同じ値になる)

起きる現象
ループ内で作ったラムダを「あとで実行」すると、全部が「最後の値」を見る形になりやすい。
原因は「値をコピー」ではなく「変数そのもの」を捕捉するため。

再現(現象が出る形)

var actions = new List<Action>();

for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

actions.ForEach((a) => a());

// 期待: 0,1,2
// 実際: 3,3,3

原因(何を捕まえているか)
i はループ全体で同じ変数。ラムダは i の「値」ではなく i そのものを参照する。
実行するタイミングでは i は最後まで進んでいるため、同じ値が出る。

回避(最小で効く策)
ループ内でローカルへ退避して、その退避変数を捕捉する。

var actions = new List<Action>();

for (int i = 0; i < 3; i++)
{
    var index = i; // 退避
    actions.Add(() => Console.WriteLine(index));
}

actions.ForEach((a) => a());
// 0,1,2

非同期が混ざると表面化しやすい形

var tasks = new List<Task>();

for (int i = 0; i < 3; i++)
{
    tasks.Add(Task.Run(() => Console.WriteLine(i)));
}

await Task.WhenAll(tasks);

補足: foreach の挙動は言語世代差が混ざって語りづらい。
実務は「ループ変数はローカルへ退避」で統一すると再発が止まる。

5-2. イベント購読(解除できる形にする)

起きる現象
ラムダで += したイベントは、見た目が同じラムダを書いても -= で外れない。
解除できず、長寿命の発行元から参照され続ける形になりやすい。

再現(外れない形)

button.Click += (sender, e) => Console.WriteLine("tick");

// 見た目が同じでも別インスタンス。
// 追加したものと一致しないため外れない。
button.Click -= (sender, e) => Console.WriteLine("tick");

回避A(参照を保持して外す:最小で堅い)

EventHandler? onClick;

public void Wire(Button button)
{
    onClick = (sender, e) => Console.WriteLine("tick");
    button.Click += onClick;
}

public void Unwire(Button button)
{
    if (onClick is null) return;
    button.Click -= onClick;
    onClick = null;
}

回避B(メソッド名へ戻す:読みやすさが強い)

button.Click += OnClick;
button.Click -= OnClick;

static void OnClick(object? sender, EventArgs e)
{
    Console.WriteLine("tick");
}

設計の要点:

  • +=-= を同じ責務(同じクラス)に置く
  • 長寿命(発行元)→短寿命(購読側)の参照は、解除漏れの影響が大きい
補足:`event` の `+=` / `-=` はどう動いているか

デリゲート変数は = で置き換え、+= で結合(呼び出しリストへ追加)になる。

Action a = () => Console.WriteLine("A");
a(); // A

a = () => Console.WriteLine("B");
a(); // B(置き換え)

a += () => Console.WriteLine("C");
a(); // B -> C(結合)

イベントは外部から = で置き換えできない(追加/削除だけ公開される)。
内部的には「呼び出しリストへ追加/削除」という意味になるため、-= は“同一インスタンス”にしか効かない。

5-3. Action + asyncasync void 化)

起きる現象
Action は戻り値が voidasync を付けると async void になる。
この形は「待てない」「合成できない」「例外が拾いづらい」が同時に起きる。

再現(呼び出し側が“終わった”と思う)

Action run = async () =>
{
    await Task.Delay(100);
    Console.WriteLine("done");
};

run();
Console.WriteLine("caller finished");

再現(例外が呼び出し元で扱えない)

try
{
    Action bad = async () =>
    {
        await Task.Delay(10);
        throw new InvalidOperationException("boom");
    };

    bad();
}
catch
{
    // ここで捕まらない(bad の中で“後から”投げられる)
}

困りどころ(現場で出る形)

  • UIイベントの内部で async void が混ざると、例外が呼び出し元へ戻らない
  • ログが薄いと「何が起きたか」が残らず、追跡が難しくなる

回避(Taskを返す形へ寄せる:合成できる形)

Func<Task> good = async () =>
{
    await Task.Delay(100);
};

await good();

複数をまとめて扱える(待てる/例外が集約される):

var jobs = new List<Func<Task>>
{
    async () => await Task.Delay(10),
    async () => await Task.Delay(20),
};

await Task.WhenAll(jobs.Select((j) => j()));

補足: UIイベントはシグネチャ都合で async void になりやすい。
その場合は「例外を流さない」形へ寄せる(try/catch+ログ、共通ラッパーなど)。

5-4. 補足:キャプチャを避ける書き方(static ラムダ / ローカル関数)

キャプチャが原因で詰む形は、「外側を捕まえない」方向へ寄せると減る。
目的は「外側を参照できない形を、コンパイラで強制する」こと。

// C# 9+: static ラムダ(外側変数を参照できない)
Func<int, int> twice = static (x) => x * 2;

// ローカル関数へ戻す(名前で意図が残る)
static bool IsValid(int x) => x > 0;

6. ラムダとチェーンは別物

混ざって見えるが、役割が違う。

  • チェーン: メソッド呼び出しを連ねた構造(Where(...).Select(...).ToList()
  • ラムダ: その引数として渡す関数値(... の部分)

典型の形:

var result = orders
    .Where((o) => o.IsActive)                 // Func<Order, bool>
    .Select((o) => new { o.Id, o.Total })     // Func<Order, anon>
    .OrderBy((x) => x.Total)                  // Func<anon, key>
    .ToList();

見方は次の順番で安定する。

  1. Where/Select/OrderBy は「何を要求するか」が決まっている
  2. 右側の (o) => ... は「要求された形」へ当てはまるだけ
  3. 型が見えない場合は、拡張メソッド定義へ戻る(Func<TSource,bool> など)

クエリ構文に戻しても同じ。

var result =
    (from o in orders
     where o.IsActive
     select new { o.Id, o.Total })
    .OrderBy((x) => x.Total)
    .ToList();

7. 5問セルフチェック

「読める」から「判定できる」へ寄せるための最小セット。

  1. Where((x) => x > 0)x の型は何で決まるか
  2. Func<T1, T2, TResult> の戻り値型はどれか
  3. Expression<Func<T, bool>> が出てきた時、何が起きている可能性が高いか
  4. for の中で作ったラムダを後で実行すると値が揃うのはなぜか
  5. Actionasync を付けると何が困るか(2点でよい)
回答
  1. 受け取る側(Where が要求する Func<TSource,bool>)で決まる。TSourcex の型。
  2. TResult(最後の型引数)。
  3. 実行ではなく式の構造を渡している可能性が高い(IQueryable 等で解析・変換される)。
  4. 値ではなく変数そのものを捕捉するため。ループの変数は同一なので、実行時点の最終値が見える。
  5. async void になり、(a) 呼び出し側で待てない、(b) 例外が呼び出し側で扱いづらい(合成もしづらい)。

逆引き表とチートシートは、実装とレビューの即席メモとして使える形にしてある。
このページで「x => ... を型で読める」「イベント解除を設計できる」「async void を避けて合成できる」まで持っていく。

.NET Framework 4.8 補足(差分が出やすい点だけ)

.NET Framework 4.8 でもラムダ式 / Action / Func / Expression の基本は同じ。
差分が出やすいのは「nullable参照型」の有無と、利用するAPI群(.NET 8 側で追加された便利APIなど)。

  • string? のような nullable 注釈は、プロジェクト設定とコンパイラに依存する
  • ラムダの読み方(型は受け取る側で決まる)は変わらない
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?