LINQ(リンク)とは、配列やリストなどのデータの集まりに対して、絞り込み・変換・並べ替え・集計を同じ書き方で行える C# の機能です。 products.Where(p => p.Price >= 1000).Select(p => p.Name) のように、メソッドをつなげて「何を取り出したいか」を書きます。for 文で1件ずつ処理するコードを、短く読みやすい形にできます。

この記事では、LINQ のメソッドを用途別(絞り込み・変換・並べ替え・集計・グループ化・結合・集合・要素の取得・判定・生成)の早見表にまとめ、主要なメソッドを .NET 8 で実行した結果を載せています。あわせて、クエリ構文とメソッド構文の対応、遅延評価で結果が変わる例、First と Single の例外メッセージ、Chunk・DistinctBy・MaxBy・Order など .NET 6 以降に追加されたメソッドの対応バージョン表まで扱います。実行環境は .NET SDK 8.0.131(ランタイム 8.0.31)です。

LINQメソッドの用途別早見表

用途 メソッド 働き
絞り込み Where 条件に合う要素だけを残す
絞り込み Take / Skip / TakeLast / SkipLast 先頭・末尾から指定した数を取る/飛ばす
絞り込み TakeWhile / SkipWhile 条件を満たす間だけ取る/飛ばす
絞り込み Distinct / DistinctBy 重複を取り除く(DistinctBy はキーで判定)
絞り込み OfType<T> 指定した型の要素だけを残す
変換 Select 各要素を別の形に変える
変換 SelectMany 入れ子の集まりを1本に平らにする
変換 Chunk 指定した数ごとに分割する
変換 Zip 2つの集まりを同じ位置どうしで組み合わせる
変換 Cast<T> 各要素を指定した型にキャストする
並べ替え OrderBy / OrderByDescending キーで昇順/降順に並べる
並べ替え ThenBy / ThenByDescending 2つ目以降のキーで並べる
並べ替え Order / OrderDescending 要素そのものの値で並べる
並べ替え Reverse 逆順にする
集計 Count / Sum / Average / Min / Max 件数・合計・平均・最小・最大
集計 MinBy / MaxBy キーが最小/最大の要素そのものを返す
集計 Aggregate 自分で決めた計算で1つの値にまとめる
グループ化 GroupBy キーごとにグループに分ける
グループ化 ToLookup キーで引ける形にまとめる(すぐに実行される)
結合 Join キーが一致する要素どうしを組み合わせる(内部結合)
結合 GroupJoin 左側の各要素に、一致する右側の要素の集まりを付ける
結合 Concat 2つの集まりを後ろにつなげる
集合 Union / Intersect / Except 和集合・共通部分・差
集合 UnionBy / IntersectBy / ExceptBy キーで判定する集合演算
要素の取得 First / Last / Single 最初・最後・ただ1つの要素(ないと例外)
要素の取得 FirstOrDefault / LastOrDefault / SingleOrDefault ないときは既定値を返す
要素の取得 ElementAt / ElementAtOrDefault 指定した位置の要素
要素の取得 DefaultIfEmpty 空のときに既定値1件の集まりにする
判定 Any / All 1つでも満たすか/すべて満たすか
判定 Contains / SequenceEqual 含むか/並びが同じか
生成 Enumerable.Range / Repeat / Empty 連番・繰り返し・空の集まりを作る
生成 Append / Prepend 末尾/先頭に1件足す
確定 ToList / ToArray / ToDictionary / ToHashSet その場で実行して、コレクションにする

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# LINQの使い方一覧|Where・Select・GroupByを実行結果つきで」

この記事のコードの動かし方と共通データ

コードは、dotnet new console -o linqsample で作ったプロジェクトの Program.cs に貼り、dotnet run で実行できます。.NET 6 以降のコンソールアプリでは System.Linq の using が自動で入りますが、ここではわかりやすいように明示しています。以降のコード片は、次の共通部分に続けて書く前提です(record の定義は Program.cs の末尾に置きます)。

using System;
using System.Collections.Generic;
using System.Linq;

var products = new List<Product>
{
    new(1, "ノート",     "文具", 180, 40),
    new(2, "ボールペン", "文具", 120, 0),
    new(3, "マウス",     "PC",   1500, 12),
    new(4, "キーボード", "PC",   4800, 5),
    new(5, "椅子",       "家具", 12000, 3),
    new(6, "机",         "家具", 28000, 0),
};
var orders = new List<Order>
{
    new(101, 1, 10), new(102, 3, 2), new(103, 1, 5), new(104, 5, 1), new(105, 9, 1),
};
int[] nums = { 5, 3, 8, 3, 1, 8, 2 };

// 結果を1行で表示するための補助メソッド
void P<T>(string label, IEnumerable<T> seq) => Console.WriteLine($"{label,-28}=> {string.Join(", ", seq)}");
void V(string label, object? value) => Console.WriteLine($"{label,-28}=> {value}");

