LINQ

【C#】LINQ拡張メソッドの使い方と応用テクニックまとめ

LINQの拡張メソッドは、配列やリストなどのコレクションに対してSelectWhereなどをメソッドチェーンで繋ぎ、クエリ構文と同等の処理をシンプルに書ける仕組みです。

カスタム演算を追加できる柔軟性があり、可読性と再利用性を高めながら遅延実行や非同期処理とも自然に連携できます。

LINQ拡張メソッドの基本構造

LINQ(Language Integrated Query)は、C#でデータ操作を簡潔に記述できる強力な機能です。

LINQの拡張メソッドは、既存の型に対して新しいメソッドを追加する仕組みを利用して実装されています。

ここでは、LINQ拡張メソッドの基本的な構造や仕組みについて詳しく解説します。

拡張メソッドの仕組み

拡張メソッドは、既存のクラスやインターフェイスに対して、あたかもそのクラスのメソッドであるかのように振る舞うメソッドを追加できる機能です。

これにより、元のクラスを継承したり修正したりせずに機能拡張が可能になります。

拡張メソッドは、静的クラスの中に静的メソッドとして定義され、第一引数にthisキーワードを付けて拡張対象の型を指定します。

呼び出し側は、まるでインスタンスメソッドのように使えます。

例えば、以下のような拡張メソッドを考えてみましょう。

public static class StringExtensions
{
    // 文字列の先頭1文字を大文字に変換する拡張メソッド
    public static string Capitalize(this string str)
    {
        if (string.IsNullOrEmpty(str)) return str;
        return char.ToUpper(str[0]) + str.Substring(1);
    }
}
class Program
{
    static void Main()
    {
        string name = "linq";
        // 拡張メソッドをインスタンスメソッドのように呼び出せる
        string capitalized = name.Capitalize();
        System.Console.WriteLine(capitalized); // 出力: Linq
    }
}
Linq

この例では、string型にCapitalizeというメソッドを追加しています。

this string strが拡張対象を示しており、呼び出し時はname.Capitalize()のように書けます。

LINQの拡張メソッドも同様に、IEnumerable<T>などのインターフェイスに対して多数の拡張メソッドが用意されており、データのフィルタリングや変換、集計などを簡単に行えます。

静的クラスとthisキーワード

拡張メソッドは必ず静的クラスの中に定義しなければなりません。

静的クラスはインスタンス化できず、メソッドもすべて静的である必要があります。

これにより、拡張メソッドは型に対してグローバルに追加される形になります。

thisキーワードは、拡張メソッドの第一引数に付けて、そのメソッドが拡張する型を指定します。

これがあることで、呼び出し側はあたかもインスタンスメソッドのように呼べるのです。

例えば、以下のように定義します。

public static class EnumerableExtensions
{
    // IEnumerable<T>の要素数をカウントする拡張メソッド(例)
    public static int CountElements<T>(this IEnumerable<T> source)
    {
        int count = 0;
        foreach (var item in source)
        {
            count++;
        }
        return count;
    }
}
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3, 4 };
        int count = numbers.CountElements();
        System.Console.WriteLine(count); // 出力: 4
    }
}
4

この例では、IEnumerable<T>CountElementsという拡張メソッドを追加しています。

this IEnumerable<T> sourceが拡張対象で、呼び出し時はnumbers.CountElements()と書けます。

このように、静的クラスとthisキーワードの組み合わせが拡張メソッドの基本構造です。

ジェネリックと型推論の役割

LINQの拡張メソッドは多くの場合、ジェネリックメソッドとして定義されています。

ジェネリックを使うことで、任意の型のコレクションに対して同じメソッドを適用できる柔軟性を持たせています。

例えば、IEnumerable<T>に対するWhereメソッドは以下のように定義されています(簡略化したイメージです)。

public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (T item in source)
    {
        if (predicate(item))
        {
            yield return item;
        }
    }
}

ここで、Tはジェネリック型パラメータで、呼び出し時にコンパイラが自動的に型を推論します。

例えば、

var numbers = new List<int> { 1, 2, 3, 4, 5 };
var evenNumbers = numbers.Where(n => n % 2 == 0);

この場合、Tintと推論され、Where<int>として呼び出されます。

型推論のおかげで、呼び出し側は型を明示的に指定する必要がありません。

ジェネリックと型推論の組み合わせにより、LINQ拡張メソッドは非常に汎用的かつ使いやすくなっています。

IEnumerable<T>インターフェイスとの連携

LINQ拡張メソッドの多くは、IEnumerable<T>インターフェイスを拡張しています。

IEnumerable<T>は、ジェネリックな列挙可能コレクションを表すインターフェイスで、foreachループで要素を列挙できることが特徴です。

LINQの拡張メソッドは、このIEnumerable<T>を受け取り、条件に合う要素の抽出や変換、集計などを行います。

これにより、配列やリスト、セット、さらには自作のコレクションなど、IEnumerable<T>を実装しているあらゆる型に対して同じ操作が可能です。

例えば、WhereメソッドはIEnumerable<T>の要素を順に調べて、条件を満たすものだけを返します。

Selectメソッドは要素を別の型に変換して新しい列挙可能なコレクションを返します。

以下はIEnumerable<T>を使った簡単な例です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        IEnumerable<string> fruits = new List<string> { "apple", "banana", "cherry", "date" };
        // 文字数が5文字以上の果物だけを抽出
        var longFruits = fruits.Where(f => f.Length >= 5);
        foreach (var fruit in longFruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
apple
banana
cherry

この例では、fruitsList<string>ですが、IEnumerable<string>として扱われています。

Where拡張メソッドはIEnumerable<string>を受け取り、条件に合う要素だけを返しています。

このように、IEnumerable<T>はLINQ拡張メソッドの基盤となるインターフェイスであり、LINQの柔軟性と汎用性を支えています。

代表的な拡張メソッド一覧と使用例

フィルタリング

Where

Whereは条件に合致する要素だけを抽出する拡張メソッドです。

条件はFunc<T, bool>のデリゲートで指定します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
        // 偶数だけを抽出
        var evens = numbers.Where(n => n % 2 == 0);
        foreach (var num in evens)
        {
            Console.WriteLine(num);
        }
    }
}
2
4
6

