C# LINQ クエリ構文とメソッド構文:コンパイラの変換・let・SelectMany

from・where・orderby・select がどのメソッド呼び出しになるかを実際に出力して確認。let の隠れたアロケーション、複数 from、ソートの安定性、.NET 9/10 の新メソッドも実測。

2026年11月改訂: 2021年に韓国語で書いた LINQ 講座のコードを .NET 10.0.14(C# 14)で再コンパイルしました。最初のクエリは 範囲変数名が women と woman で食い違っていてコンパイルできず(error CS0103)、LINQ を使わない比較コードも new List<Woman>; とラムダ内のセミコロンで失敗しました(CS1526、CS1026)。さらに本文は「20歳以上」と書きながら条件が age > 20 で、20歳が漏れていました。修正版に加えて、今回はクエリ構文が 実際にどんなメソッド呼び出しに変わるか を出力して確かめ、let・複数 from・ソートの安定性・.NET 9/10 の新メソッドを実測付きで整理しました。

既存のコードを読んでいると、from m in members where … select … と members.Where(…).Select(…) が同じファイルに混在していることがよくあります。この記事は「この2つは本当に同じなのか、どちらをいつ使うべきか」を知りたい人向けです。結論から言うと、クエリ構文は コンパイラがメソッド呼び出しに書き換える構文 で、性能差はありません。差が出るのは、let が作る隠れたオブジェクト、カンマ1つで意味が変わる orderby、そしてクエリ構文では書けない演算子です。LINQ が初めてなら LINQ の基礎、グループ化と結合は LINQ の group by と join を参照してください。

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

元の最初のクエリはこうでした。

var woman = from women in womanList   // 範囲変数は women
            where woman.age > 20      // 以降はすべて woman
            orderby woman.age
            select woman;

存在しない名前を参照しているので、3行とも失敗します。

Program.cs(23,15): error CS0103: 現在のコンテキストに 'woman' という名前は存在しません
Program.cs(24,17): error CS0103: 現在のコンテキストに 'woman' という名前は存在しません
Program.cs(25,16): error CS0103: 現在のコンテキストに 'woman' という名前は存在しません

「LINQ を使わない場合」の比較コードにも2か所の誤りがありました。

Program.cs(22,45): error CS1526: new 式は、型の後に引数リストか、丸かっこ ()、角かっこ []、または波かっこ {} を必要とします
Program.cs(23,48): error CS1026: ) が必要です
Program.cs(23,49): error CS1513: } が必要です

new List<Woman>() と丸かっこを付け、(w1, w2) => w1.age - w2.age; の中のセミコロンを外し、add は Add にします。最後の完成プログラムはそのままコンパイルでき、5件中4件を表示しました。20歳のデータが出ないのは > 20 が20を含まないためで、「20歳以上」なら >= 20 が正解です。

2021年の説明も2点直します。

  • 「from の範囲変数にはデータが入らない」は半分だけ正しい説明でした。範囲変数はコンパイル後 ラムダの引数 になり、クエリを列挙したときに要素が1つずつ渡されます。定義した時点では何も実行されない遅延実行のため、そう見えただけです。
  • 「select で配列データとして取り出される」も誤りです。結果は配列ではなく遅延評価の IEnumerable<T>(orderby があれば IOrderedEnumerable<T>)で、ToArray() を呼んで初めて配列になります。また .NET 6 以降のテンプレートは ImplicitUsings が有効なので、using System.Linq; は書かなくても動きます。

ここからのサンプルデータは、性別や年齢層を扱う元の例をやめて、チームメンバーの一覧にします。

record Member(string Name, int Age, string Team);

Member[] members =
[
    new("Kim", 34, "app"), new("Lee", 37, "web"), new("Park", 20, "app"),
    new("Choi", 24, "web"), new("Jung", 47, "app"), new("Kang", 24, "app"),
];

クエリ構文がメソッド呼び出しになる様子を出力する

C# の仕様は、クエリ式を 構文上の変換 として定義しています。コンパイラは where を見たら Where という名前のメソッドを探すだけで、それが System.Linq.Enumerable のものかどうかは問いません。適切なメソッドを持つ型なら何でもクエリの対象になります。

