連載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(式ツリー)かどうかを確認 |
関連リンク
チートシート(受け取る側→形)
注:
-
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 の Click は EventHandler。
つまり (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. IEnumerable と IQueryable(同じ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 + async(async void 化)
起きる現象
Action は戻り値が void。async を付けると 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();
見方は次の順番で安定する。
-
Where/Select/OrderByは「何を要求するか」が決まっている - 右側の
(o) => ...は「要求された形」へ当てはまるだけ - 型が見えない場合は、拡張メソッド定義へ戻る(
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問セルフチェック
「読める」から「判定できる」へ寄せるための最小セット。
-
Where((x) => x > 0)のxの型は何で決まるか -
Func<T1, T2, TResult>の戻り値型はどれか -
Expression<Func<T, bool>>が出てきた時、何が起きている可能性が高いか -
forの中で作ったラムダを後で実行すると値が揃うのはなぜか -
Actionにasyncを付けると何が困るか(2点でよい)
回答
- 受け取る側(
Whereが要求するFunc<TSource,bool>)で決まる。TSourceがxの型。 -
TResult(最後の型引数)。 - 実行ではなく式の構造を渡している可能性が高い(
IQueryable等で解析・変換される)。 - 値ではなく変数そのものを捕捉するため。ループの変数は同一なので、実行時点の最終値が見える。
-
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 注釈は、プロジェクト設定とコンパイラに依存する - ラムダの読み方(型は受け取る側で決まる)は変わらない