この例では、numbersの中から偶数だけを抽出しています。

Whereは遅延実行されるため、実際に列挙するときに条件が評価されます。

OfType

OfType<T>()は、コレクション内の要素のうち指定した型にキャスト可能なものだけを抽出します。

異なる型が混在するコレクションで特定の型だけを取り出す際に便利です。

using System;
using System.Collections;
class Program
{
    static void Main()
    {
        ArrayList mixedList = new ArrayList { 1, "two", 3, "four", 5 };
        // int型の要素だけ抽出
        var ints = mixedList.OfType<int>();
        foreach (var i in ints)
        {
            Console.WriteLine(i);
        }
    }
}
1
3
5

OfTypeは型チェックとキャストを同時に行い、指定型以外の要素は除外します。

投影

Select

Selectは各要素を別の形に変換する拡張メソッドです。

変換関数をFunc<TSource, TResult>で指定します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var words = new List<string> { "apple", "banana", "cherry" };
        // 文字列の長さに変換
        var lengths = words.Select(w => w.Length);
        foreach (var len in lengths)
        {
            Console.WriteLine(len);
        }
    }
}
5
6
6

Selectは元のコレクションの要素数を変えずに、各要素を変換して返します。

SelectMany

SelectManyは各要素から複数の要素を生成し、それらを一つのシーケンスに平坦化します。

ネストしたコレクションを扱う際に使います。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var sentences = new List<string> { "Hello world", "LINQ is powerful" };
        // 各文を単語に分割し、すべての単語を一つの列挙にまとめる
        var words = sentences.SelectMany(s => s.Split(' '));
        foreach (var word in words)
        {
            Console.WriteLine(word);
        }
    }
}
Hello
world
LINQ
is
powerful

SelectManyは複数のコレクションを一つにまとめるのに適しています。

要素取得

First / FirstOrDefault

Firstは条件に合う最初の要素を返します。

条件を満たす要素がない場合は例外が発生します。

FirstOrDefaultは条件に合う要素がなければ型のデフォルト値(参照型ならnull)を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 3, 5, 7, 9 };
        // 5より大きい最初の要素
        int firstOver5 = numbers.First(n => n > 5);
        Console.WriteLine(firstOver5); // 7
        // 10より大きい最初の要素(存在しない)
        int firstOver10 = numbers.FirstOrDefault(n => n > 10);
        Console.WriteLine(firstOver10); // 0(intのデフォルト値)
    }
}

Single / SingleOrDefault

Singleは条件に合う要素がただ1つだけ存在する場合にその要素を返します。

0個または複数ある場合は例外が発生します。

SingleOrDefaultは0個の場合はデフォルト値を返しますが、複数ある場合は例外です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 2, 4, 6 };
        // 4だけを取得
        int singleFour = numbers.Single(n => n == 4);
        Console.WriteLine(singleFour); // 4
        // 10は存在しないのでデフォルト値を返す
        int singleTen = numbers.SingleOrDefault(n => n == 10);
        Console.WriteLine(singleTen); // 0
    }
}

ElementAt / ElementAtOrDefault

ElementAtは指定したインデックスの要素を返します。

範囲外の場合は例外が発生します。

ElementAtOrDefaultは範囲外の場合にデフォルト値を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var letters = new List<char> { 'a', 'b', 'c' };
        char second = letters.ElementAt(1);
        Console.WriteLine(second); // b
        char fifth = letters.ElementAtOrDefault(4);
        Console.WriteLine(fifth == default(char) ? "default" : fifth.ToString()); // default
    }
}

集計

Count

Countは要素数を返します。

条件を指定すると条件に合う要素数を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };
        int totalCount = numbers.Count();
        int evenCount = numbers.Count(n => n % 2 == 0);
        Console.WriteLine($"Total: {totalCount}, Even: {evenCount}");
    }
}
Total: 5, Even: 2

Sum

Sumは数値の合計を計算します。

数値型のコレクションに使います。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var prices = new List<decimal> { 10.5m, 20.0m, 5.5m };
        decimal total = prices.Sum();
        Console.WriteLine($"合計金額: {total}");
    }
}
合計金額: 36.0

Average

Averageは数値の平均値を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var scores = new List<int> { 80, 90, 100 };
        double avg = scores.Average();
        Console.WriteLine($"平均点: {avg}");
    }
}
平均点: 90

Min / Max

Minは最小値、Maxは最大値を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var temps = new List<int> { 15, 22, 8, 19 };
        int minTemp = temps.Min();
        int maxTemp = temps.Max();
        Console.WriteLine($"最低気温: {minTemp}, 最高気温: {maxTemp}");
    }
}
最低気温: 8, 最高気温: 22

並べ替え

OrderBy / OrderByDescending

OrderByは昇順、OrderByDescendingは降順で並べ替えます。

キーを指定する関数を渡します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var people = new[]
        {
            new { Name = "Alice", Age = 30 },
            new { Name = "Bob", Age = 25 },
            new { Name = "Charlie", Age = 35 }
        };
        var sortedAsc = people.OrderBy(p => p.Age);
        var sortedDesc = people.OrderByDescending(p => p.Age);
        Console.WriteLine("昇順:");
        foreach (var p in sortedAsc) Console.WriteLine($"{p.Name}: {p.Age}");
        Console.WriteLine("降順:");
        foreach (var p in sortedDesc) Console.WriteLine($"{p.Name}: {p.Age}");
    }
}
昇順:
Bob: 25
Alice: 30
Charlie: 35
降順:
Charlie: 35
Alice: 30
Bob: 25