これを利用すると変換結果を観察できます。ラムダを Func<…> ではなく Expression<Func<…>> で受け取るラッパー Q<T> を書きました。式ツリーは文字列にできるので、コンパイラが作ったラムダをそのまま表示できます(一部省略)。

using System.Linq.Expressions;

sealed class Q<T>(IEnumerable<T> src)
{
    public IEnumerable<T> Items => src;
    public Q<T> Where(Expression<Func<T, bool>> p)
    { Console.WriteLine($"  .Where({p})"); return new(src.Where(p.Compile())); }
    public Q<R> Select<R>(Expression<Func<T, R>> f)
    { Console.WriteLine($"  .Select({f})"); return new(src.Select(f.Compile())); }
    // SelectMany、OrderBy、OrderByDescending、GroupBy、ThenBy … も同様
}

6つのクエリを流した実際の出力です。

var q = new Q<Member>(members);
var r1 = from m in q where m.Age >= 20 select m;
var r2 = from m in q where m.Age >= 20 orderby m.Age, m.Name descending select m.Name;
var r3 = from m in q let decade = m.Age / 10 * 10 where decade == 20 select $"{m.Name}({decade}s)";
var r4 = from m in q select m;
var r5 = from o in orders from item in o.Items select $"{o.Customer}:{item}";
var r6 = from m in q group m.Name by m.Team;
1) where + select (identity select)
  .Where(m => (m.Age >= 20))
2) where + orderby + select projection
  .Where(m => (m.Age >= 20))
  .OrderBy(m => m.Age)
  .ThenByDescending(m => m.Name)
  .Select(m => m.Name)
   => Park, Kang, Choi, Kim, Lee, Jung