// (ここに各章のコードを書く)

record Product(int Id, string Name, string Category, int Price, int Stock);
record Order(int Id, int ProductId, int Qty);

products は商品(Id・名前・分類・価格・在庫数)、orders は注文(Id・商品Id・数量)です。注文105は、存在しない商品Id(9)を指しています。

絞り込む(Where・Take・Skip・Distinct)

P("Where(Price>=1000)", products.Where(p => p.Price >= 1000).Select(p => p.Name));
P("Where((p,i)=>i%2==0)", products.Where((p, i) => i % 2 == 0).Select(p => p.Name));
P("Take(2)", nums.Take(2));
P("Skip(5)", nums.Skip(5));
P("Take(2..5)", nums.Take(2..5));
P("TakeLast(2)", nums.TakeLast(2));
P("SkipLast(5)", nums.SkipLast(5));
P("TakeWhile(n=>n>2)", nums.TakeWhile(n => n > 2));
P("SkipWhile(n=>n>2)", nums.SkipWhile(n => n > 2));
P("Distinct()", nums.Distinct());
P("DistinctBy(Category)", products.DistinctBy(p => p.Category).Select(p => p.Name));
P("OfType<int>()", new object[] { 1, "a", 2, null!, 3.5 }.OfType<int>());

実行結果(.NET 8):

Where(Price>=1000)          => マウス, キーボード, 椅子, 机
Where((p,i)=>i%2==0)        => ノート, マウス, 椅子
Take(2)                     => 5, 3
Skip(5)                     => 8, 2
Take(2..5)                  => 8, 3, 1
TakeLast(2)                 => 8, 2
SkipLast(5)                 => 5, 3
TakeWhile(n=>n>2)           => 5, 3, 8, 3
SkipWhile(n=>n>2)           => 1, 8, 2
Distinct()                  => 5, 3, 8, 1, 2
DistinctBy(Category)        => ノート, マウス, 椅子
OfType<int>()               => 1, 2
  • Where は条件が true の要素だけを残します。 2つ目の書き方のように、要素の位置(0始まり)も受け取れます。
  • Skip(n).Take(m) を組み合わせると、ページ分け(n件飛ばしてm件取る)になります。Take(2..5) は範囲で指定する書き方で、位置2から位置5の手前までを取ります。
  • TakeWhile は「条件を満たさなくなった時点」で終わります。Where と違い、その後ろに条件を満たす要素(8)があっても含まれません。
  • DistinctBy はキーが同じ要素のうち最初の1件を残します。 分類ごとに先頭の商品が1件ずつ残りました。

変換する(Select・SelectMany・Chunk・Zip)

P("Select(Name)", products.Select(p => p.Name));
P("Select((p,i)=>...)", products.Take(3).Select((p, i) => $"{i}:{p.Name}"));
P("Select(匿名型)", products.Take(2).Select(p => new { p.Name, Tax = p.Price * 110 / 100 }));
P("SelectMany", new[] { "a b", "c d e" }.SelectMany(s => s.Split(' ')));
P("Chunk(3)", nums.Chunk(3).Select(c => "[" + string.Join(",", c) + "]"));
P("Zip", new[] { 1, 2, 3 }.Zip(new[] { "A", "B", "C" }, (n, s) => $"{n}{s}"));
P("Zip(タプル)", new[] { 1, 2 }.Zip(new[] { "A", "B" }));
P("Cast<int>()", new System.Collections.ArrayList { 1, 2, 3 }.Cast<int>());

実行結果(.NET 8):

Select(Name)                => ノート, ボールペン, マウス, キーボード, 椅子, 机
Select((p,i)=>...)          => 0:ノート, 1:ボールペン, 2:マウス
Select(匿名型)                 => { Name = ノート, Tax = 198 }, { Name = ボールペン, Tax = 132 }
SelectMany                  => a, b, c, d, e
Chunk(3)                    => [5,3,8], [3,1,8], [2]
Zip                         => 1A, 2B, 3C
Zip(タプル)                    => (1, A), (2, B)
Cast<int>()                 => 1, 2, 3
  • Select は要素の数を変えずに、形だけを変えます。 new { p.Name, Tax = ... } のように匿名型を使うと、必要な項目だけの入れ物をその場で作れます。
  • SelectMany は、各要素から得られる複数の値を1本にまとめます。「注文ごとの明細を全部並べる」のような場面で使います。
  • Chunk(3) は3件ずつの配列に分けます。 最後は余りの1件だけの配列になります。まとめて処理する件数を区切りたいときに便利です。
  • Zip は短いほうの長さに合わせて終わります。

並べ替える(OrderBy・ThenBy・Order)

P("OrderBy(Price)", products.OrderBy(p => p.Price).Select(p => p.Name));
P("OrderByDescending(Price)", products.OrderByDescending(p => p.Price).Select(p => p.Name));
P("OrderBy.ThenByDescending", products.OrderBy(p => p.Category).ThenByDescending(p => p.Price).Select(p => p.Name));
P("Order()", nums.Order());
P("OrderDescending()", nums.OrderDescending());
P("Reverse()", nums.AsEnumerable().Reverse());