ThenBy / ThenByDescending

ThenByOrderByの後に複数のキーで並べ替えを行う際に使います。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var people = new[]
        {
            new { Name = "Alice", Age = 30 },
            new { Name = "Bob", Age = 30 },
            new { Name = "Charlie", Age = 25 }
        };
        var sorted = people.OrderBy(p => p.Age).ThenBy(p => p.Name);
        foreach (var p in sorted)
        {
            Console.WriteLine($"{p.Name}: {p.Age}");
        }
    }
}
Charlie: 25
Alice: 30
Bob: 30

セット演算

Distinct

Distinctは重複する要素を除外して一意な要素だけを返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var nums = new List<int> { 1, 2, 2, 3, 3, 3 };
        var distinctNums = nums.Distinct();
        foreach (var n in distinctNums)
        {
            Console.WriteLine(n);
        }
    }
}
1
2
3

Union

Unionは2つのコレクションの和集合を返します。

重複は除かれます。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var set1 = new[] { 1, 2, 3 };
        var set2 = new[] { 3, 4, 5 };
        var union = set1.Union(set2);
        foreach (var n in union)
        {
            Console.WriteLine(n);
        }
    }
}
1
2
3
4
5

Intersect

Intersectは2つのコレクションの共通部分を返します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var set1 = new[] { 1, 2, 3 };
        var set2 = new[] { 2, 3, 4 };
        var intersect = set1.Intersect(set2);
        foreach (var n in intersect)
        {
            Console.WriteLine(n);
        }
    }
}
2
3

Except

Exceptは最初のコレクションから2番目のコレクションに含まれる要素を除外します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var set1 = new[] { 1, 2, 3, 4 };
        var set2 = new[] { 2, 4 };
        var except = set1.Except(set2);
        foreach (var n in except)
        {
            Console.WriteLine(n);
        }
    }
}
1
3

変換

ToList

ToListIEnumerable<T>List<T>に変換します。

即時実行されます。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 5);
        List<int> list = numbers.ToList();
        Console.WriteLine(string.Join(", ", list));
    }
}
1, 2, 3, 4, 5

ToArray

ToArrayIEnumerable<T>を配列に変換します。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 3);
        int[] array = numbers.ToArray();
        Console.WriteLine(string.Join(", ", array));
    }
}
1, 2, 3

ToDictionary

ToDictionaryはキーと値の関数を指定して辞書に変換します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var fruits = new[] { "apple", "banana", "cherry" };
        var dict = fruits.ToDictionary(f => f, f => f.Length);
        foreach (var kvp in dict)
        {
            Console.WriteLine($"{kvp.Key}: {kvp.Value}");
        }
    }
}
apple: 5
banana: 6
cherry: 6

生成

Range

Rangeは指定した開始値から連続した整数のシーケンスを生成します。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var range = Enumerable.Range(5, 4);
        foreach (var n in range)
        {
            Console.WriteLine(n);
        }
    }
}
5
6
7
8

Repeat

Repeatは指定した値を指定回数繰り返すシーケンスを生成します。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var repeated = Enumerable.Repeat("Hello", 3);
        foreach (var s in repeated)
        {
            Console.WriteLine(s);
        }
    }
}
Hello
Hello
Hello

Empty

Empty<T>は空のシーケンスを返します。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var empty = Enumerable.Empty<int>();
        Console.WriteLine($"要素数: {empty.Count()}");
    }
}
要素数: 0

クエリ結合

Join

Joinは2つのコレクションをキーで結合し、結合結果を生成します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    class Score
    {
        public int StudentId { get; set; }
        public int Value { get; set; }
    }
    static void Main()
    {
        var students = new[]
        {
            new Student { Id = 1, Name = "Alice" },
            new Student { Id = 2, Name = "Bob" }
        };
        var scores = new[]
        {
            new Score { StudentId = 1, Value = 90 },
            new Score { StudentId = 2, Value = 80 },
            new Score { StudentId = 1, Value = 85 }
        };
        var query = students.Join(
            scores,
            s => s.Id,
            sc => sc.StudentId,
            (s, sc) => new { s.Name, sc.Value }
        );
        foreach (var item in query)
        {
            Console.WriteLine($"{item.Name}: {item.Value}");
        }
    }
}
Alice: 90
Bob: 80
Alice: 85

GroupJoin

GroupJoinは左側の要素に対して右側の関連する複数の要素をグループ化して結合します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    class Score
    {
        public int StudentId { get; set; }
        public int Value { get; set; }
    }
    static void Main()
    {
        var students = new[]
        {
            new Student { Id = 1, Name = "Alice" },
            new Student { Id = 2, Name = "Bob" }
        };
        var scores = new[]
        {
            new Score { StudentId = 1, Value = 90 },
            new Score { StudentId = 2, Value = 80 },
            new Score { StudentId = 1, Value = 85 }
        };
        var query = students.GroupJoin(
            scores,
            s => s.Id,
            sc => sc.StudentId,
            (s, scs) => new { s.Name, Scores = scs.Select(x => x.Value) }
        );
        foreach (var item in query)
        {
            Console.WriteLine($"{item.Name}: {string.Join(", ", item.Scores)}");
        }
    }
}
Alice: 90, 85
Bob: 80

グループ化

GroupBy

GroupByは指定したキーで要素をグループ化します。

