C# のラムダ式は LINQ・イベント処理・非同期コードの至るところで使われます。しかし「x => x * 2 は書けるが、クロージャの落とし穴で痛い目を見た」「Expression<Func<T>> と Func<T> の違いが分からない」「静的ラムダを使うとパフォーマンスが上がると聞いた」という声は多いです。
本記事ではラムダ式の基本から、クロージャの内部構造・式ツリー・静的ラムダ(C# 9+)・自然型(C# 10+)・メソッドグループ変換・ローカル関数との使い分け・パフォーマンスまで体系的に解説します。デリゲート宣言とイベント購読の詳細はデリゲートとイベントの仕組みを参照してください。
ラムダ式の基本構文
ラムダ式は =>(ラムダ演算子)を使って「パラメーター => 処理」の形で書きます。
// 式ラムダ(Expression Lambda): 右辺が単一の式
Func<int, int> square = x => x * x; // 引数1つ: 括弧省略可
Func<int, int, int> add = (x, y) => x + y; // 引数2つ以上: 括弧必須
Func<int> getOne = () => 1; // 引数なし: () 必須
Action<string> print = s => Console.WriteLine(s);
Console.WriteLine(square(5)); // 25
Console.WriteLine(add(3, 4)); // 7
// ステートメントラムダ(Statement Lambda): 右辺が複数文のブロック
Func<int, string> describe = n =>
{
if (n > 0) return "正の数";
if (n < 0) return "負の数";
return "ゼロ";
};
Console.WriteLine(describe(-3)); // "負の数"
// 型を明示する場合(C# 10+: パラメーターに型注釈)
Func<int, int> doubleIt = (int x) => x * 2; // 型を明示
Func<int, int> tripleIt = (int x) => { return x * 3; }; // 明示 + ブロック
単一の式で結果を返せる場合は式ラムダが簡潔です。複数の文や複雑な制御フローが必要な場合はステートメントラムダを使います。ただし処理が複雑になるなら、名前付きのローカル関数やプライベートメソッドの方が可読性が高い場合があります(後述)。
Func / Action / Predicate — 組み込みデリゲート型
C# には汎用デリゲートとして Func・Action・Predicate が標準ライブラリに定義されています。カスタムデリゲートを宣言せずに済みます。
| 型 | シグネチャ | 用途 |
|---|---|---|
Action |
void () |
引数なし・戻り値なしの処理 |
Action<T> |
void (T) |
引数あり・戻り値なしの処理(T は最大16個) |
Func<TResult> |
TResult () |
引数なし・値を返す処理 |
Func<T, TResult> |
TResult (T) |
引数あり・値を返す(最後の型パラメーターが戻り値) |
Predicate<T> |
bool (T) |
条件判定(Func<T, bool> と等価) |
// Action: 副作用だけ行う処理
Action<string> log = msg => Console.WriteLine($"[LOG] {msg}");
Action<string, int> repeat = (s, n) => { for (int i = 0; i < n; i++) Console.Write(s); };
log("起動完了");
repeat("* ", 5); // * * * * *
// Func: 値を返す処理
Func<string, int> length = s => s.Length;
Func<int, int, bool> isLarger = (a, b) => a > b;
Console.WriteLine(length("Hello")); // 5
Console.WriteLine(isLarger(10, 3)); // True
// Predicate<T>: List<T>.Find などで使われる
Predicate<int> isEven = n => n % 2 == 0;
var numbers = new List<int> { 1, 2, 3, 4, 5 };
int firstEven = numbers.Find(isEven); // 2
// Predicate<T> は Func<T, bool> に暗黙変換できない(別の型)
Func<int, bool> funcVersion = isEven.Invoke; // Invoke 経由でラップ
// カスタムデリゲートが必要な場面: ref/out パラメーター
// Func<ref int, void> は書けない → クラスレベルでカスタムデリゲートを宣言して使う
// ↓ クラス/名前空間レベルで宣言(メソッド内では宣言不可)
// delegate void Transformer(ref int value);
//
// 使用例:
// Transformer increment = (ref int x) => x++;
// int val = 10;
// increment(ref val);
// Console.WriteLine(val); // 11
匿名メソッド(delegate { })の唯一の利点
現代の C# では匿名メソッドよりラムダ式が推奨されますが、匿名メソッドにはラムダ式にない1つだけの独自機能があります。それは「パラメーターリストを省略できる」ことです。
// NG: ラムダ式でパラメーターを無視する場合(C# 9 以前)
// シグネチャが一致しないとコンパイルエラー
// EventHandler ev1 = () => Console.WriteLine("clicked"); // 引数が足りない
// OK: 匿名メソッドならパラメーターリストを丸ごと省略できる
// どんなシグネチャのデリゲートにも代入できる
EventHandler ev2 = delegate { Console.WriteLine("clicked"); }; // (sender, e) を無視
// EventHandler<T>、カスタムデリゲートでも同様に使える
button.Click += delegate { DoSomething(); };
timer.Elapsed += delegate { CheckStatus(); };
// C# 9+ のラムダ: _ でパラメーターを捨てられる
EventHandler ev3 = (_, _) => Console.WriteLine("clicked"); // 捨てパラメーター
// C# 9+: _ の改良
button.Click += (_, _) => DoSomething(); // ラムダ式でも対応可
// 現代的な推奨: C# 9 以降は _ 付きラムダが readable かつ推奨
// 匿名メソッドは C# 9+ ではほぼ使う理由がなくなった
| 観点 | 匿名メソッド(delegate { }) | ラムダ式(=>) |
|---|---|---|
| 導入バージョン | C# 2.0 | C# 3.0 |
| パラメーター省略 | できる(全パラメーターを無視) | できない(C# 9+ は _ で捨て可) |
| LINQ 式構文との互換 | なし | あり |
| 式ツリーへの変換 | できない | できる(Expression<Func<T>>) |
| 現代の推奨度 | 低い(新規コードでは不要) | 高い(主流) |
メソッドグループ変換
既存の名前付きメソッドをラムダ式の代わりにそのまま代入できます(メソッドグループ変換)。
// メソッドグループ: ラムダ不要でそのまま代入
Action<string> print1 = Console.WriteLine; // メソッドグループ
Action<string> print2 = s => Console.WriteLine(s); // 等価なラムダ式
Func<string, int> len1 = s => s.Length; // ラムダ式
// Func<string, int> len2 = s.Length は書けない(プロパティ)
// LINQ でのメソッドグループ変換
var words = new[] { "apple", "banana", "cherry" };
var lengths = words.Select(s => s.Length); // ラムダ式
// string.Length はプロパティなのでメソッドグループ変換不可
// メソッドグループの利点: ラムダより簡潔・パフォーマンスが良い場合がある
var lines = new[] { " hello ", " world ", " c# " };
// ラムダ: 毎回クロージャオブジェクトが作られる可能性
var trimmed1 = lines.Select(s => s.Trim());
// メソッドグループ: static メソッドならキャッシュされる(.NET 8+)
// string.Trim はインスタンスメソッドなので等価だが、
// static メソッドはコンパイラが最適化できる
Func<double, double> abs = Math.Abs;
double[] values = { -1.0, 2.5, -3.7 };
var positives = values.Select(Math.Abs); // メソッドグループ(キャッシュ可能)
// イベント登録/解除でも使える
button.Click += HandleClick; // メソッドグループ
button.Click -= HandleClick; // 解除も可(ラムダ式だと解除できない!)
static void HandleClick(object? sender, EventArgs e)
=> Console.WriteLine("クリックされた");
ラムダ式はインスタンスを生成するたびに異なるオブジェクトになるため、
+= で登録したラムダを後から -= で解除することはできません。イベントの解除が必要な場合は、名前付きメソッドのメソッドグループ変換を使ってください。クロージャ — 変数キャプチャの仕組み
ラムダ式は定義されたスコープの変数を「キャプチャ」できます。この機能をクロージャといいます。コンパイラは内部的に「クロージャクラス」を生成して変数を保持します。
// 書いたコード
int multiplier = 3;
Func<int, int> triple = x => x * multiplier; // multiplier をキャプチャ
Console.WriteLine(triple(5)); // 15
multiplier = 10; // キャプチャした変数を変更
Console.WriteLine(triple(5)); // 50 ← 変更が反映される!(参照キャプチャ)
// コンパイラが内部的に生成するコード(擬似コード)
// キャプチャ変数をフィールドに持つクラスが自動生成される
class <>c__DisplayClass
{
public int multiplier; // キャプチャされた変数
}
// ラムダ式本体はこのクラスのインスタンスメソッドになる
// → ヒープアロケーションが発生する(パフォーマンスに影響)
// キャプチャなし(外部変数を参照しない)の場合:
Func<int, int> square = x => x * x;
// → コンパイラがキャッシュできる(アロケーションなし)
クロージャの罠 — ループ変数のキャプチャ
C# の古典的なバグの1つがループ変数のキャプチャです。ラムダ式は変数の参照をキャプチャするため、後から変数が変わると意図しない結果になります。
// NG: for ループの変数キャプチャ(C# 5 以前の foreach でも同様の問題があった)
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i)); // i を参照キャプチャ
}
actions.ForEach(a => a());
// 出力: 3, 3, 3 ← 3つとも同じ i を参照しているため、ループ終了後の 3 を出力
// NG と思いきや: C# 5 以降の foreach は修正済み
var items = new[] { "A", "B", "C" };
var actions2 = new List<Action>();
foreach (var item in items)
{
actions2.Add(() => Console.WriteLine(item)); // C# 5+ では安全
}
actions2.ForEach(a => a());
// 出力: A, B, C ← foreach のイテレーション変数は各回で新しい変数
// for ループは今も要注意(foreach とは異なる)
// 対策1: ローカル変数にコピーしてキャプチャ
var actions3 = new List<Action>();
for (int i = 0; i < 3; i++)
{
int captured = i; // ループ内でコピーを作る
actions3.Add(() => Console.WriteLine(captured));
}
actions3.ForEach(a => a());
// 出力: 0, 1, 2 ← 各ラムダが別の変数をキャプチャ
// 対策2: LINQ で構築(即値評価)
var printActions = Enumerable.Range(0, 3)
.Select(i => (Action)(() => Console.WriteLine(i)))
.ToList();
printActions.ForEach(a => a());
// 出力: 0, 1, 2
① ラムダ式は変数の参照をキャプチャする(コピーではない)。後から変数が変わると影響を受ける。
②
for ループの変数は ループ全体で共有。ループ内でローカルコピーを作ってキャプチャすること。foreach(C# 5+)のイテレーション変数は各回で独立しているため安全。式ツリー(Expression<Func<T>>)
Expression<Func<T>> はラムダ式を実行可能なコードとしてではなく、データ構造(AST)として格納します。Entity Framework などの LINQ プロバイダーはこの式ツリーを SQL に変換します。
using System.Linq.Expressions;
// Func<T>: コンパイル済みの実行可能デリゲート
Func<int, bool> isEven1 = x => x % 2 == 0;
Console.WriteLine(isEven1(4)); // True(即座に実行)
// Expression<Func<T>>: ラムダ式の「構造」を表すデータ
Expression<Func<int, bool>> isEven2 = x => x % 2 == 0;
// → コードは実行されない。式ツリーとして保持される
// 式ツリーの中身を見る
Console.WriteLine(isEven2.Body); // (x % 2) == 0
Console.WriteLine(isEven2.Parameters[0].Name); // x
// .Compile() でデリゲートに変換して実行できる
Func<int, bool> compiled = isEven2.Compile();
Console.WriteLine(compiled(4)); // True
// 式ツリーを構築・変換する例
// x => x.Name.StartsWith("A") を式ツリーとして作成
var param = Expression.Parameter(typeof(string), "x");
var method = typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!;
var call = Expression.Call(param, method, Expression.Constant("A"));
var lambda = Expression.Lambda<Func<string, bool>>(call, param);
Func<string, bool> startsWithA = lambda.Compile();
Console.WriteLine(startsWithA("Alice")); // True
Console.WriteLine(startsWithA("Bob")); // False
// IEnumerable<T>: Func<T> を使い、メモリ上で実行
IEnumerable<string> names = new[] { "Alice", "Bob", "Charlie" };
var result1 = names.Where(n => n.StartsWith("A")); // ラムダ式が直接実行される
// → 全件メモリに読み込んでからフィルタ
// IQueryable<T>: Expression<Func<T>> を使い、SQL に変換
// (EF Core での例 - 実際には DbSet を使う)
IQueryable<string>? queryable = null; // 擬似的な表現
// var result2 = queryable.Where(n => n.StartsWith("A"));
// → WHERE Name LIKE 'A%' という SQL が生成される!
// → DBサーバー側でフィルタリング(高効率)
// 注意: IQueryable に渡すラムダは式ツリーとして解釈される
// メモリ内の関数を渡すと式ツリーに変換できずエラーや非効率になる
Func<string, bool> funcFilter = n => n.StartsWith("A");
// queryable.Where(funcFilter); // 式ツリーに変換できない → IEnumerable に落ちて全件取得
// 解決: 式ツリーとして定義する
Expression<Func<string, bool>> exprFilter = n => n.StartsWith("A");
// queryable.Where(exprFilter); // SQL に変換される
Entity Framework Core に渡すラムダ式は
Expression<Func<T>> として解釈され、SQL に変換されます。Func<T> を渡すと式ツリーに変換できず、すべてのレコードをメモリに読み込んでからフィルタする「Client Evaluation」になります(パフォーマンス劣化)。詳しくはIEnumerableとIQueryableの違いを参照してください。static ラムダ(C# 9+)— キャプチャを禁止してパフォーマンス向上
static を付けたラムダ式は外部変数のキャプチャが禁止されます。クロージャオブジェクトのヒープアロケーションがなくなり、コンパイラによるキャッシュが保証されます。
// 通常のラムダ: キャプチャなしでも毎回デリゲートオブジェクトが作られる可能性 Func<int, int> normal = x => x * 2; // static ラムダ(C# 9+): キャプチャ不可 → アロケーションなし(コンパイラがキャッシュ) Func<int, int> staticLambda = static x => x * 2; // static ラムダ内で外部変数をキャプチャしようとするとコンパイルエラー int factor = 3; // Func<int, int> bad = static x => x * factor; // CS8820: static ラムダではキャプチャ不可 // 定数は参照できる(値が固定のため問題なし) const int MULTIPLIER = 3; Func<int, int> good = static x => x * MULTIPLIER; // OK: 定数は参照可 // LINQ でのパフォーマンス最適化 var numbers = Enumerable.Range(1, 1_000_000); // 通常ラムダ: 毎回の呼び出しでデリゲートインスタンスが異なる可能性 var sum1 = numbers.Where(x => x % 2 == 0).Sum(); // static ラムダ: キャッシュされてアロケーション削減 var sum2 = numbers.Where(static x => x % 2 == 0).Sum(); // static キーワードはコンパイラへの意図の表明でもある: // 「このラムダは外部状態に依存しない」 Console.WriteLine(sum1 == sum2); // True
ラムダの自然型(C# 10+)
C# 10 以降、ラムダ式には「自然型」が推論されます。Func/Action を明示しなくても var で受け取れます。
// C# 9 以前: var で受け取れない(型が決まらない)
// var f = x => x + 1; // CS0815: ラムダ式を暗黙的型指定変数に代入できない
// C# 10+: var が使える(自然型推論)
var square = (int x) => x * x; // Func<int, int> と推論
var printMsg = (string s) => Console.WriteLine(s); // Action<string> と推論
Console.WriteLine(square(7)); // 49
// 戻り値の型注釈(C# 10+)
var abs = int (int x) => x < 0 ? -x : x; // 戻り値型を明示
Console.WriteLine(abs(-5)); // 5
// ラムダに属性を付ける(C# 10+)
var divide = [return: MaybeNull]
(int a, int b) => b == 0 ? (int?)null : a / b;
// メソッドグループの自然型(C# 10+)
var trim = string.IsNullOrWhiteSpace; // Func<string?, bool> と推論
Console.WriteLine(trim(" ")); // True
// 注意: 自然型はオーバーロードが1つの場合のみ推論できる
// var parse = int.Parse; // CS8917: オーバーロードが複数あるので推論不可
Func<string, int> parse = int.Parse; // 型を明示すれば解決
ローカル関数 vs ラムダ式 — 使い分け
| 観点 | ローカル関数 | ラムダ式 |
|---|---|---|
| 再帰 | ○(自分自身を呼べる) | ✗(変数への再代入が必要で非推奨) |
| イテレーター(yield) | ○(yield return 使用可) |
✗ |
async/await |
○(async 修飾子使用可) |
○(async ラムダも可) |
| デバッグ | ○(スタックトレースに名前が出る) | △(匿名のため分かりにくい) |
| 変数への代入 | 不要(定義と呼び出しが分離) | 必要(変数に代入してから使う) |
| パフォーマンス | ○(キャプチャなしでヒープ不要) | △(キャプチャありでヒープ確保) |
| 型シグネチャ変換 | ✗(デリゲートへの変換に一手必要) | ○(直接 Func/Action に代入可) |
| 適した場面 | 内部ヘルパー・検証ガード・再帰 | LINQ・コールバック・高階関数への渡し方 |
// 再帰: ローカル関数は自分自身を直接呼べる
static int Fibonacci(int n)
{
// ローカル関数で再帰(スタックトレースに "Fib" が出る)
int Fib(int x) => x <= 1 ? x : Fib(x - 1) + Fib(x - 2);
return Fib(n);
}
// イテレーター: ローカル関数内で yield return
static IEnumerable<int> GetEvenNumbers(int max)
{
// 引数検証を先に行い、実際の列挙はローカル関数で
if (max < 0) throw new ArgumentOutOfRangeException(nameof(max));
return Impl(max); // イテレーターの遅延評価を保つ
static IEnumerable<int> Impl(int limit)
{
for (int i = 0; i <= limit; i += 2)
yield return i;
}
}
// デバッグ: ローカル関数は名前がスタックトレースに表示される
static async Task ProcessAsync()
{
await Inner(); // スタックトレースに "Inner" と出る
async Task Inner()
{
await Task.Delay(100);
throw new InvalidOperationException("エラー");
}
}
パフォーマンス — ヒープアロケーションとキャッシュ
// ケース1: キャプチャなし(静的メソッドとして実装可能)
// → コンパイラがデリゲートをキャッシュ(同じインスタンスを再利用)
Func<int, int> f1 = x => x * 2; // ヒープアロケーション: 初回1回のみ(キャッシュ)
// ケース2: インスタンスメンバーをキャプチャ(this キャプチャ)
// → デリゲートは毎回または1回(this が固定なのでキャッシュ可能な場合も)
class MyClass
{
private int _value = 10;
Func<int, int> f2 => x => x + _value; // プロパティにすると毎回新インスタンス
Func<int, int> _f2cached;
Func<int, int> F2 => _f2cached ??= x => x + _value; // キャッシュ
}
// ケース3: ローカル変数をキャプチャ
// → 毎回ヒープアロケーション(クロージャクラスのインスタンス生成)
int multiplier = 5;
Func<int, int> f3 = x => x * multiplier; // ヒープアロケーション: 毎回
// ケース4: static ラムダ(C# 9+)
Func<int, int> f4 = static x => x * 2; // キャプチャ禁止 → コンパイラがキャッシュ保証
// パフォーマンスが重要な場面のガイドライン:
// 1. 高頻度で呼ばれる LINQ/コールバックでは static ラムダを使う
// 2. キャプチャが必要なら変数をフィールドにしてラムダをキャッシュする
// 3. ローカル変数のキャプチャはホットパスで避ける
実践例 — 高階関数とパイプライン
// 変換処理をラムダで組み合わせる(関数型スタイル)
static Func<T, TResult> Compose<T, TMiddle, TResult>(
Func<T, TMiddle> first,
Func<TMiddle, TResult> second)
=> x => second(first(x));
Func<string, string> trim = static s => s.Trim();
Func<string, string> toUpper = static s => s.ToUpper();
Func<string, bool> isNotEmpty = static s => s.Length > 0;
var normalize = Compose(trim, toUpper);
var inputs = new[] { " hello ", " world ", " ", " c# " };
var results = inputs
.Where(isNotEmpty) // 先に空文字を除外(static ラムダ)
.Select(normalize) // トリム + 大文字変換
.Where(static s => s.Length > 0) // 空文字(スペースのみ)を除外
.ToArray();
Console.WriteLine(string.Join(", ", results)); // HELLO, WORLD, C#
// ラムダを返す: ファクトリーパターン
static Func<int, bool> CreateRangeChecker(int min, int max)
=> x => x >= min && x <= max; // min, max をクロージャでキャプチャ
var isScore = CreateRangeChecker(0, 100);
var isInniable = CreateRangeChecker(18, 65);
Console.WriteLine(isScore(85)); // True
Console.WriteLine(isInniable(17)); // False
よくある質問
delegate { } でパラメーターリストを省略したい」という場面だけでしたが、C# 9+ では (_, _) => という捨てパラメーターで同様のことができます。新規コードでは匿名メソッドを使う必要はほぼありません。button.Click += () => Use(largeData); で largeData をキャプチャし、button.Click -= で解除しないと largeData がリークします。大きなデータをキャプチャするラムダはイベントに長期登録しないか、名前付きメソッドで登録・解除してください。Expression<Func<T>> はどんな場面で必要ですか?IQueryable<T> の Where などに渡すラムダは Expression<Func<T, bool>> として解釈され、SQL に変換されます。Func<T, bool> を渡すと全件をメモリに読み込んでからフィルタするため非効率です。また、バリデーションフレームワークやルールエンジンで式ツリーを動的構築する際にも使います。static ラムダにすることでコンパイラがキャッシュし、ローカル関数と同等のパフォーマンスになります。まとめ
| 機能・概念 | ポイント |
|---|---|
| 式ラムダ / ステートメントラムダ | 単一式は式ラムダ。複数文はブロック付きステートメントラムダ |
Func/Action/Predicate |
用途で使い分け。カスタムデリゲートは ref/out が必要なときのみ |
| 匿名メソッドの利点 | パラメーター省略 delegate { } のみ。C# 9+ では (_, _) => で代替可 |
| メソッドグループ変換 | 名前付きメソッドを直接代入。イベント解除が必要な場合に必須 |
| クロージャ | 変数の参照キャプチャ。for ループ変数はローカルコピーを作る |
| 式ツリー | Expression<Func<T>>は SQL 変換・ルールエンジンに使う。Func<T> とは別物 |
static ラムダ(C# 9+) |
キャプチャ禁止。コンパイラがキャッシュ保証。ホットパスで有効 |
| 自然型(C# 10+) | var f = (int x) => x * 2; が可能。型注釈・属性も付けられる |
| ローカル関数 vs ラムダ | 再帰・yield・デバッグ容易性ならローカル関数。LINQ・コールバックならラムダ |
| パフォーマンス | キャプチャなし or static: キャッシュ可。ローカル変数キャプチャ: 毎回アロケーション |
デリゲートの宣言とイベント購読・マルチキャストの詳細はデリゲートとイベントの仕組みを、LINQ での活用はLINQ完全ガイドを参照してください。