実行結果(.NET 8):

OrderBy(Price)              => ボールペン, ノート, マウス, キーボード, 椅子, 机
OrderByDescending(Price)    => 机, 椅子, キーボード, マウス, ノート, ボールペン
OrderBy.ThenByDescending    => キーボード, マウス, 机, 椅子, ノート, ボールペン
Order()                     => 1, 2, 3, 3, 5, 8, 8
OrderDescending()           => 8, 8, 5, 3, 3, 2, 1
Reverse()                   => 2, 8, 1, 3, 8, 3, 5
  • 複数のキーで並べるときは、2つ目以降に ThenBy を使います。 OrderBy を2回続けると、後から書いた並べ替えが優先されてしまいます。
  • 数値や文字列をそのままの値で並べるなら、Order() が OrderBy(n => n) より短く書けます(.NET 7 以降)。
  • LINQ の並べ替えは、キーが同じ要素どうしの元の順番を保ちます(安定な並べ替え)。
  • 配列や List<T> には同じ名前の Reverse メソッドがあり、そちらは自分自身を書き換えます。新しい並びを結果として受け取りたいときは、上のように AsEnumerable() を挟むと、LINQ の Reverse であることが明確になります。

集計する(Count・Sum・Average・MaxBy・Aggregate)

V("Count()", products.Count());
V("Count(Stock==0)", products.Count(p => p.Stock == 0));
V("Sum(Price)", products.Sum(p => p.Price));
V("Average(Price)", products.Average(p => p.Price));
V("Min(Price)", products.Min(p => p.Price));
V("Max(Price)", products.Max(p => p.Price));
V("MinBy(Price)", products.MinBy(p => p.Price));
V("MaxBy(Price)", products.MaxBy(p => p.Price)?.Name);
V("Aggregate(合計)", nums.Aggregate((acc, n) => acc + n));
V("Aggregate(初期値あり)", nums.Aggregate("", (acc, n) => acc + n));

実行結果(.NET 8):

Count()                     => 6
Count(Stock==0)             => 2
Sum(Price)                  => 46600
Average(Price)              => 7766.666666666667
Min(Price)                  => 120
Max(Price)                  => 28000
MinBy(Price)                => Product { Id = 2, Name = ボールペン, Category = 文具, Price = 120, Stock = 0 }
MaxBy(Price)                => 机
Aggregate(合計)               => 30
Aggregate(初期値あり)            => 5383182
  • Max(p => p.Price) は「最大の価格(値)」、MaxBy(p => p.Price) は「価格が最大の商品(要素)」を返します。 「いちばん高い商品の名前」が欲しいときは MaxBy が向いています。
  • Count(条件) は、Where(条件).Count() と同じ結果を1回で書けます。
  • Average の結果は、int の集まりでも double になります。
  • Aggregate は、前回までの結果(acc)と次の要素(n)から新しい結果を作る処理を、先頭から順に繰り返します。初期値を渡すと、結果の型を要素の型と変えられます(ここでは文字列の連結)。

グループ化する(GroupBy・ToLookup)

foreach (var g in products.GroupBy(p => p.Category))
    Console.WriteLine($"  {g.Key}: {string.Join(", ", g.Select(p => p.Name))}");
P("GroupBy+集計", products.GroupBy(p => p.Category)
    .Select(g => $"{g.Key}:件数{g.Count()}/合計{g.Sum(p => p.Price)}"));
P("GroupBy(key,結果)", products.GroupBy(p => p.Category, (key, items) => $"{key}:最高{items.Max(p => p.Price)}"));
var lookup = products.ToLookup(p => p.Category);
P("ToLookup[\"PC\"]", lookup["PC"].Select(p => p.Name));
V("ToLookup[\"なし\"].Count()", lookup["なし"].Count());

実行結果(.NET 8):

  文具: ノート, ボールペン
  PC: マウス, キーボード
  家具: 椅子, 机
GroupBy+集計                  => 文具:件数2/合計300, PC:件数2/合計6300, 家具:件数2/合計40000
GroupBy(key,結果)             => 文具:最高180, PC:最高4800, 家具:最高28000
ToLookup["PC"]              => マウス, キーボード
ToLookup["なし"].Count()      => 0
  • GroupBy の結果は「グループの集まり」です。 各グループは Key(分類)を持ち、グループ自体がそのキーに属する要素の集まりになっています。g.Count() や g.Sum(...) のように、グループに対してさらに LINQ を使えます。
  • グループは、キーが最初に現れた順に並びます。
  • ToLookup は、キーで何度も引きたいときに使います。存在しないキーを指定しても例外にならず、空の集まりが返る点が Dictionary との違いです。