グループはIGrouping<TKey, TElement>として返されます。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    class Person
    {
        public string Name { get; set; }
        public string City { get; set; }
    }
    static void Main()
    {
        var people = new[]
        {
            new Person { Name = "Alice", City = "Tokyo" },
            new Person { Name = "Bob", City = "Osaka" },
            new Person { Name = "Charlie", City = "Tokyo" }
        };
        var groups = people.GroupBy(p => p.City);
        foreach (var group in groups)
        {
            Console.WriteLine($"都市: {group.Key}");
            foreach (var person in group)
            {
                Console.WriteLine($"  {person.Name}");
            }
        }
    }
}
都市: Tokyo
  Alice
  Charlie
都市: Osaka
  Bob

カスタム拡張メソッドの実装手順

文字列操作の例

SplitWords

文字列を単語ごとに分割する拡張メソッドです。

空白や句読点などで区切りたい場合に便利です。

ここでは空白文字で分割し、空の要素を除外する例を示します。

using System;
using System.Collections.Generic;
using System.Linq;
public static class StringExtensions
{
    // 文字列を空白で分割し、空要素を除外して単語の列挙を返す
    public static IEnumerable<string> SplitWords(this string str)
    {
        if (string.IsNullOrWhiteSpace(str))
            return Enumerable.Empty<string>();
        // 空白文字で分割し、空文字列を除外
        return str.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
    }
}
class Program
{
    static void Main()
    {
        string sentence = "C# LINQ 拡張メソッド の 使い方";
        var words = sentence.SplitWords();
        foreach (var word in words)
        {
            Console.WriteLine(word);
        }
    }
}
C#
LINQ
拡張メソッド
の
使い方

このメソッドは文字列が空や空白のみの場合は空の列挙を返し、そうでなければ空白文字で分割して単語を返します。

LINQのEnumerable.Empty<T>()を使うことで空のシーケンスを返せます。

ToCamelCase

文字列の先頭文字を小文字に変換し、キャメルケースにする拡張メソッドです。

例えば"HelloWorld""helloWorld"に変換します。

using System;
public static class StringExtensions
{
    // 先頭文字を小文字に変換してキャメルケースにする
    public static string ToCamelCase(this string str)
    {
        if (string.IsNullOrEmpty(str))
            return str;
        if (str.Length == 1)
            return str.ToLower();
        return char.ToLower(str[0]) + str.Substring(1);
    }
}
class Program
{
    static void Main()
    {
        string pascal = "HelloWorld";
        string camel = pascal.ToCamelCase();
        Console.WriteLine(camel);
    }
}
helloWorld

このメソッドは文字列が空または1文字の場合に対応し、先頭文字だけを小文字に変換して返します。

日付計算の例

IsWeekend

DateTime型の拡張メソッドで、指定した日付が土曜日または日曜日かどうかを判定します。

using System;
public static class DateTimeExtensions
{
    // 土日かどうか判定する拡張メソッド
    public static bool IsWeekend(this DateTime date)
    {
        return date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday;
    }
}
class Program
{
    static void Main()
    {
        DateTime date1 = new DateTime(2024, 6, 15); // 土曜日
        DateTime date2 = new DateTime(2024, 6, 17); // 月曜日
        Console.WriteLine($"{date1.ToShortDateString()} は週末? {date1.IsWeekend()}");
        Console.WriteLine($"{date2.ToShortDateString()} は週末? {date2.IsWeekend()}");
    }
}
2024/06/15 は週末? True
2024/06/17 は週末? False

DayOfWeek列挙型を使い、土曜か日曜かを判定しています。

NextBusinessDay

指定した日付の次の営業日を返す拡張メソッドです。

土日を除外して平日のみを返します。

using System;
public static class DateTimeExtensions
{
    // 次の営業日を返す拡張メソッド(土日を除く)
    public static DateTime NextBusinessDay(this DateTime date)
    {
        DateTime nextDay = date.AddDays(1);
        // 土日の場合は月曜日まで進める
        if (nextDay.DayOfWeek == DayOfWeek.Saturday)
            nextDay = nextDay.AddDays(2);
        else if (nextDay.DayOfWeek == DayOfWeek.Sunday)
            nextDay = nextDay.AddDays(1);
        return nextDay;
    }
}
class Program
{
    static void Main()
    {
        DateTime fri = new DateTime(2024, 6, 14); // 金曜日
        DateTime sat = new DateTime(2024, 6, 15); // 土曜日
        Console.WriteLine($"{fri.ToShortDateString()} の次の営業日: {fri.NextBusinessDay().ToShortDateString()}");
        Console.WriteLine($"{sat.ToShortDateString()} の次の営業日: {sat.NextBusinessDay().ToShortDateString()}");
    }
}
2024/06/14 の次の営業日: 2024/06/17
2024/06/15 の次の営業日: 2024/06/17

金曜日の次の営業日は月曜日、土曜日の次の営業日も月曜日になるように調整しています。

コレクション補助

IsNullOrEmpty

IEnumerable<T>型の拡張メソッドで、コレクションがnullまたは空であるかを判定します。

nullチェックと空チェックをまとめて行えます。

using System;
using System.Collections.Generic;
using System.Linq;
public static class EnumerableExtensions
{
    // nullまたは空のコレクションかどうか判定
    public static bool IsNullOrEmpty<T>(this IEnumerable<T> source)
    {
        return source == null || !source.Any();
    }
}
class Program
{
    static void Main()
    {
        List<int> list1 = null;
        List<int> list2 = new List<int>();
        List<int> list3 = new List<int> { 1, 2, 3 };
        Console.WriteLine(list1.IsNullOrEmpty()); // True
        Console.WriteLine(list2.IsNullOrEmpty()); // True
        Console.WriteLine(list3.IsNullOrEmpty()); // False
    }
}
True
True
False

Any()は要素が1つでもあればtrueを返すため、空判定に使いやすいです。

インライン関数との違い

