C#のラムダ式:.NET 10で動かし直した入門と落とし穴

C#ラムダ式の基本(式形式・ステートメント形式)から、C# 10の自然な型、C# 12の既定パラメーター、staticラムダ、ループでのキャプチャの罠まで。コンパイルエラーとキャプチャのコストを実測。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語で書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、最初の例はデリゲート名が重複していてコンパイルできず(error CS0102)、ステートメント形式のラムダの断片はセミコロンが抜けていました(error CS1002)。元のタイトルの「Lamda」も「Lambda」の誤字でした。修正したコードに加え、2021年以降にC#へ入ったラムダの機能と、キャプチャのコストの実測を足しています。

C#のラムダ式を学び始めた人や、古い書き方しか知らず var f = (a, b) => a + b; がなぜエラーになるのか分からない人向けの記事です。先に結論を書くと、ラムダ式は「名前のないメソッドを式として書いたもの」で、コンパイラーは代入先のデリゲート型から引数の型を推論します。その型が分からなければ型を明示する必要があり、外側の変数をキャプチャするとヒープ割り当てが起きます。実務でつまずくのはほぼこの2点です。

ラムダ式は匿名メソッドを短くしたもの

メソッドは引数・本体・戻り値でできています。ラムダ式はこの3つだけを残して、メソッドを計算式のように書く記法です。delegate キーワードを使う匿名メソッドと比べるとかなり短くなります(図は元記事のもので、コメントは「匿名メソッド」「ラムダ式」「同じラムダ式」の意味です)。

引数 a、b の型まで省略できるのは、左辺のデリゲート型 MyDelegate のシグネチャからコンパイラーが型を推論するからです。次の4行は .NET 10 ですべてコンパイルでき、同じ結果になります。

delegate int MyDelegate(int a, int b);

MyDelegate A = delegate (int a, int b) { return a + b; };  // 匿名メソッド
MyDelegate B = (int a, int b) => a + b;                    // 型を書いたラムダ
MyDelegate C = (a, b) => a + b;                            // 型を省略したラムダ
MyDelegate D = delegate { return 42; };                    // 匿名メソッドは引数リスト自体を省略できる

Console.WriteLine($"{A(1,2)} {B(1,2)} {C(1,2)} {D(1,2)}");
3 3 3 42