やりたいこと 書き方
分類ごとの件数 GroupBy(p => p.Category).Select(g => new { g.Key, Count = g.Count() })
分類ごとの合計 GroupBy(p => p.Category).Select(g => new { g.Key, Total = g.Sum(p => p.Price) })
分類ごとに最も高い商品 GroupBy(p => p.Category).Select(g => g.MaxBy(p => p.Price))
複数の項目でグループ化 GroupBy(p => new { p.Category, InStock = p.Stock > 0 })
件数が2件以上のグループだけ GroupBy(...).Where(g => g.Count() >= 2)

結合する(Join・GroupJoin・左外部結合)

P("Join(内部結合)", orders.Join(products, o => o.ProductId, p => p.Id, (o, p) => $"{o.Id}:{p.Name}x{o.Qty}"));
P("GroupJoin(商品ごとの注文数)", products.Take(3).GroupJoin(orders, p => p.Id, o => o.ProductId,
    (p, os) => $"{p.Name}:{os.Count()}件"));
P("左外部結合", orders.GroupJoin(products, o => o.ProductId, p => p.Id, (o, ps) => new { o, ps })
    .SelectMany(x => x.ps.DefaultIfEmpty(), (x, p) => $"{x.o.Id}:{p?.Name ?? "(該当なし)"}"));
P("Concat", new[] { 1, 2 }.Concat(new[] { 2, 3 }));

実行結果(.NET 8):

Join(内部結合)                  => 101:ノートx10, 102:マウスx2, 103:ノートx5, 104:椅子x1
GroupJoin(商品ごとの注文数)         => ノート:2件, ボールペン:0件, マウス:1件
左外部結合                       => 101:ノート, 102:マウス, 103:ノート, 104:椅子, 105:(該当なし)
Concat                      => 1, 2, 2, 3

Join の引数は「結合する相手、左側のキー、右側のキー、結果の作り方」の順です。

  • Join は内部結合なので、相手が見つからない注文105は結果に出てきません。
  • GroupJoin は、左側(商品)を1件ずつ残したまま、一致する右側(注文)の集まりを付けます。注文のないボールペンも「0件」として残ります。
  • 相手がいない行も残したい(左外部結合)ときは、GroupJoin と SelectMany、DefaultIfEmpty を組み合わせます。相手がいない場合は p が null になるので、p?.Name ?? "(該当なし)" のように扱います。

集合として扱う(Union・Intersect・Except)

int[] a = { 1, 2, 3, 4 }, b = { 3, 4, 5 };
P("Union", a.Union(b));
P("Intersect", a.Intersect(b));
P("Except", a.Except(b));
P("UnionBy(Category)", products.Take(2).UnionBy(products.Skip(2), p => p.Category).Select(p => p.Name));
P("ExceptBy(注文のある商品を除く)", products.ExceptBy(orders.Select(o => o.ProductId), p => p.Id).Select(p => p.Name));
P("IntersectBy(注文のある商品)", products.IntersectBy(orders.Select(o => o.ProductId), p => p.Id).Select(p => p.Name));

実行結果(.NET 8):

Union                       => 1, 2, 3, 4, 5
Intersect                   => 3, 4
Except                      => 1, 2
UnionBy(Category)           => ノート, マウス, 椅子
ExceptBy(注文のある商品を除く)        => ボールペン, キーボード, 机
IntersectBy(注文のある商品)        => ノート, マウス, 椅子

Union は重複を取り除く点が、そのままつなげる Concat との違いです。

ExceptBy と IntersectBy は、2つ目の引数に「キーの集まり」を渡します。 「注文に出てくる商品Idに一致する商品」「一致しない商品」を、Where と Contains を組み合わせるより短く書けます。

要素を1つ取り出す(First・Single・ElementAt)

V("First()", nums.First());
V("First(n=>n>5)", nums.First(n => n > 5));
V("FirstOrDefault(n=>n>100)", nums.FirstOrDefault(n => n > 100));
V("FirstOrDefault(n>100, -1)", nums.FirstOrDefault(n => n > 100, -1));
V("Last()", nums.Last());
V("Single(n=>n==5)", nums.Single(n => n == 5));
V("SingleOrDefault(n=>n==9)", nums.SingleOrDefault(n => n == 9));
V("ElementAt(2)", nums.ElementAt(2));
V("ElementAt(^1)", nums.ElementAt(^1));
V("ElementAtOrDefault(99)", nums.ElementAtOrDefault(99));
P("DefaultIfEmpty(-1)", Array.Empty<int>().DefaultIfEmpty(-1));

実行結果(.NET 8):

First()                     => 5
First(n=>n>5)               => 8
FirstOrDefault(n=>n>100)    => 0
FirstOrDefault(n>100, -1)   => -1
Last()                      => 2
Single(n=>n==5)             => 5
SingleOrDefault(n=>n==9)    => 0
ElementAt(2)                => 8
ElementAt(^1)               => 2
ElementAtOrDefault(99)      => 0
DefaultIfEmpty(-1)          => -1