拡張メソッドとインライン関数(ラムダ式やローカル関数)は似ていますが、役割や使い方に違いがあります。

  • 拡張メソッドは既存の型に対してメソッドのように振る舞う機能を追加します。呼び出し側はインスタンスメソッドのように使えるため、コードの可読性や再利用性が高まります。複数のプロジェクトやクラスで共通して使いたい機能に向いています
  • インライン関数(ラムダ式やローカル関数)は、その場限りの処理や一時的な関数を定義するのに適しています。スコープが限定され、外部変数をキャプチャできるため柔軟ですが、再利用性は低いです

例えば、LINQのWhereに渡す条件はインライン関数で書くことが多いですが、Where自体は拡張メソッドです。

拡張メソッドは型のメソッドとして振る舞うため、API設計やライブラリ開発に適しており、インライン関数は処理の一部として使うイメージです。

用途に応じて使い分けると良いでしょう。

パフォーマンスと遅延実行のポイント

遅延実行の仕組み

LINQの拡張メソッドの多くは「遅延実行(Deferred Execution)」を採用しています。

遅延実行とは、クエリの定義時には実際の処理を行わず、結果が必要になったタイミングで初めて処理が実行される仕組みです。

例えば、WhereSelectなどのメソッドは、呼び出した時点では単に処理の定義を作成しているだけで、実際に要素を列挙するforeachなどが始まるまで処理は行われません。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 5);
        // ここではまだ処理は実行されていない
        var query = numbers.Where(n =>
        {
            Console.WriteLine($"Evaluating {n}");
            return n % 2 == 0;
        });
        Console.WriteLine("クエリ定義完了");
        // ここで初めて処理が実行される
        foreach (var num in query)
        {
            Console.WriteLine($"結果: {num}");
        }
    }
}
クエリ定義完了
Evaluating 1
Evaluating 2
結果: 2
Evaluating 3
Evaluating 4
結果: 4
Evaluating 5

この例では、Whereの条件が評価されるのはforeachが始まったタイミングです。

遅延実行により、無駄な処理を避けたり、条件を動的に変えたりする柔軟性が得られます。

即時実行メソッドとの比較

一方で、ToList(), ToArray(), Count(), Sum()などのメソッドは「即時実行(Immediate Execution)」です。

これらは呼び出された時点で全ての要素を列挙し、結果を計算・格納します。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 3);
        Console.WriteLine("ToList呼び出し前");
        var list = numbers.ToList(); // 即時実行
        Console.WriteLine("ToList呼び出し後");
        foreach (var n in list)
        {
            Console.WriteLine(n);
        }
    }
}
ToList呼び出し前
ToList呼び出し後
1
2
3

ToList()の呼び出し時にすべての要素が列挙されてリストに格納されます。

遅延実行のクエリを即時実行に変換する役割もあります。

遅延実行は柔軟ですが、複数回列挙すると同じ処理が繰り返されるため、パフォーマンスに影響する場合があります。

必要に応じて即時実行メソッドで結果をキャッシュすることが推奨されます。

多重列挙に関する注意

遅延実行のクエリは、IEnumerable<T>を複数回列挙すると、その都度処理が実行されます。

これを「多重列挙」と呼びます。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 3);
        var query = numbers.Where(n =>
        {
            Console.WriteLine($"Evaluating {n}");
            return n % 2 == 1;
        });
        Console.WriteLine("1回目の列挙");
        foreach (var n in query)
        {
            Console.WriteLine(n);
        }
        Console.WriteLine("2回目の列挙");
        foreach (var n in query)
        {
            Console.WriteLine(n);
        }
    }
}
1回目の列挙
Evaluating 1
1
Evaluating 2
Evaluating 3
3
2回目の列挙
Evaluating 1
1
Evaluating 2
Evaluating 3
3

このように、2回目の列挙でも同じ処理が繰り返されます。

処理が重い場合や副作用がある場合は注意が必要です。

多重列挙を避けたい場合は、ToList()ToArray()で結果をキャッシュしておくと良いでしょう。

メモリ効率とIEnumerable

IEnumerable<T>は遅延実行を活かして、必要な要素だけを順次処理するため、メモリ効率が良い特徴があります。

大量のデータを扱う場合でも、一度に全要素をメモリに読み込む必要がありません。

例えば、ファイルの行を読み込む際にIEnumerable<string>を使うと、1行ずつ処理できるためメモリ消費を抑えられます。

ただし、ToList()ToArray()で即時実行すると全要素がメモリに展開されるため、メモリ使用量が増加します。

また、LINQの中間操作は遅延実行でメモリ効率が良いですが、複雑なクエリや多重列挙があるとパフォーマンスに影響することもあります。

適切に即時実行を使い分け、必要に応じてキャッシュやバッファリングを検討してください。

非同期処理との連携

Taskベース拡張メソッド

C#の非同期処理はTaskTask<T>を使って表現されます。

LINQの拡張メソッドも非同期処理に対応するために、Taskを返すメソッドを拡張として実装することが増えています。

これにより、非同期の結果を待ちながら連鎖的に処理を行うことが可能です。

例えば、非同期に値を取得してから変換する拡張メソッドを作成する例を示します。

using System;
using System.Threading.Tasks;
public static class TaskExtensions
{
    // Task<T>の結果に対して非同期で変換処理を行う拡張メソッド
    public static async Task<TResult> MapAsync<TSource, TResult>(this Task<TSource> task, Func<TSource, TResult> selector)
    {
        var source = await task.ConfigureAwait(false);
        return selector(source);
    }
}
class Program
{
    static async Task Main()
    {
        Task<int> task = Task.FromResult(10);
        // 非同期の結果に対して変換を適用
        int result = await task.MapAsync(x => x * 2);
        Console.WriteLine(result); // 20
    }
}
20

このMapAsyncは、Task<TSource>の完了を待ち、結果に対して同期的な変換関数を適用してTask<TResult>を返します。

非同期処理の流れの中で使いやすい拡張メソッドです。

.NET標準ライブラリにはTask用のLINQ風の拡張メソッドは少ないため、独自に作成するケースも多いです。

Asyncパイプライン構築例

複数の非同期処理を連結してパイプラインを作る場合、拡張メソッドを活用するとコードがシンプルになります。

以下は非同期にデータを取得し、変換し、結果を出力する例です。

using System;
using System.Threading.Tasks;
public static class TaskExtensions
{
    public static async Task<TResult> MapAsync<TSource, TResult>(this Task<TSource> task, Func<TSource, TResult> selector)
    {
        var source = await task.ConfigureAwait(false);
        return selector(source);
    }
    public static async Task<TSource> TapAsync<TSource>(this Task<TSource> task, Action<TSource> action)
    {
        var source = await task.ConfigureAwait(false);
        action(source);
        return source;
    }
}
class Program
{
    static async Task<int> GetNumberAsync()
    {
        await Task.Delay(100);
        return 42;
    }
    static async Task Main()
    {
        int finalResult = await GetNumberAsync()
            .MapAsync(x => x * 2)
            .TapAsync(x => Console.WriteLine($"途中結果: {x}"))
            .MapAsync(x => x + 10);
        Console.WriteLine($"最終結果: {finalResult}");
    }
}
途中結果: 84
最終結果: 94

MapAsyncで値を変換し、TapAsyncで途中結果をログ出力しています。

これにより、非同期処理の流れをメソッドチェーンで表現でき、可読性が向上します。

非同期ストリームは大量データの逐次処理やリアルタイムデータ処理に適しており、LINQの非同期拡張メソッドと組み合わせることで効率的な非同期パイプラインを構築できます。

エラー処理とデバッグのコツ

例外の伝搬とキャッチ

LINQ拡張メソッドを使う際、条件式や変換関数内で例外が発生することがあります。

例えば、Selectの中でnull参照や計算エラーが起きる場合です。

こうした例外は呼び出し元に伝搬し、適切にキャッチしなければプログラムがクラッシュします。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 0, 2 };
        try
        {
            var results = numbers.Select(n => 10 / n);
            foreach (var r in results)
            {
                Console.WriteLine(r);
            }
        }
        catch (DivideByZeroException ex)
        {
            Console.WriteLine($"例外発生: {ex.Message}");
        }
    }
}
10
例外発生: Attempted to divide by zero.

この例では、0で割り算を行ったためDivideByZeroExceptionが発生し、try-catchで捕捉しています。

LINQの遅延実行により、例外は列挙時に発生します。

例外処理は列挙を開始する直前に行うのが一般的です。

Null対策パターン

LINQでnull参照が原因の例外を防ぐため、nullチェックを適切に行うことが重要です。

特に拡張メソッドの引数や条件式内でnullが混入している場合は注意が必要です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        List<string> words = null;
        // nullチェックをしないとNullReferenceExceptionになる
        if (words != null && words.Any())
        {
            var filtered = words.Where(w => w.Length > 3);
            foreach (var w in filtered)
            {
                Console.WriteLine(w);
            }
        }
        else
        {
            Console.WriteLine("wordsはnullか空です");
        }
    }
}
wordsはnullか空です

また、拡張メソッド側でArgumentNullExceptionを投げることも多いので、呼び出し前にnullチェックを行うか、IsNullOrEmptyのような補助メソッドを使うと安全です。

中間結果の確認方法

LINQのメソッドチェーンは遅延実行のため、途中の結果を直接見ることができません。

デバッグや動作確認のために中間結果を確認したい場合は、ToList()ToArray()で即時実行し、結果を変数に格納してから確認すると便利です。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 10);
        var filtered = numbers.Where(n => n % 2 == 0).ToList(); // 中間結果を取得
        Console.WriteLine("偶数のリスト:");
        filtered.ForEach(Console.WriteLine);
        var squared = filtered.Select(n => n * n).ToList();
        Console.WriteLine("偶数の二乗:");
        squared.ForEach(Console.WriteLine);
    }
}
偶数のリスト:
2
4
6
8
10
偶数の二乗:
4
16
36
64
100

Visual Studioのウォッチウィンドウや即時ウィンドウでも中間結果を確認できますが、コード内で明示的に即時実行することで確実に値を把握できます。

単一列挙を保証するテクニック

LINQの遅延実行クエリは複数回列挙すると処理が繰り返されるため、パフォーマンスや副作用の観点で問題になることがあります。

これを防ぐために、結果を一度だけ列挙してキャッシュする方法があります。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 3);
        var query = numbers.Select(n =>
        {
            Console.WriteLine($"処理中: {n}");
            return n * 2;
        });
        // ToListで一度だけ列挙し結果をキャッシュ
        var cached = query.ToList();
        Console.WriteLine("1回目の列挙");
        foreach (var n in cached)
        {
            Console.WriteLine(n);
        }
        Console.WriteLine("2回目の列挙");
        foreach (var n in cached)
        {
            Console.WriteLine(n);
        }
    }
}
処理中: 1
処理中: 2
処理中: 3
1回目の列挙
2
4
6
2回目の列挙
2
4
6

ToList()で結果をメモリに保持することで、2回目以降の列挙は再計算されず高速にアクセスできます。

副作用のある処理や重い計算を含む場合は、この方法で単一列挙を保証すると安全です。

コーディングパターン集

メソッドチェーンの可読性向上

LINQの拡張メソッドはメソッドチェーンで連結して使うことが多いですが、長くなると可読性が低下しやすいです。

可読性を高めるためのポイントを紹介します。

  • 改行とインデントを活用する

メソッドチェーンは各メソッド呼び出しごとに改行し、インデントを揃えると見やすくなります。

var result = numbers
    .Where(n => n % 2 == 0)
    .OrderBy(n => n)
    .Select(n => n * n)
    .ToList();
  • 意味のある変数名で中間結果を分割する

複雑な処理は途中で変数に代入し、処理の意図を明示すると理解しやすくなります。

var evenNumbers = numbers.Where(n => n % 2 == 0);
var sortedNumbers = evenNumbers.OrderBy(n => n);
var squaredNumbers = sortedNumbers.Select(n => n * n);
var result = squaredNumbers.ToList();
  • ラムダ式は簡潔に書く

複雑なロジックは別メソッドに切り出し、ラムダ式はシンプルに保つと読みやすいです。

bool IsEven(int n) => n % 2 == 0;
var result = numbers
    .Where(IsEven)
    .Select(n => n * n)
    .ToList();
  • コメントを適宜入れる

処理の意図や注意点をコメントで補足すると、後から見たときに理解しやすくなります。

これらの工夫でメソッドチェーンの可読性を向上させ、保守性の高いコードを書けます。

クエリ構文とのハイブリッド活用

C#のLINQにはメソッドチェーン形式の拡張メソッドと、SQL風のクエリ構文(クエリ式)があります。

両者は同じ機能を持ちますが、状況に応じて使い分けると効果的です。

  • クエリ構文の特徴

複雑な結合やグループ化、ネストしたクエリを記述する際に読みやすくなります。

var query = from n in numbers
            where n % 2 == 0
            orderby n
            select n * n;
  • メソッドチェーンの特徴

単純な変換やフィルタリング、メソッドの連結が直感的で、ラムダ式を使った柔軟な処理が書きやすいです。

  • ハイブリッド活用例

クエリ構文で大まかな処理を記述し、最後にメソッドチェーンで細かい変換や集計を行うことも可能です。

var query = from n in numbers
            where n > 0
            select n;
var result = query
    .Where(n => n % 2 == 0)
    .Select(n => n * n)
    .ToList();
  • 注意点

クエリ構文は内部的に拡張メソッドに変換されるため、パフォーマンスはほぼ同じです。

好みやチームのコーディング規約に合わせて使い分けると良いでしょう。

他言語のストリームAPIとの比較

LINQはJavaのStream APIやJavaScriptの配列メソッドなど、他言語のストリーム処理と似た機能を持ちますが、いくつか特徴的な違いがあります。

項目C# LINQJava Stream APIJavaScript Array Methods
遅延実行あり(IEnumerable<T>ベース)ありなし(ほとんど即時実行)
データ型ジェネリック型ジェネリック型動的型付け
非同期対応IAsyncEnumerable<T>ありCompletableFutureなど別途対応Promiseベースの非同期処理が別途必要
メソッドチェーン豊富で直感的豊富で直感的豊富で直感的
クエリ構文あり(SQL風クエリ式)なしなし
拡張性拡張メソッドで簡単に追加可能独自メソッドの追加はやや複雑関数を自由に渡せる
  • 遅延実行の違い

LINQとJava Streamは遅延実行を基本とし、パイプラインの最後で処理が実行されます。

JavaScriptの配列メソッドは即時実行が多く、パフォーマンスやメモリ効率の面で差があります。

  • 非同期処理の扱い

C#はIAsyncEnumerable<T>で非同期ストリームをサポートし、JavaScriptはPromiseasync/awaitで非同期処理を行います。

JavaはCompletableFutureなど別のAPIで非同期を扱います。

  • クエリ構文の有無

C#はSQL風のクエリ構文があり、複雑なクエリを直感的に書けます。

JavaやJavaScriptはメソッドチェーンのみです。

これらの違いを理解し、言語の特性に合わせて最適なストリーム処理を選択すると良いでしょう。

よくある落とし穴

遅延実行と副作用

LINQの拡張メソッドは多くが遅延実行で動作します。

つまり、クエリを定義した時点では処理は実行されず、実際に列挙が始まったときに初めて処理が行われます。

この特性は柔軟性を高めますが、副作用を伴う処理を含む場合に思わぬ問題を引き起こすことがあります。

例えば、以下のコードを見てください。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static int counter = 0;
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3 };
        var query = numbers.Select(n =>
        {
            counter++;
            return n * 2;
        });
        Console.WriteLine("クエリ定義完了");
        Console.WriteLine($"counter: {counter}");
        foreach (var item in query)
        {
            Console.WriteLine(item);
        }
        Console.WriteLine($"counter: {counter}");
    }
}
クエリ定義完了
counter: 0
2
4
6
counter: 3

この例では、Selectの中でcounterをインクリメントしていますが、クエリ定義時には実行されず、foreachで列挙が始まったタイミングで初めて実行されます。

副作用を伴う処理は遅延実行の影響を受けるため、予期しないタイミングで実行されることに注意が必要です。

また、クエリを複数回列挙すると副作用も複数回発生します。

副作用を含む処理は避けるか、ToList()などで結果をキャッシュして一度だけ実行することが推奨されます。

外部変数キャプチャの罠

LINQのラムダ式内で外部変数を参照すると、その変数が「キャプチャ」されます。

これにより、変数の値が変わるとクエリの動作も変わるため、意図しない結果になることがあります。

以下の例を見てみましょう。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };
        int threshold = 3;
        var query = numbers.Where(n => n > threshold);
        threshold = 4;
        foreach (var n in query)
        {
            Console.WriteLine(n);
        }
    }
}
5

この例では、thresholdの値を3から4に変更しています。

Whereの条件はn > thresholdですが、クエリは遅延実行なので、列挙時のthresholdの値(4)が使われます。

そのため、3より大きい要素ではなく4より大きい要素が抽出されます。

このように、外部変数をキャプチャすると変数の変更がクエリの結果に影響するため、意図しない動作を招くことがあります。

対策としては、キャプチャする値をローカル変数にコピーして使う方法があります。

int localThreshold = threshold;
var query = numbers.Where(n => n > localThreshold);

順序保証に関する誤解

LINQの拡張メソッドの中には、元のシーケンスの順序を保持するものと、順序を保証しないものがあります。

順序を保証しないメソッドを使うと、結果の順序が予想と異なる場合があるため注意が必要です。

例えば、WhereSelectは元の順序を保持しますが、DistinctGroupByは順序を保証しません。

using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 3, 1, 2, 3, 2, 1 };
        var distinct = numbers.Distinct();
        foreach (var n in distinct)
        {
            Console.WriteLine(n);
        }
    }
}
3
1
2

この例では、Distinctは元の順序を保持しているように見えますが、仕様上は順序保証がありません。

実装によっては異なる順序になる可能性があります。

また、GroupByもグループの順序やグループ内の要素の順序を保証しません。

順序を明確にしたい場合は、OrderByOrderByDescendingで明示的に並べ替えを行うことが重要です。

var distinctOrdered = numbers.Distinct().OrderBy(n => n);

このように、順序保証の有無を理解し、必要に応じて明示的に順序を指定することで、予期しない順序の問題を防げます。

バージョン別トピック

C# 3.0で導入された要素

C# 3.0は2007年にリリースされ、LINQ(Language Integrated Query)が初めて導入されたバージョンです。

LINQはデータ操作を言語に統合し、コレクションやデータベース、XMLなどを統一的に扱えるようにしました。

C# 3.0での主なLINQ関連の要素は以下の通りです。

  • 拡張メソッド

静的クラスの静的メソッドにthisキーワードを付けて、既存の型にメソッドを追加できる機能。

LINQの多くのメソッドは拡張メソッドとして実装されています。

  • 匿名型

名前のない型を簡単に作成できる機能。

LINQのselect句で複数の値をまとめて返す際に使われます。

  • ラムダ式

簡潔に無名関数を記述できる構文。

LINQの条件や変換関数に頻繁に使われます。

  • クエリ構文(クエリ式)

SQLに似た構文でLINQクエリを記述可能です。

fromwhereselectなどのキーワードを使います。

  • 標準クエリ演算子(Standard Query Operators)

WhereSelectOrderByGroupByJoinなどの拡張メソッド群。

IEnumerable<T>を操作するための基本的なメソッドが揃いました。

これらの要素により、C# 3.0はLINQを中心としたデータ操作のパラダイムシフトを実現しました。

C# 6.0以降の追加メソッド

C# 6.0(2015年リリース)以降、LINQ自体の大きな仕様変更は少ないものの、.NET Frameworkや.NET CoreのライブラリにおいてLINQ拡張メソッドの追加や改善が行われています。

特に以下のようなメソッドが追加されました。

  • ElementAtOrDefault / FirstOrDefault / SingleOrDefaultの強化

これらのメソッドは条件に合う要素がない場合にデフォルト値を返すため、例外を避ける安全なアクセス手段として広く使われています。

  • Zipメソッドの追加

2つのシーケンスを要素ごとに結合し、新しいシーケンスを作成するメソッド。

C# 6.0の頃に.NET Framework 4.0で導入されました。

  • Concatの改善

複数のシーケンスを連結するConcatメソッドのパフォーマンスや使い勝手が向上しました。

  • DefaultIfEmptyの利用促進

空のシーケンスに対してデフォルト値を返すメソッドで、条件付きの集計や結合で役立ちます。

また、C# 6.0以降は言語機能の強化(例:文字列補間、null条件演算子など)により、LINQの記述がより簡潔で安全になりました。

.NET 6以降の最新改善点

.NET 6(2021年リリース)以降は、パフォーマンスの向上や非同期処理の強化を中心にLINQ関連の改善が進んでいます。

  • IAsyncEnumerable<T>の標準サポート

C# 8.0で導入された非同期ストリームIAsyncEnumerable<T>が.NET 6でさらに成熟し、LINQの非同期拡張メソッド(WhereAsyncSelectAsyncなど)が公式に提供されるようになりました。

これにより、非同期データの逐次処理がより簡単かつ効率的になりました。

  • パフォーマンス最適化

LINQの内部実装が見直され、特にWhereSelectなどの中間演算子のオーバーヘッドが削減されました。

これにより、大量データの処理が高速化しています。

  • TryGetNonEnumeratedCountの追加

コレクションの要素数を効率的に取得するためのメソッドが追加され、Count()呼び出し時のパフォーマンス改善に寄与しています。

  • Chunkメソッドの追加

大きなシーケンスを指定したサイズのチャンク(小分割)に分割するメソッドが追加され、バッチ処理や分割処理が簡単になりました。

  • DistinctByUnionByの追加

キーを指定して重複排除や和集合を行うメソッドが追加され、より柔軟なセット演算が可能になりました。

  • ソースジェネレーターとの連携強化

LINQクエリのパフォーマンスを向上させるために、ソースジェネレーターを活用した最適化が進んでいます。

これらの改善により、最新の.NET環境ではLINQがより高速かつ柔軟に使えるようになっています。

開発者は新しいメソッドや非同期対応を積極的に活用することで、効率的なデータ処理を実現できます。

まとめ

この記事では、C#のLINQ拡張メソッドの基本構造から代表的なメソッドの使い方、カスタム拡張メソッドの作成方法、パフォーマンスや非同期処理との連携、エラー処理、コーディングパターン、よくある落とし穴、そしてバージョンごとの進化まで幅広く解説しました。

LINQの遅延実行や拡張メソッドの仕組みを理解し、適切に活用することで、効率的で読みやすいコードが書けるようになります。

最新の.NET環境での新機能も押さえ、実践的なスキル向上に役立ててください。

関連記事

Back to top button
目次へ