最後の delegate { … } のように引数リストごと省略できるのは匿名メソッドだけです。引数を使わないイベントハンドラーで今も見かけます。ラムダでは代わりに (_, _) => 0 のように破棄(discard)を使います(C# 9)。

2021年の例を .NET 10 でコンパイルすると

元記事の最初の例は、そのままではコンパイルできませんでした。

class MainApp {
    delegate int MyDelegate(int a, int b);
    delegate void MyDelegate();          // ← 同じ名前
    static void Main(string[] args) {
        MyDelegate add = (a, b) => a + b;
        MyDelegate2 lamda = () => Console.WriteLine("ラムダ式");
        ...
Program.cs(3,19): error CS0102: 型 'MainApp' は既に 'MyDelegate' の定義を含んでいます

本体では MyDelegate2 を使っているので、宣言側の名前の誤りです。C#ではデリゲート型を引数の数でオーバーロードできません(ジェネリック型パラメーターの数が違えば可能で、Func<T> と Func<T1,TResult> が共存できるのはそのためです)。また元のコードには using System; がありません。.NET 6 以降の新規プロジェクトは ImplicitUsings が有効なので通りますが、無効にすると次のエラーになります。

error CS0103: The name 'Console' does not exist in the current context

修正版と実行結果です。

using System;

class MainApp
{
    delegate int MyDelegate(int a, int b);
    delegate void MyDelegate2();

    static void Main(string[] args)
    {
        MyDelegate add = (a, b) => a + b;
        MyDelegate2 lambda = () => Console.WriteLine("ラムダ式");
        Console.WriteLine("11 + 22 = {0}", add(11, 22));
        lambda();
    }
}
11 + 22 = 33
ラムダ式

ステートメント形式のラムダ

式1つで足りないときは、本体を波かっこで囲んだステートメント形式のラムダを使います。元記事の断片は閉じる } の後にセミコロンがありませんでした。ラムダ全体が変数宣言の一部なので、}; で終える必要があります。

Program.cs(12,2): error CS1002: ; が必要です

3つの分岐をすべて確認できるよう呼び出しを増やした版です。

delegate void MyDelegate(int a, int b);

MyDelegate Compare = (a, b) =>
{
    if (a > b)
        Console.WriteLine("{0} のほうが大きい", a);
    else if (a < b)
        Console.WriteLine("{0} のほうが大きい", b);
    else
        Console.WriteLine("{0} と {1} は等しい", a, b);
};   // ← 2021年の断片に欠けていたセミコロン

Compare(11, 22);
Compare(22, 11);
Compare(7, 7);
22 のほうが大きい
22 のほうが大きい
7 と 7 は等しい

C# 10 以降:var で受けるラムダ(自然な型)

2021年当時(C# 9)は、ラムダを必ずデリゲート型の変数に代入する必要がありました。C# 10 からラムダに自然な型(natural type)ができ、var で受けられます。コンパイラーが選ぶ型を GetType() で表示してみました。

var add   = (int a, int b) => a + b;
var hello = () => Console.WriteLine("hi");
var pick  = object (bool b) => b ? 1 : "one";   // 戻り値の型を明示(C# 10)

Console.WriteLine(add.GetType());
Console.WriteLine(hello.GetType());
Console.WriteLine(pick.GetType());
System.Func`3[System.Int32,System.Int32,System.Int32]
System.Action
System.Func`2[System.Boolean,System.Object]

基本は Func/Action が選ばれます。ただし引数の型を省略すると推論の手がかりがなく、失敗します。int.Parse のようにオーバーロードが複数あるメソッドグループを var で受けた場合も同じです。

var f = (a, b) => a + b;     // 型なし
var parse = int.Parse;       // オーバーロードが複数
error CS8917: デリゲート型を推論できませんでした。

pick のように分岐ごとに戻り値の型が違う(int と string)ときは、ラムダの前に戻り値の型を書きます。

C# 12:ラムダの既定パラメーターと params

C# 12 からラムダの引数に既定値と params を付けられます。このとき自然な型は Func ではなく、コンパイラーが生成する匿名デリゲート型になります。Func には既定値の情報を持たせられないからです。

var inc = (int x, int step = 1) => x + step;
Console.WriteLine(inc(10) + " " + inc(10, 5));
Console.WriteLine(inc.GetType());

var sum = (params int[] xs) => xs.Sum();
Console.WriteLine(sum(1, 2, 3));
11 15
<>f__AnonymousDelegate0`3[System.Int32,System.Int32,System.Int32]
6

落とし穴は、このラムダを Func に代入したときです。警告付きでコンパイルは通りますが、既定値は消えて h(1) のように1引数では呼べなくなります。

Func<int, int, int> h = (int x, int step = 1) => x + step;
warning CS9099: パラメーター 2 のラムダでの既定値は '1' だが、ターゲットデリゲート型では '<missing>' です。

out 引数を持つラムダも var で受けられ、やはり Func ではない匿名デリゲート型になります。Func/Action が ref/out に対応していないためで、詳しくは Func・Actionデリゲート の記事で扱っています。

static ラムダとキャプチャのコスト(実測)

ラムダが外側のローカル変数を使うと、コンパイラーはその変数を保持するクロージャオブジェクトを生成し、ラムダをそのオブジェクトのメソッドに変換します。ラムダの前に static(C# 9)を付けると、キャプチャをコンパイル時に禁止できます。

int offset = 10;
Func<int, int> g = static x => x + offset;
error CS8820: 静的な匿名関数に 'offset' への参照を含めることはできません。

キャプチャが実際にどれだけ高いのかを測りました。Func<int,int> を受け取るメソッドを1000万回呼び、(1) キャプチャしないラムダをループ内で渡す、(2) ループ内のローカル変数をキャプチャするラムダを渡す、(3) キャプチャするラムダをループの外で1回だけ作る、の3通りを比べています。

[MethodImpl(MethodImplOptions.NoInlining)]
static int Apply(Func<int, int> f, int x) => f(x);

// (1) キャプチャなし
for (int i = 0; i < n; i++) s += Apply(x => x * 2, i);
// (2) ループ内でキャプチャ
for (int i = 0; i < n; i++) { int k = i & 7; s += Apply(x => x * k, i); }
// (3) キャプチャするが外で1回だけ生成
int k = 3; Func<int, int> f = x => x * k;
for (int i = 0; i < n; i++) s += Apply(f, i);
// 割り当て量:GC.GetAllocatedBytesForCurrentThread() の前後差

.NET 10.0.14、Release ビルド、Apple M1 Pro、1000万回、3回実行した結果です。

ケース 1回目 2回目 3回目 割り当て
(1) キャプチャなし 19.1 ms 19.4 ms 19.3 ms 0 B
(2) ループ内でキャプチャ 136.5 ms 137.8 ms 133.8 ms 839.2 MB(88 B/回)
(3) キャプチャ、外で1回生成 19.3 ms 19.0 ms 19.1 ms 0 B

キャプチャしないラムダはコンパイラーがデリゲートを静的フィールドにキャッシュするため、ループ内に書いても割り当ては0でした。一方 (2) は反復ごとにクロージャとデリゲートを新しく作り、1回88バイト、時間は約7倍です。1000万回で0.1秒強なので普通のコードでは問題になりませんが、ゲームループや毎秒数万件をさばくサーバーの経路なら、(3) のようにラムダをループの外に出すか、static を付けてキャプチャを防ぐのが安全です。LINQ の Where(x => x > limit) も limit をキャプチャするので、同じことが言えます。

ループ内キャプチャの罠

ラムダは変数の値ではなく変数そのものをキャプチャします。そのため for 文で作ったラムダを後で実行すると、期待と違う値が出ます。

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
    actions.Add(() => Console.Write(i + " "));
foreach (var a in actions) a();          // for

actions.Clear();
foreach (var i in new[] { 0, 1, 2 })
    actions.Add(() => Console.Write(i + " "));
foreach (var a in actions) a();          // foreach

actions.Clear();
for (int i = 0; i < 3; i++)
{
    int copy = i;                        // 反復ごとに新しい変数
    actions.Add(() => Console.Write(copy + " "));
}
foreach (var a in actions) a();          // for + コピー
3 3 3 <- for
0 1 2 <- foreach
0 1 2 <- for + copy

for の i はループ全体で1つなので、実行時点の値3が3回出ます。foreach の反復変数は C# 5 から反復ごとに新しく作られるため 0 1 2 です。同じ理由で、キャプチャした変数をラムダ内で変えると外側にも反映されます。

int counter = 0;
Action incr = () => counter++;
incr(); incr();
Console.WriteLine($"counter = {counter}");   // counter = 2

非同期コールバックや Task.Run の中でループ変数を使うときに、この罠によく出会います。

どれをいつ使うか

状況 おすすめ
1行の計算、LINQ の条件 式形式のラムダ x => x * 2
複数行の処理、分岐 ステートメント形式 x => { …; }(最後の ; を忘れない)
ローカル変数に入れて再利用 var f = (int x) => …(C# 10 以降、型は必須)
分岐ごとに戻り値の型が違う object (bool b) => … と戻り値の型を明示
省略可能な引数を持つコールバック ラムダの既定パラメーター(C# 12)。Func に代入すると消える
ホットなループで呼ぶ キャプチャを避ける、static ラムダ、またはループ外で生成
引数をまったく使わない (_, _) => … または匿名メソッド delegate { … }

ラムダを入れるデリゲート型には、ほとんどの場合 Func/Action を使います。続けて Func・Actionデリゲート を、ラムダが一番活躍する LINQ は LINQの基本 を参照してください。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro)。

よくある質問

ラムダ式の引数の型はいつ省略できる?

代入先のデリゲート型が決まっているときです。MyDelegate add = (a, b) => a + b; は通りますが、var f = (a, b) => a + b; は error CS8917(デリゲート型を推論できませんでした)になります。var で受けるなら (int a, int b) => a + b のように型を書きます。

for文の中で作ったラムダがすべて同じ値を出すのはなぜ?

ラムダは変数の値ではなく変数そのものをキャプチャするからです。for (int i …) の i はループ全体で1つなので、実行時点の値3が3回出ます。foreach は C# 5 から反復ごとに新しい変数なので 0 1 2 になります。for では int copy = i; とコピーしてからキャプチャします。

ラムダ式はメモリを確保する?

外側の変数をキャプチャしないラムダはデリゲートがキャッシュされ、1000万回呼んでも割り当ては0でした。ループ内のローカル変数をキャプチャするラムダを毎回作ると1回88バイト(クロージャ+デリゲート)、1000万回で839MB、時間は約7倍(19ms→136ms)でした(.NET 10、Release、Apple M1 Pro)。

  • #C#
  • #ラムダ式
  • #.NET
  • #クロージャ

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