FirstOrDefault は、見つからないときにその型の既定値(int なら 0、クラスなら null)を返します。0 が「見つからなかった」のか「値が0だった」のか区別しにくいときは、2つ目の引数で既定値を指定できます(.NET 6 以降)。ElementAt(^1) は末尾から数える書き方です。例外になる場合は、後の章でまとめて確認します。

判定する・生成する・コレクションにする

V("Any()", nums.Any());
V("Any(n=>n>7)", nums.Any(n => n > 7));
V("All(n=>n>0)", nums.All(n => n > 0));
V("Contains(8)", nums.Contains(8));
V("SequenceEqual", new[] { 1, 2 }.SequenceEqual(new[] { 1, 2 }));
V("空に対する All", Array.Empty<int>().All(n => n > 100));
V("空に対する Any", Array.Empty<int>().Any());

P("Range(1,5)", Enumerable.Range(1, 5));
P("Repeat(\"x\",3)", Enumerable.Repeat("x", 3));
V("Empty<int>().Count()", Enumerable.Empty<int>().Count());
P("Append(9)", nums.Take(2).Append(9));
P("Prepend(0)", nums.Take(2).Prepend(0));

V("ToList()", products.Where(p => p.Stock == 0).ToList().GetType().Name);
V("ToArray()", nums.ToArray().GetType().Name);
V("ToDictionary[3]", products.ToDictionary(p => p.Id)[3].Name);
V("ToDictionary(Id,Name)[5]", products.ToDictionary(p => p.Id, p => p.Name)[5]);
P("ToHashSet()", nums.ToHashSet());

実行結果(.NET 8):