3) let
  .Select(m => new <>f__AnonymousType0`2(m = m, decade = ((m.Age / 10) * 10)))
  .Where(<>h__TransparentIdentifier0 => (<>h__TransparentIdentifier0.decade == 20))
  .Select(<>h__TransparentIdentifier0 => Format("{0}({1}s)", <>h__TransparentIdentifier0.m.Name, Convert(<>h__TransparentIdentifier0.decade, Object)))
   => Park(20s), Choi(20s), Kang(20s)
4) degenerate query
  .Select(m => m)
5) two from clauses
  .SelectMany(o => o.Items, (o, item) => Format("{0}:{1}", o.Customer, item))
   => Kim:pen, Kim:ink, Lee:pad
6) group by
  .GroupBy(m => m.Team, m => m.Name)
   => app: Kim,Park,Jung,Kang
   => web: Lee,Choi

出力から読み取れる規則:

  • 1:where の後の select m は何もしないので、コンパイラは Select 呼び出しを 省略 しました。
  • 4:逆に from m in q select m だけだと Select(m => m) が残ります。元のコレクションそのものを返さないための規則で、ReferenceEquals(クエリ, 配列) は False でした。
  • 2:orderby m.Age, m.Name descending のカンマの後ろは ThenByDescending になります。
  • 3:let は範囲変数 m と decade を入れる 匿名型オブジェクトを要素ごとに 作り、以降の句はコンパイラが命名した「透過識別子」経由で値を読みます。
  • 5:2つ目の from は SelectMany になり、select は結果セレクターのラムダに畳み込まれます。
  • 6:group A by B は GroupBy(キー, 要素) になり、select は不要です。

Format(…) や Convert(…, Object) が見えるのは、式ツリーでは文字列補間が string.Format で表現されるためです。通常のデリゲート(Func)としてコンパイルされる場合は補間文字列ハンドラーが使われ、ボックス化は起きません。

クエリ句とメソッド呼び出しを対応させた2列の図。where は Where に。orderby a, b descending は OrderBy と ThenByDescending に。let は範囲変数と新しい値を要素ごとに匿名オブジェクトへ包む Select に。2つ目の from は結果セレクター付きの SelectMany に。where の後の select m は丸ごと省略される。
.NET 10 で式ツリーのラッパーに出力させた、各クエリ句の変換結果。要素ごとにオブジェクトを増やすのは let だけ。

let と複数 from:便利だがタダではない

let は、一度計算した値を複数の句で使い回すためのクエリ構文専用の機能です。メソッド構文に対応するキーワードはなく、Select(m => new { m, decade = … }) を自分で書くことになります。上の出力の通り、これは 要素ごとにオブジェクト1つ を意味します。コストを数字にするため、100万件(年齢10〜69のランダム)から20代を数えました。環境は .NET 10.0.14、Release ビルド、Apple M1 Pro。20回の反復を1サンプルとし、7サンプルの中央値です。

(from m in src where m.Age / 10 * 10 == 20 select m.Name).Count();             // クエリ
src.Where(m => m.Age / 10 * 10 == 20).Select(m => m.Name).Count();             // メソッド
(from m in src let d = m.Age / 10 * 10 where d == 20 select m.Name).Count();   // let
src.Select(m => (m, d: m.Age / 10 * 10)).Where(t => t.d == 20).Select(t => t.m.Name).Count(); // 値タプル
書き方 中央値 最小〜最大 1回の確保量
クエリ構文(let なし) 3.29ms 3.18〜4.98 106B
同じメソッドチェーン 3.33ms 3.18〜4.56 106B
クエリ構文 + let 16.54ms 12.72〜45.45 32,000,170B(30.5MB)
メソッド + 値タプル 5.62ms 5.44〜6.97 186B
foreach 2.75ms 2.66〜3.80 2B
棒グラフ。let なしのクエリ構文 3.29ms、同じメソッドチェーン 3.33ms、let ありのクエリ構文 16.54ms、値タプルのメソッドチェーン 5.62ms、foreach 2.75ms。クエリ構文とメソッド構文は同じで、let は約5倍遅い。
100万件から条件に合う件数を数えた時間。7サンプル(各20回)の中央値。.NET 10.0.14、Release ビルド、Apple Silicon Mac(M1 Pro)。let 版は1回あたり 30.5MB を確保、ほかは200バイト未満。

クエリ構文とメソッド構文は誤差の範囲で一致しました。同じ IL になるので当然です。一方 let はきっちり 要素あたり32バイト(小さなオブジェクト1つ)を確保し、GC の負担でばらつきも最大でした(最悪 45ms)。値タプルに替えると確保がなくなり 5.6ms まで下がりました。普段のコードで let を禁止する理由はありませんが、毎秒何千回も回る箇所では式を where に直接書くほうが安全です。

複数の from は、注文ごとの明細をすべて1列に並べるような一対多の平坦化に使います。メソッド構文では orders.SelectMany(o => o.Items, (o, item) => …) ですが、結果に外側の o と内側の item の両方が必要になった時点でクエリ構文のほうが読みやすくなり、3つ目の from が加わるとメソッド構文は匿名型を手で組む必要が出てきます。ただし、2つのコレクションをキーで突き合わせるなら from を入れ子にして where で絞るのは避けてください。O(N×M) になります。join はハッシュ表を使うので桁違いに速く、実測は LINQ の group by と join にあります。

orderby と ThenBy:安定ソートとカンマ1つの差

Enumerable.OrderBy は 安定ソート です。キーが同じ要素は元の順序を保ちます。Choi と Kang はどちらも24歳で、元の配列では Choi が先なので結果でも Choi が先です。

from m in members orderby m.Age select m.Name
from m in members orderby m.Team, m.Age select $"{m.Team}/{m.Name}"
from m in members orderby m.Team orderby m.Age select $"{m.Team}/{m.Name}"
orderby Age          : Park, Choi, Kang, Kim, Lee, Jung
orderby Team, Age    : app/Park, app/Kang, app/Kim, app/Jung, web/Choi, web/Lee
orderby Team orderby Age: app/Park, app/Kang, web/Choi, app/Kim, web/Lee, app/Jung

カンマでつないだ2行目は OrderBy(Team).ThenBy(Age) で、チームごとにまとまってから年齢順です。3行目のように orderby を2回書くと OrderBy(Team).OrderBy(Age) になり、年齢で最初から並べ直します。警告も出ません。チームの順序が残っているのは同じ年齢の Kang と Choi の間だけで、それも安定ソートのおかげです。2021年の記事のように ascending を明記するかは好みの問題ですが、キーが2つ以上あるときはカンマの位置を先に確認してください。

クエリ構文で書けないもの、.NET 9/10 の追加メソッド

クエリ構文の語彙は from、where、select、orderby、let、join、group、into 程度です。Count、Take、Distinct、First、ToList にはキーワードがないので、クエリをかっこで囲んでメソッドを続けます。C# 13 と 14 でもクエリ構文自体は変わっておらず、新機能はすべてライブラリ側から来ました。以下はすべてメソッド構文専用です。

foreach (var kv in members.CountBy(m => m.Team)) Console.Write($"{kv.Key}={kv.Value} ");
foreach (var kv in members.AggregateBy(m => m.Team, 0, (max, m) => Math.Max(max, m.Age))) Console.Write($"{kv.Key}={kv.Value} ");
foreach (var (i, m) in members.Where(m => m.Team == "web").Index()) Console.Write($"{i}:{m.Name} ");
Console.WriteLine(string.Join(",", Enumerable.Sequence(0, 20, 5)));
Console.WriteLine(string.Join(",", Enumerable.InfiniteSequence(1, 2).Take(4)));
Console.WriteLine(string.Join(",", Enumerable.Range(1, 5).Shuffle()));
app=4 web=2
app=47 web=37
0:Lee 1:Choi
0,5,10,15,20
1,3,5,7
2,1,4,5,3

どのバージョンで入ったかは、同じソースを net8.0;net9.0 でビルドして確かめました。net8.0 では CountBy、AggregateBy、Index が error CS1061 で失敗し net9.0 では通ったので .NET 9 の追加 です。Sequence・InfiniteSequence(error CS0117)と Shuffle(CS1061)は net9.0 でも失敗したので .NET 10 の追加 です。Shuffle の結果は実行のたびに変わります。これに LINQ の基礎 で扱った .NET 10 の LeftJoin/RightJoin が加わります。

Index() は、Select((m, i) => …) で番号を振っていた書き方を分解可能なタプルに置き換えます。CountBy と AggregateBy は group … by の後に Count() していたコードをグループオブジェクトなしで処理するため、確保量が大きく減ります。

どちらの構文を使うか

状況 おすすめ
フィルタ・並べ替え・射影が1回ずつ どちらでもよい。チームの規約に従う
中間値を複数の句で使い回す クエリ構文の let(ホットパスなら値タプル)
一対多の平坦化、from が2つ以上 クエリ構文
join や group … into の継続 クエリ構文
Count、Take、Distinct、First、CountBy、Index、Shuffle メソッド構文(クエリをかっこで囲んで続ける)
IEqualityComparer を渡す GroupBy・Distinct メソッド構文
並べ替えキーが2つ以上 orderby a, b(カンマ)。orderby を2回書かない

クエリ構文は性能のためではなく 読みやすさのための道具 です。コンパイラが出すメソッド呼び出しを一度見ておけば、let がオブジェクトを作ること、2つ目の orderby が前の並べ替えを上書きすること、末尾の select m が消えることがあることも自然に理解できます。

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro)。バージョン比較は同じソースを net8.0・net9.0・net10.0 でビルドして確認。

よくある質問

クエリ構文とメソッド構文はどちらが速いですか?

同じです。クエリ構文はコンパイラが Where・OrderBy・Select などのメソッド呼び出しに書き換えてからコンパイルするため、生成コードが一致します。100万件のフィルタでクエリ構文 3.29ms、同じメソッドチェーン 3.33ms と誤差の範囲でした(.NET 10、Release、Apple M1 Pro)。

let を使うと性能に影響しますか?

します。let は要素ごとに元の範囲変数と let の値を入れる匿名型オブジェクトを1つ作ります。100万件で let 版は 30.5MB(要素あたり32バイト)を確保し、中央値 16.5ms と let なし(3.3ms)の約5倍でした。ホットパスでは式を where に直接書くか値タプルにしてください。

orderby を2回書くと2つのキーで並びますか?

並びません。orderby a orderby b は OrderBy(a).OrderBy(b) になり、最後のキー b で並べ直します。OrderBy は安定ソートなので b が同じ要素の間だけ a の順序が残ります。2キーで並べるにはカンマで orderby a, b と書き、ThenBy にします。

  • #C#
  • #LINQ
  • #クエリ構文
  • #SelectMany
  • #.NET 10

t.laboratory

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

運営者情報 · アプリ

↑