Any()                       => True
Any(n=>n>7)                 => True
All(n=>n>0)                 => True
Contains(8)                 => True
SequenceEqual               => True
空に対する All                   => True
空に対する Any                   => False
Range(1,5)                  => 1, 2, 3, 4, 5
Repeat("x",3)               => x, x, x
Empty<int>().Count()        => 0
Append(9)                   => 5, 3, 9
Prepend(0)                  => 0, 5, 3
ToList()                    => List`1
ToArray()                   => Int32[]
ToDictionary[3]             => マウス
ToDictionary(Id,Name)[5]    => 椅子
ToHashSet()                 => 5, 3, 8, 1, 2
  • 「1件でもあるか」を調べるときは Count() > 0 より Any() が向いています。 Any() は1件見つかった時点で終わります。
  • 空の集まりに対する All は True になります。 「条件に反する要素が1つもない」という意味だからです。「1件以上あって、すべて条件を満たす」を確かめたいときは Any() と組み合わせます。
  • Enumerable.Range(1, 5) の2つ目の引数は「終わりの値」ではなく「個数」です。
  • ToDictionary はキーが重複すると例外になります。重複がありうるときは ToLookup や GroupBy を使います。

クエリ構文とメソッド構文の対応

LINQ には、SQL に似たクエリ構文(from ... where ... select)と、ここまで使ってきたメソッド構文の2つの書き方があります。クエリ構文は、コンパイル時にメソッド構文に置き換えられるため、結果は同じです。

// 1. 絞り込み + 並べ替え + 変換
var q1 = from p in products
         where p.Price >= 1000
         orderby p.Price descending
         select p.Name;
var m1 = products.Where(p => p.Price >= 1000).OrderByDescending(p => p.Price).Select(p => p.Name);
Console.WriteLine("クエリ  : " + string.Join(", ", q1));
Console.WriteLine("メソッド: " + string.Join(", ", m1));

// 2. グループ化 + 集計
var q2 = from p in products
         group p by p.Category into g
         select new { Category = g.Key, Total = g.Sum(x => x.Price) };
var m2 = products.GroupBy(p => p.Category).Select(g => new { Category = g.Key, Total = g.Sum(x => x.Price) });
Console.WriteLine("クエリ  : " + string.Join(", ", q2));
Console.WriteLine("メソッド: " + string.Join(", ", m2));

// 3. 結合 + 途中の計算(let)
var q3 = from o in orders
         join p in products on o.ProductId equals p.Id
         let amount = p.Price * o.Qty
         where amount >= 1000
         select $"{o.Id}:{p.Name}={amount}";
var m3 = orders.Join(products, o => o.ProductId, p => p.Id, (o, p) => new { o, p, amount = p.Price * o.Qty })
               .Where(x => x.amount >= 1000)
               .Select(x => $"{x.o.Id}:{x.p.Name}={x.amount}");
Console.WriteLine("クエリ  : " + string.Join(", ", q3));
Console.WriteLine("メソッド: " + string.Join(", ", m3));

実行結果(.NET 8。3つ目は注文101〜103の3件で実行):

クエリ  : 机, 椅子, キーボード, マウス
メソッド: 机, 椅子, キーボード, マウス
クエリ  : { Category = 文具, Total = 300 }, { Category = PC, Total = 6300 }, { Category = 家具, Total = 40000 }
メソッド: { Category = 文具, Total = 300 }, { Category = PC, Total = 6300 }, { Category = 家具, Total = 40000 }
クエリ  : 101:ノート=1800, 102:マウス=3000
メソッド: 101:ノート=1800, 102:マウス=3000
クエリ構文 メソッド構文
from p in products products(起点)
where 条件 .Where(p => 条件)
select 式 .Select(p => 式)
orderby キー / orderby キー descending .OrderBy(...) / .OrderByDescending(...)
orderby a, b .OrderBy(a).ThenBy(b)
group p by キー into g .GroupBy(p => キー)
join b in B on a.X equals b.Y .Join(B, a => a.X, b => b.Y, (a, b) => ...)
join ... into g .GroupJoin(...)
from a in A from b in a.Items .SelectMany(a => a.Items, ...)
let x = 式 .Select(p => new { p, x = 式 })

短い絞り込みや変換、Count・First・Take・Distinct を使う処理はメソッド構文、結合が複数ある処理や let を使う処理はクエリ構文が読みやすくなります。クエリ構文の結果に (from ... select ...).ToList() のようにメソッドを続けることもできます。

遅延評価で結果が変わる例と ToList

LINQ の多くのメソッドは、呼んだ時点では処理をせず、結果が必要になったとき(foreach や ToList など)に初めて実行されます。 これを遅延評価と呼びます。この性質を知っておくと、「思っていた結果と違う」という場面の理由がわかります。

var list = new List<int> { 1, 2, 3 };
var lazy = list.Where(n => n >= 2);                 // ここではまだ実行されない
var fixedList = list.Where(n => n >= 2).ToList();   // ここで実行され、結果が確定する
list.Add(4);
Console.WriteLine("Where のみ : " + string.Join(", ", lazy));
Console.WriteLine("ToList 済み: " + string.Join(", ", fixedList));

int threshold = 2;
var byVar = list.Where(n => n >= threshold);
threshold = 4;                                      // 列挙する前に変数を変更
Console.WriteLine("変数を後から変更: " + string.Join(", ", byVar));

実行結果(.NET 8):

Where のみ : 2, 3, 4
ToList 済み: 2, 3
変数を後から変更: 4

Where だけの lazy は、後から追加した 4 を含んでいます。lazy が持っているのは「結果」ではなく「list から2以上を取り出す、という手順」だからです。同じ理由で、ラムダ式の中で使っている変数(threshold)を後から変更すると、変更後の値で判定されます。その時点の結果を確定させたいときは、ToList() や ToArray() を呼びます。

実行される順番を確かめる

var seq = new[] { 1, 2, 3, 4 }
    .Where(n => { Console.WriteLine($"  Where  {n}"); return n % 2 == 0; })
    .Select(n => { Console.WriteLine($"  Select {n}"); return n * 10; });
Console.WriteLine("クエリを組み立てました(まだ何も表示されない)");
foreach (var x in seq) Console.WriteLine($"  結果   {x}");

実行結果(.NET 8):

クエリを組み立てました(まだ何も表示されない)
  Where  1
  Where  2
  Select 2
  結果   20
  Where  3
  Where  4
  Select 4
  結果   40

「全件を Where してから全件を Select」ではなく、1件ずつ Where → Select → 結果、と流れていきます。 途中に Take(1) があれば、1件見つかった時点で残りは処理されません。

種類 メソッドの例 実行されるタイミング
遅延評価 Where・Select・SelectMany・Take・Skip・Distinct・Concat・Chunk・Zip 列挙したとき。1件ずつ流れる
遅延評価(全件を読んでから返す) OrderBy・GroupBy・Reverse・Join・Except 列挙したとき。最初の1件を返す前に全体を読む
すぐに実行 ToList・ToArray・ToDictionary・ToLookup・ToHashSet 呼んだとき。コレクションを返す
すぐに実行 Count・Sum・Max・First・Any・All・Aggregate 呼んだとき。1つの値を返す

戻り値が IEnumerable<T> なら遅延評価、1つの値やコレクションならすぐに実行、と覚えると見分けやすくなります。

複数回列挙の落とし穴

遅延評価のクエリは、列挙するたびに最初から実行し直されます。 同じクエリに Count()・Sum()・Max() を続けて呼ぶと、そのたびに全体が計算されます。

int calls = 0;
IEnumerable<int> expensive = new[] { 1, 2, 3 }.Select(n => { calls++; return n * n; });
Console.WriteLine($"Count={expensive.Count()}, Sum={expensive.Sum()}, Max={expensive.Max()}");
Console.WriteLine($"ラムダが呼ばれた回数(ToListなし): {calls}");

calls = 0;
var cached = new[] { 1, 2, 3 }.Select(n => { calls++; return n * n; }).ToList();
Console.WriteLine($"Count={cached.Count}, Sum={cached.Sum()}, Max={cached.Max()}");
Console.WriteLine($"ラムダが呼ばれた回数(ToListあり): {calls}");

var rnd = new Random(1);
var randoms = Enumerable.Range(0, 3).Select(_ => rnd.Next(100));
Console.WriteLine("1回目: " + string.Join(", ", randoms));
Console.WriteLine("2回目: " + string.Join(", ", randoms));

実行結果(.NET 8):

Count=3, Sum=14, Max=9
ラムダが呼ばれた回数(ToListなし): 9
Count=3, Sum=14, Max=9
ラムダが呼ばれた回数(ToListあり): 3
1回目: 24, 11, 46
2回目: 77, 65, 43

3件のデータに対して、ToList なしではラムダ式が9回(3件×3回の列挙)呼ばれました。ToList で一度確定させれば3回で済みます。変換の中身がデータベースへの問い合わせや時間のかかる計算だった場合、この差はそのまま処理時間の差になります。

乱数の例では、同じ変数を2回表示しただけで中身が変わっています。 列挙のたびに rnd.Next が呼び直されるためです。現在時刻や連番の採番のように「呼ぶたびに結果が変わる処理」を含むクエリは、ToList() で確定させてから使うと、何度参照しても同じ値になります。

列挙の途中で元のコレクションを変更したとき

var src = new List<int> { 1, 2, 3 };
foreach (var n in src.Where(n => n > 1)) src.Remove(n);

実行結果(.NET 8):

InvalidOperationException: Collection was modified; enumeration operation may not execute.

Where は src を読みながら進むので、途中で src を変更すると例外になります。src.Where(n => n > 1).ToList() としてから foreach するか、src.RemoveAll(n => n > 1) を使うと意図どおりに動きます。

First・Single の例外と OrDefault の使い分け

要素を取り出すメソッドや集計メソッドは、対象がないときの動きがメソッドによって違います。実際のメッセージを一覧にします(.NET 8 で実行。empty は空の int[]、nums は { 5, 3, 8, 3 })。

呼び出し 結果
empty.First() InvalidOperationException: Sequence contains no elements
nums.First(n => n > 100) InvalidOperationException: Sequence contains no matching element
nums.Single() InvalidOperationException: Sequence contains more than one element
nums.Single(n => n == 3) InvalidOperationException: Sequence contains more than one matching element
nums.SingleOrDefault(n => n == 3) InvalidOperationException: Sequence contains more than one matching element
empty.FirstOrDefault() 0
empty.FirstOrDefault(-1) -1
products.FirstOrDefault(p => p.Id == 99) null
empty.Max() / empty.Average() InvalidOperationException: Sequence contains no elements
empty.Sum() 0
empty.DefaultIfEmpty(0).Max() 0
empty.Cast<int?>().Max() null
products.Where(p => p.Price < 0).MaxBy(p => p.Price) null
empty.Aggregate((a, b) => a + b) InvalidOperationException: Sequence contains no elements
nums.ElementAt(10) ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
products.ToDictionary(p => p.Category) ArgumentException: An item with the same key has already been added. Key: 文具
((int[])null).Where(n => n > 0) ArgumentNullException: Value cannot be null. (Parameter 'source')
new object[] { 1, "a" }.Cast<int>().ToList() InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Int32'.

読み取れるポイントは次のとおりです。

  • SingleOrDefault は「0件なら既定値」ですが、「2件以上」のときは例外になります。 OrDefault が付いていても、複数件は許されません。
  • 空の集まりに対して、Sum は 0 を返し、Max・Min・Average は例外になります(要素の型が int などの値型の場合)。空になりうるときは、DefaultIfEmpty(0).Max() のように既定値を用意します。
  • 要素がクラスや int? のように null を取れる型なら、Max・MaxBy は空のとき null を返します。
メソッド 0件 1件 2件以上 使う場面
First 例外 その要素 最初の要素 必ず1件以上あるはずのとき
FirstOrDefault 既定値 その要素 最初の要素 ない場合もあるとき(主キー以外での検索など)
Single 例外 その要素 例外 ちょうど1件のはずで、違えば気づきたいとき
SingleOrDefault 既定値 その要素 例外 0件か1件のはずのとき(Idでの検索など)

FirstOrDefault の結果がクラスの場合は null になりうるので、if (item is null) や item?.Name で確認してから使います。

.NET 6〜9 で追加されたメソッドの対応バージョン表

LINQ のメソッドは .NET のバージョンとともに増えています。.NET 6 と 7 の行は一般に知られた追加時期で、メソッドの動作は今回 .NET 8 で実行して確かめました。.NET 9 の3つは、Microsoft Learn の「.NET 9 の新機能(ライブラリ)」のページで確認した内容です(.NET 8 では実行できないため未実行)。

メソッド 使える版 働き 今回の確認
Chunk(n) .NET 6 以降 n件ずつの配列に分ける .NET 8 で実行
DistinctBy / UnionBy / IntersectBy / ExceptBy .NET 6 以降 キーで判定する重複除去・集合演算 .NET 8 で実行
MinBy / MaxBy .NET 6 以降 キーが最小/最大の要素を返す .NET 8 で実行
Take(2..5)(範囲指定) .NET 6 以降 範囲で取り出す .NET 8 で実行
ElementAt(^1)(末尾から) .NET 6 以降 末尾から数えて取り出す .NET 8 で実行
FirstOrDefault(既定値) など .NET 6 以降 見つからないときの値を指定する .NET 8 で実行
TryGetNonEnumeratedCount .NET 6 以降 列挙せずに件数がわかる場合だけ取得する .NET 8 で実行
Order() / OrderDescending() .NET 7 以降 要素そのものの値で並べる .NET 8 で実行
CountBy .NET 9 以降 キーごとの件数を返す 公式ドキュメントで確認
AggregateBy .NET 9 以降 キーごとに Aggregate する 公式ドキュメントで確認
Index() .NET 9 以降 (位置, 要素) の組にする 公式ドキュメントで確認

.NET 8 のプロジェクトで .NET 9 のメソッドを書くと、次のコンパイルエラーになります(.NET 8 で実行)。

Program.cs(3,25): error CS1061: 'int[]' does not contain a definition for 'CountBy' and no accessible extension method 'CountBy' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)

.NET 9 のメソッドと同じ結果を .NET 8 で書く

.NET 8 では、GroupBy と Select の組み合わせで同じ結果を得られます。

// .NET 9: products.CountBy(p => p.Category)
var countBy = products.GroupBy(p => p.Category).Select(g => KeyValuePair.Create(g.Key, g.Count()));
Console.WriteLine("CountBy 相当    : " + string.Join(", ", countBy));

// .NET 9: products.AggregateBy(p => p.Category, 0, (sum, p) => sum + p.Price)
var aggBy = products.GroupBy(p => p.Category)
    .Select(g => KeyValuePair.Create(g.Key, g.Aggregate(0, (sum, p) => sum + p.Price)));
Console.WriteLine("AggregateBy 相当: " + string.Join(", ", aggBy));

// .NET 9: products.Take(3).Index()
var index = products.Take(3).Select((p, i) => (Index: i, Item: p.Name));
Console.WriteLine("Index 相当      : " + string.Join(", ", index));

実行結果(.NET 8):

CountBy 相当    : [文具, 2], [PC, 2], [家具, 2]
AggregateBy 相当: [文具, 300], [PC, 6300], [家具, 40000]
Index 相当      : (0, ノート), (1, ボールペン), (2, マウス)

公式ドキュメントによると、CountBy と AggregateBy はキーと値の組(KeyValuePair)の集まりを返し、Index() は foreach ((int index, string line) in lines.Index()) のように位置と要素を同時に受け取れます。.NET 9 以降では、グループを途中で作らずに済むぶん、短く書けるようになります。

よくある質問

Q. LINQ と for 文はどちらを使えばよいですか? A. 絞り込み・変換・集計のように「何を取り出すか」を表す処理は LINQ が読みやすくなります。要素の位置を細かく操作する処理や、途中で複数の変数を更新していく処理は for や foreach のほうが素直に書けます。両方を混ぜても問題ありません。

Q. ToList と ToArray はどちらを使えばよいですか? A. 後から要素を追加・削除するなら ToList()、件数が変わらない結果として持つなら ToArray() が目安です。どちらも呼んだ時点でクエリを実行し、結果を確定させる点は同じです。

Q. クエリ構文とメソッド構文はどちらを覚えればよいですか? A. まずメソッド構文を覚えるのがおすすめです。Count・First・Take・Distinct など、メソッド構文でしか書けない操作が多いためです。結合が複数ある処理ではクエリ構文が読みやすい場合があるので、読めるようにしておくと役立ちます。

まとめ

  • LINQ は、絞り込み(Where)・変換(Select)・並べ替え(OrderBy)・集計(Sum など)・グループ化(GroupBy)を同じ書き方でつなげられる機能
  • GroupBy の結果はグループの集まり。 g.Key でキー、g.Count() や g.Sum(...) でグループごとの集計ができる
  • Where や Select は遅延評価。 列挙した時点で実行されるので、結果を確定させたいとき・複数回使うときは ToList() を呼ぶ
  • First と Single は対象がないと InvalidOperationException。 ない場合があるなら FirstOrDefault、0件か1件のはずなら SingleOrDefault
  • 空の集まりに対して Sum は 0、Max・Average は例外。 DefaultIfEmpty(0) で既定値を用意できる
  • 複数キーの並べ替えは OrderBy(...).ThenBy(...)、キーでの重複除去は DistinctBy、最大の要素は MaxBy
  • Chunk・DistinctBy・MaxBy は .NET 6 以降、Order は .NET 7 以降、CountBy・AggregateBy・Index は .NET 9 以降で使える

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# LINQの使い方一覧|Where・Select・GroupByを実行結果つきで」

株式会社RJCでは、C#のLINQのような日々使う書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり