LINQ

【C#】LINQでマスターするデータ集計テクニック:SumからGroupBy・Aggregateまで

C#のLINQは配列やコレクションをSQLライクに扱い、SumAverageなどの標準メソッド、GroupBySelectの組み合わせ、自由度の高いAggregateで一度に複数集計を実現できます。

宣言的記述で可読性と保守性が向上し、ループよりも記述量を削減しながら性能も確保できます。

目次から探す
  1. LINQによるデータ集計の基本
  2. GroupByで実現する多次元集計
  3. Aggregateでカスタム集計を作成
  4. SelectManyとZipを組み合わせた派生集計
  5. Null値と例外を防ぐ安全な集計
  6. カスタムIEqualityComparerでキー比較を制御
  7. 実行タイミングとパフォーマンス
  8. IEnumerableとIQueryableの違い
  9. 典型的なユースケースサンプル
  10. よくある落とし穴と対策
  11. まとめ

LINQによるデータ集計の基本

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

ここでは、LINQの基本的な集計メソッドの種類や使い方、そしてメソッド構文とクエリ構文の違いについて詳しく解説します。

集計メソッドの種類

LINQには、データの集計を行うための標準的なメソッドがいくつか用意されています。

代表的なものにCountLongCountSumMinMaxAverageがあります。

これらは数値データの合計や平均、最大値・最小値、要素数の取得などに使われます。

以下でそれぞれの特徴や注意点を説明します。

CountとLongCountの違い

Countメソッドは、シーケンス内の要素数を取得するために使います。

戻り値はint型で、最大で約21億(2,147,483,647)までの要素数を扱えます。

通常のコレクションであればCountで十分ですが、非常に大きなデータセットを扱う場合はLongCountを使うことが推奨されます。

LongCountは戻り値がlong型(64ビット整数)で、より大きな要素数をカウントできます。

例えば、巨大なログファイルの行数や大規模なデータベースのレコード数を扱う際に役立ちます。

以下はCountLongCountの使い分け例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        int count = numbers.Count();
        long longCount = numbers.LongCount();
        Console.WriteLine($"Count: {count}");
        Console.WriteLine($"LongCount: {longCount}");
    }
}
Count: 5
LongCount: 5

この例では要素数が少ないため両者の結果は同じですが、要素数が非常に多い場合はLongCountを使うことでオーバーフローを防げます。

Sumの返却型とオーバーロード

Sumメソッドは、数値の合計を計算します。

LINQのSumは複数のオーバーロードが用意されており、intlongfloatdoubledecimalなどの型に対応しています。

返却型は入力の型に依存します。

例えば、int配列に対してSumを呼び出すとint型の合計が返りますが、decimal型のシーケンスに対してはdecimal型の合計が返ります。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] intNumbers = { 1, 2, 3 };
        decimal[] decimalNumbers = { 1.5m, 2.5m, 3.0m };
        int intSum = intNumbers.Sum();
        decimal decimalSum = decimalNumbers.Sum();
        Console.WriteLine($"intの合計: {intSum}");
        Console.WriteLine($"decimalの合計: {decimalSum}");
    }
}
intの合計: 6
decimalの合計: 7.0

また、Sumはプロジェクション関数を受け取るオーバーロードもあります。

例えば、オブジェクトのリストから特定の数値プロパティの合計を求める場合に使います。

var products = new[]
{
    new { Name = "リンゴ", Price = 100 },
    new { Name = "バナナ", Price = 150 },
    new { Name = "オレンジ", Price = 120 }
};
int totalPrice = products.Sum(p => p.Price);
Console.WriteLine($"合計金額: {totalPrice}");
合計金額: 370

MinとMaxのカスタムセレクタ

MinMaxはシーケンスの最小値・最大値を取得するメソッドです。

これらもSum同様に、数値だけでなく文字列や日付など比較可能な型に対応しています。

また、プロジェクション関数を指定して、オブジェクトの特定のプロパティを基準に最小値・最大値を求めることができます。

var employees = new[]
{
    new { Name = "佐藤", Age = 28 },
    new { Name = "鈴木", Age = 35 },
    new { Name = "高橋", Age = 22 }
};
int minAge = employees.Min(e => e.Age);
int maxAge = employees.Max(e => e.Age);
Console.WriteLine($"最年少: {minAge}歳");
Console.WriteLine($"最高年齢: {maxAge}歳");
最年少: 22歳
最高年齢: 35歳

注意点として、MinMaxは空のシーケンスに対して呼び出すと例外が発生します。

空の可能性がある場合は事前にチェックするか、DefaultIfEmptyでデフォルト値を設定してください。

Averageの精度と型変換

Averageはシーケンスの平均値を計算します。

返却型は入力の型に依存しますが、整数型のシーケンスに対してはdouble型の平均値が返る点に注意が必要です。

例えば、int配列の平均を求めるとdouble型の結果が返ります。

int[] scores = { 70, 80, 90 };
double average = scores.Average();
Console.WriteLine($"平均点: {average}");
平均点: 80

また、Averageもプロジェクション関数を受け取るオーバーロードがあり、オブジェクトの数値プロパティの平均を簡単に求められます。

var sales = new[]
{
    new { Product = "A", Amount = 1000 },
    new { Product = "B", Amount = 1500 },
    new { Product = "C", Amount = 1200 }
};
double averageAmount = sales.Average(s => s.Amount);
Console.WriteLine($"平均売上: {averageAmount}");
平均売上: 1233.3333333333333

空のシーケンスに対してAverageを呼ぶと例外が発生するため、空チェックやDefaultIfEmptyの利用を検討してください。

Method SyntaxとQuery Syntaxの比較

LINQには2つの記法スタイルがあります。

1つはメソッドチェーンを使う「メソッド構文(Method Syntax)」、もう1つはSQLに似た「クエリ構文(Query Syntax)」です。

どちらも同じ処理を実現できますが、書き方や可読性に違いがあります。

メソッド構文(Method Syntax)

メソッド構文は、WhereSelectGroupByなどの拡張メソッドを連結して記述します。

柔軟で直感的に書けるため、実務でよく使われます。

int[] numbers = { 1, 2, 3, 4, 5 };
int sum = numbers.Where(n => n % 2 == 0).Sum();
Console.WriteLine($"偶数の合計: {sum}");
偶数の合計: 6

この例では、偶数だけを抽出して合計を計算しています。

クエリ構文(Query Syntax)

クエリ構文は、SQLのSELECT文に似た構文で、読みやすさを重視したい場合に適しています。

特に複雑な結合やグループ化を行う際に見やすくなります。

int[] numbers = { 1, 2, 3, 4, 5 };
var evenNumbers = from n in numbers
                  where n % 2 == 0
                  select n;
int sum = evenNumbers.Sum();
Console.WriteLine($"偶数の合計: {sum}");
偶数の合計: 6

使い分けのポイント

  • メソッド構文は、ラムダ式を使った細かい制御やメソッドチェーンの連結が得意で、柔軟に書けます
  • クエリ構文は、SQLに慣れている方にとって直感的で、複雑なクエリを読みやすく記述できます

どちらもコンパイル時に同じILコードに変換されるため、パフォーマンスに差はありません。

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

GroupByで実現する多次元集計

単一キーでの基本的なグループ化

GroupByメソッドは、指定したキーに基づいてシーケンスの要素をグループ化します。

単一のキーでグループ化する場合、キーの型はプリミティブ型や文字列、列挙型などが一般的です。

グループ化した後は、各グループに対して集計や加工を行うことができます。

以下は、学生の成績を科目ごとにグループ化し、各科目の平均点を求める例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var students = new[]
        {
            new { Name = "田中", Subject = "数学", Score = 80 },
            new { Name = "佐藤", Subject = "英語", Score = 90 },
            new { Name = "鈴木", Subject = "数学", Score = 85 },
            new { Name = "高橋", Subject = "英語", Score = 88 }
        };
        var groupedBySubject = students
            .GroupBy(s => s.Subject)
            .Select(g => new
            {
                Subject = g.Key,
                AverageScore = g.Average(s => s.Score)
            });
        foreach (var group in groupedBySubject)
        {
            Console.WriteLine($"科目: {group.Subject}, 平均点: {group.AverageScore}");
        }
    }
}
科目: 数学, 平均点: 82.5
科目: 英語, 平均点: 89

この例では、GroupBySubjectをキーにグループ化し、各グループのScoreの平均を計算しています。

g.Keyでグループのキーにアクセスでき、gはそのキーに属する要素のシーケンスです。

複合キーを使ったグループ化

複数のプロパティを組み合わせてグループ化したい場合、複合キーを使います。

C#では匿名型やTupleをキーとして利用できます。

複合キーを使うことで、より細かい条件でグループ化が可能です。

匿名型キー活用

匿名型を使うと、複数のプロパティをまとめてキーにできます。

匿名型はEqualsGetHashCodeが自動的に適切に実装されているため、グループ化に便利です。

以下は、学生の成績を科目と名前の頭文字でグループ化し、各グループの平均点を求める例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var students = new[]
        {
            new { Name = "田中", Subject = "数学", Score = 80 },
            new { Name = "佐藤", Subject = "英語", Score = 90 },
            new { Name = "鈴木", Subject = "数学", Score = 85 },
            new { Name = "高橋", Subject = "英語", Score = 88 }
        };
        var grouped = students
            .GroupBy(s => new { s.Subject, Initial = s.Name.Substring(0, 1) })
            .Select(g => new
            {
                Subject = g.Key.Subject,
                Initial = g.Key.Initial,
                AverageScore = g.Average(s => s.Score)
            });
        foreach (var group in grouped)
        {
            Console.WriteLine($"科目: {group.Subject}, 名前の頭文字: {group.Initial}, 平均点: {group.AverageScore}");
        }
    }
}
科目: 数学, 名前の頭文字: 田, 平均点: 80
科目: 英語, 名前の頭文字: 佐, 平均点: 90
科目: 数学, 名前の頭文字: 鈴, 平均点: 85
科目: 英語, 名前の頭文字: 高, 平均点: 88

匿名型キーを使うことで、複数の条件をまとめてグループ化できます。

キーの各プロパティはg.Keyからアクセス可能です。

Tupleキー利用

C# 7.0以降では、ValueTupleをキーに使うこともできます。

ValueTupleは軽量で構造化された複合キーを作成でき、匿名型と同様にEqualsGetHashCodeが適切に実装されています。

以下は、匿名型の代わりにValueTupleを使った例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var students = new[]
        {
            new { Name = "田中", Subject = "数学", Score = 80 },
            new { Name = "佐藤", Subject = "英語", Score = 90 },
            new { Name = "鈴木", Subject = "数学", Score = 85 },
            new { Name = "高橋", Subject = "英語", Score = 88 }
        };
        var grouped = students
            .GroupBy(s => (s.Subject, Initial: s.Name.Substring(0, 1)))
            .Select(g => new
            {
                Subject = g.Key.Subject,
                Initial = g.Key.Initial,
                AverageScore = g.Average(s => s.Score)
            });
        foreach (var group in grouped)
        {
            Console.WriteLine($"科目: {group.Subject}, 名前の頭文字: {group.Initial}, 平均点: {group.AverageScore}");
        }
    }
}
科目: 数学, 名前の頭文字: 田, 平均点: 80
科目: 英語, 名前の頭文字: 佐, 平均点: 90
科目: 数学, 名前の頭文字: 鈴, 平均点: 85
科目: 英語, 名前の頭文字: 高, 平均点: 88

ValueTupleは匿名型よりも軽量で、メソッドの引数や戻り値としても使いやすい特徴があります。

グループ化のキーとしても問題なく利用できます。

事前絞り込みとPost Group Filter

グループ化の前後で絞り込みを行うことが多いです。

Whereメソッドを使って、グループ化前に対象データを絞り込むことができます。

また、グループ化後にWhereを使って特定のグループだけを抽出することも可能です。

以下は、数学の科目だけを事前に絞り込み、さらに平均点が85点以上のグループだけを抽出する例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var students = new[]
        {
            new { Name = "田中", Subject = "数学", Score = 80 },
            new { Name = "佐藤", Subject = "英語", Score = 90 },
            new { Name = "鈴木", Subject = "数学", Score = 85 },
            new { Name = "高橋", Subject = "英語", Score = 88 }
        };
        var filteredGroups = students
            .Where(s => s.Subject == "数学") // 事前絞り込み
            .GroupBy(s => s.Subject)
            .Select(g => new
            {
                Subject = g.Key,
                AverageScore = g.Average(s => s.Score)
            })
            .Where(g => g.AverageScore >= 75); // グループ後の絞り込み
        foreach (var group in filteredGroups)
        {
            Console.WriteLine($"科目: {group.Subject}, 平均点: {group.AverageScore}");
        }
    }
}
科目: 数学, 平均点: 82.5

この例では、事前に数学の科目だけを抽出していますが、平均点が85点以上のグループは存在しないため結果は空になります。

条件を変えると結果が変わるため、絞り込みのタイミングを意識して使い分けることが重要です。

キーなしのグループ化とLookup

GroupByは通常キーを指定してグループ化しますが、キーを指定しない場合はすべての要素が1つのグループにまとめられます。

これはあまり使われませんが、Lookupを使うとキーと要素の対応を効率的に管理できます。

ToLookupメソッドはGroupByと似ていますが、即時実行され、キーに基づく高速な検索が可能なコレクションを生成します。

以下は、ToLookupを使って科目ごとに学生を分類し、特定の科目の学生を取得する例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var students = new[]
        {
            new { Name = "田中", Subject = "数学", Score = 80 },
            new { Name = "佐藤", Subject = "英語", Score = 90 },
            new { Name = "鈴木", Subject = "数学", Score = 85 },
            new { Name = "高橋", Subject = "英語", Score = 88 }
        };
        var lookup = students.ToLookup(s => s.Subject);
        Console.WriteLine("数学の学生:");
        foreach (var student in lookup["数学"])
        {
            Console.WriteLine($"名前: {student.Name}, 点数: {student.Score}");
        }
    }
}
数学の学生:
名前: 田中, 点数: 80
名前: 鈴木, 点数: 85

Lookupはキーでの高速アクセスが必要な場合に便利です。

GroupByは遅延実行で、結果を列挙するたびに再評価されますが、Lookupは即時実行で一度作成すると高速にアクセスできます。

用途に応じて使い分けてください。

Aggregateでカスタム集計を作成

初期シード値の考え方

Aggregateメソッドは、シーケンスの要素を1つずつ処理しながら累積的に結果を生成するための強力なメソッドです。

Aggregateを使う際に重要なのが「初期シード値(seed)」の設定です。

初期シード値は、集計処理の開始点となる値であり、適切に設定しないと正しい結果が得られません。

例えば、数値の合計を求める場合は初期シード値を0に設定します。

文字列の連結なら空文字列""が初期値になります。

最小値や最大値を求める場合は、int.MaxValueint.MinValueなど、極端な値を初期値に設定することが多いです。

以下は、Aggregateで合計を求める例です。

初期値を0に設定しています。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        int sum = numbers.Aggregate(0, (acc, x) => acc + x);
        Console.WriteLine($"合計: {sum}");
    }
}
合計: 15

初期値を設定しないオーバーロードもありますが、空のシーケンスに対しては例外が発生するため、空の可能性がある場合は初期値を必ず指定してください。

ValueTupleで複数指標を同時集計

複数の集計指標を同時に求めたい場合、ValueTupleを使うと便利です。

Aggregateの累積値としてValueTupleを使うことで、1回の走査で複数の値を計算できます。

これによりパフォーマンスが向上し、コードもすっきりします。

以下は、最小値、最大値、合計、要素数、平均値を同時に求める例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        var result = numbers.Aggregate(
            (Min: int.MaxValue, Max: int.MinValue, Sum: 0, Count: 0),
            (acc, x) => (
                Min: x < acc.Min ? x : acc.Min,
                Max: x > acc.Max ? x : acc.Max,
                Sum: acc.Sum + x,
                Count: acc.Count + 1
            ),
            acc => (
                acc.Min,
                acc.Max,
                acc.Sum,
                acc.Count,
                Average: acc.Count > 0 ? (double)acc.Sum / acc.Count : 0
            )
        );
        Console.WriteLine($"最小値: {result.Min}, 最大値: {result.Max}, 合計: {result.Sum}, 要素数: {result.Count}, 平均値: {result.Average}");
    }
}
最小値: 1, 最大値: 5, 合計: 15, 要素数: 5, 平均値: 3

この例では、初期値としてMinint.MaxValueMaxint.MinValueを設定し、SumCount0からスタートしています。

集計処理の最後に平均値を計算して返しています。

中間状態オブジェクトの設計

複雑な集計処理では、中間状態を表すオブジェクトを設計して管理するとコードが分かりやすくなります。

ValueTupleの代わりにクラスや構造体を使うことも多いです。

特に複数の集計指標や状態を持つ場合は、専用のクラスを作成して状態を管理すると保守性が向上します。

以下は、中間状態を表すクラスを使った例です。

using System;
using System.Linq;
class Stats
{
    public int Min { get; set; } = int.MaxValue;
    public int Max { get; set; } = int.MinValue;
    public int Sum { get; set; } = 0;
    public int Count { get; set; } = 0;
    public double Average => Count > 0 ? (double)Sum / Count : 0;
}
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        var stats = numbers.Aggregate(
            new Stats(),
            (acc, x) =>
            {
                if (x < acc.Min) acc.Min = x;
                if (x > acc.Max) acc.Max = x;
                acc.Sum += x;
                acc.Count++;
                return acc;
            }
        );
        Console.WriteLine($"最小値: {stats.Min}, 最大値: {stats.Max}, 合計: {stats.Sum}, 要素数: {stats.Count}, 平均値: {stats.Average}");
    }
}
最小値: 1, 最大値: 5, 合計: 15, 要素数: 5, 平均値: 3

この例では、Statsクラスで集計の中間状態を管理しています。

Aggregateの累積値としてStatsのインスタンスを使い、各要素を処理しながら状態を更新しています。

平均値はプロパティで計算しています。

出力変換関数の活用

Aggregateメソッドには、3つ目の引数として「出力変換関数(result selector)」を指定できるオーバーロードがあります。

これを使うと、累積処理の結果を最終的に別の型や形式に変換して返せます。

例えば、先ほどのValueTupleを使った例で、最終的に匿名型で結果を返すようにできます。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        var result = numbers.Aggregate(
            (Min: int.MaxValue, Max: int.MinValue, Sum: 0, Count: 0),
            (acc, x) => (
                Min: x < acc.Min ? x : acc.Min,
                Max: x > acc.Max ? x : acc.Max,
                Sum: acc.Sum + x,
                Count: acc.Count + 1
            ),
            acc => new
            {
                Min = acc.Min,
                Max = acc.Max,
                Sum = acc.Sum,
                Count = acc.Count,
                Average = acc.Count > 0 ? (double)acc.Sum / acc.Count : 0
            }
        );
        Console.WriteLine($"最小値: {result.Min}, 最大値: {result.Max}, 合計: {result.Sum}, 要素数: {result.Count}, 平均値: {result.Average}");
    }
}
最小値: 1, 最大値: 5, 合計: 15, 要素数: 5, 平均値: 3

このように、累積処理の結果を別の型に変換して返すことで、呼び出し側で扱いやすい形に整形できます。

特に複雑な集計処理では、出力変換関数を活用して結果の表現を柔軟に変更すると便利です。

SelectManyとZipを組み合わせた派生集計

ネストコレクションのフラット化

LINQのSelectManyメソッドは、ネストされたコレクションを1つのフラットなシーケンスに変換するために使います。

例えば、複数のオブジェクトがそれぞれ複数の子要素を持つ場合に、すべての子要素を一括で処理したいときに便利です。

以下は、クラスごとに複数の学生がいるデータをフラット化し、全学生の名前を一覧表示する例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var classes = new[]
        {
            new
            {
                ClassName = "クラスA",
                Students = new[] { "田中", "佐藤" }
            },
            new
            {
                ClassName = "クラスB",
                Students = new[] { "鈴木", "高橋", "伊藤" }
            }
        };
        var allStudents = classes.SelectMany(c => c.Students);
        Console.WriteLine("全学生の名前:");
        foreach (var student in allStudents)
        {
            Console.WriteLine(student);
        }
    }
}
全学生の名前:
田中
佐藤
鈴木
高橋
伊藤

この例では、SelectManyが各クラスのStudents配列を展開し、すべての学生名を1つのシーケンスにまとめています。

Selectを使うとクラスごとの学生配列のままですが、SelectManyを使うことでフラットな一覧が得られます。

異なるシーケンスの同期集計

Zipメソッドは、2つのシーケンスの要素をペアにして結合し、同時に処理するために使います。

要素数が異なる場合は、短い方のシーケンスの長さに合わせて処理が行われます。

例えば、売上データと目標データが別々のシーケンスにある場合に、両者を同期させて差分や達成率を計算できます。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] sales = { 100, 150, 120, 130 };
        int[] targets = { 90, 160, 110, 140 };
        var results = sales.Zip(targets, (sale, target) => new
        {
            Sale = sale,
            Target = target,
            Difference = sale - target,
            AchievementRate = target != 0 ? (double)sale / target * 100 : 0
        });
        Console.WriteLine("売上と目標の比較:");
        foreach (var r in results)
        {
            Console.WriteLine($"売上: {r.Sale}, 目標: {r.Target}, 差分: {r.Difference}, 達成率: {r.AchievementRate:F2}%");
        }
    }
}
売上と目標の比較:
売上: 100, 目標: 90, 差分: 10, 達成率: 111.11%
売上: 150, 目標: 160, 差分: -10, 達成率: 93.75%
売上: 120, 目標: 110, 差分: 10, 達成率: 109.09%
売上: 130, 目標: 140, 差分: -10, 達成率: 92.86%

この例では、Zipで売上と目標の値をペアにし、差分や達成率を計算しています。

Zipを使うことで、2つの異なるシーケンスを同期させて効率的に集計できます。

SelectManyZipを組み合わせることで、ネストされたデータのフラット化と異なるシーケンスの同期処理を柔軟に行えます。

例えば、複数のクラスの学生ごとの成績と目標点を同期させて比較するようなシナリオにも応用可能です。

Null値と例外を防ぐ安全な集計

DefaultIfEmptyによる初期値設定

LINQの集計メソッド(SumAverageMinMaxなど)は、空のシーケンスに対して呼び出すとInvalidOperationExceptionをスローします。

これを防ぐために、DefaultIfEmptyメソッドを使って空シーケンスに対してデフォルト値を設定する方法があります。

DefaultIfEmptyは、シーケンスが空の場合に指定したデフォルト値を返すシーケンスに置き換えます。

これにより、集計メソッドが空シーケンスに対して例外を投げるのを防げます。

以下は、空の整数配列に対してSumを安全に呼び出す例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] emptyNumbers = { };
        // 空シーケンスに対して0を初期値として設定
        int sum = emptyNumbers.DefaultIfEmpty(0).Sum();
        Console.WriteLine($"合計: {sum}");
    }
}
合計: 0

この例では、空の配列に対してDefaultIfEmpty(0)を適用し、Sumが0を返すようにしています。

AverageMinMaxでも同様に使えます。

nullable型と集計メソッド

LINQの集計メソッドは、int?double?などのnullable型にも対応しています。

nullable型のシーケンスに対してSumAverageを呼ぶと、null値は無視され、非nullの値だけで集計が行われます。

例えば、以下のようにint?型の配列にnullが混在している場合でも安全に合計や平均を計算できます。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int?[] numbers = { 1, null, 3, null, 5 };
        int? sum = numbers.Sum();
        double? average = numbers.Average();
        Console.WriteLine($"合計: {sum}");
        Console.WriteLine($"平均: {average}");
    }
}
合計: 9
平均: 3

この例では、nullの要素は集計から除外され、合計は9、平均は3となっています。

すべての要素がnullの場合は、Sumnullを返し、Averageは例外をスローするため注意が必要です。

DivideByZeroの回避

Averageメソッドは内部で合計を要素数で割るため、要素数が0の場合にDivideByZeroExceptionが発生することはありませんが、カスタム集計や手動で平均を計算する場合はゼロ除算に注意が必要です。

例えば、Aggregateや手動で平均を計算する際は、要素数が0の場合に0やnullなどの適切な値を返すようにガード処理を入れます。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] emptyNumbers = { };
        int sum = emptyNumbers.Sum();
        int count = emptyNumbers.Count();
        double average = count > 0 ? (double)sum / count : 0;
        Console.WriteLine($"合計: {sum}");
        Console.WriteLine($"要素数: {count}");
        Console.WriteLine($"平均: {average}");
    }
}
合計: 0
要素数: 0
平均: 0

この例では、要素数が0の場合に平均を0に設定してゼロ除算を回避しています。

カスタム集計やAggregateを使う場合も同様に、割り算の前に要素数をチェックすることが重要です。

カスタムIEqualityComparerでキー比較を制御

大文字小文字を無視した文字列集計

LINQのGroupByDistinctなどのメソッドは、デフォルトでキーの比較にEqualsGetHashCodeを使います。

文字列の場合は大文字小文字を区別するため、”apple”と”Apple”は別のキーとして扱われます。

大文字小文字を無視して集計したい場合は、IEqualityComparer<string>を実装したカスタムコンパレータを渡す必要があります。

.NETには大文字小文字を無視する比較を行うStringComparer.OrdinalIgnoreCaseStringComparer.InvariantCultureIgnoreCaseなどの組み込みのIEqualityComparer<string>が用意されています。

これらを使うと簡単に大文字小文字を無視したグループ化が可能です。

以下は、GroupByで大文字小文字を無視して文字列をグループ化し、各グループの要素数を数える例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        string[] fruits = { "Apple", "apple", "Banana", "BANANA", "banana", "Cherry" };
        var grouped = fruits
            .GroupBy(f => f, StringComparer.OrdinalIgnoreCase)
            .Select(g => new { Key = g.Key, Count = g.Count() });
        foreach (var group in grouped)
        {
            Console.WriteLine($"果物: {group.Key}, 個数: {group.Count}");
        }
    }
}
果物: Apple, 個数: 2
果物: Banana, 個数: 3
果物: Cherry, 個数: 1

この例では、StringComparer.OrdinalIgnoreCaseGroupByの第2引数に渡すことで、大文字小文字を区別せずにグループ化しています。

結果として”Apple”と”apple”が同じグループにまとめられています。

マニュアルハッシュとEquals実装

独自の複雑なキー型を使ってグループ化や重複排除を行う場合は、IEqualityComparer<T>を自作してEqualsGetHashCodeを適切に実装する必要があります。

これにより、キーの比較方法を細かく制御できます。

以下は、PersonクラスのNameAgeをキーにしてグループ化するためのカスタムIEqualityComparer<Person>の例です。

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

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

class PersonComparer : IEqualityComparer<Person>
{
    public bool Equals(Person x, Person y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;
        return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase) && x.Age == y.Age;
    }

    public int GetHashCode(Person obj)
    {
        if (obj is null) return 0;
        int hashName = obj.Name?.ToLowerInvariant().GetHashCode() ?? 0;
        int hashAge = obj.Age.GetHashCode();
        return hashName ^ hashAge;
    }
}

class Program
{
    static void Main()
    {
        var nicknameMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            {"さとう", "佐藤"}, // ひらがな「さとう」を漢字「佐藤」に統一
            // 他のニックネームや読みもここで変換可能
        };

        var people = new[]
        {
            new Person { Name = "佐藤", Age = 30 },
            new Person { Name = "さとう", Age = 30 },
            new Person { Name = "鈴木", Age = 25 },
            new Person { Name = "佐藤", Age = 35 }
        };

        // 名前を辞書で置換して統一した新しいリストを作る処理
        var normalizedPeople = people.Select(p => new Person
        {
            Name = nicknameMap.ContainsKey(p.Name) ? nicknameMap[p.Name] : p.Name,
            Age = p.Age
        }).ToArray();

        var grouped = normalizedPeople
            .GroupBy(p => p, new PersonComparer())
            .Select(g => new { Name = g.Key.Name, Age = g.Key.Age, Count = g.Count() });

        foreach (var group in grouped)
        {
            Console.WriteLine($"名前: {group.Name}, 年齢: {group.Age}, 人数: {group.Count}");
        }
    }
}
名前: 佐藤, 年齢: 30, 人数: 2
名前: 鈴木, 年齢: 25, 人数: 1
名前: 佐藤, 年齢: 35, 人数: 1

この例では、PersonComparerで名前の大文字小文字を無視しつつ年齢も比較しています。

GetHashCodeは名前を小文字に変換してからハッシュコードを取得し、年齢のハッシュコードとXOR演算で組み合わせています。

これにより、GroupByは同じ名前・年齢のPersonを同じグループとして扱います。

カスタムIEqualityComparerを実装する際は、EqualsGetHashCodeの整合性を保つことが重要です。

ハッシュコードが異なる2つのオブジェクトは必ず異なるとみなされますが、同じハッシュコードでもEqualsfalseの場合は別のオブジェクトと判断されます。

適切な実装で正確なグループ化や重複排除を行いましょう。

実行タイミングとパフォーマンス

遅延実行と即時実行の影響

LINQのクエリは基本的に「遅延実行(Deferred Execution)」の仕組みを持っています。

これは、クエリの定義時には実際の処理は行われず、結果が必要になったタイミングで初めてデータの走査や計算が実行されることを意味します。

遅延実行により、不要な処理を避けたり、クエリを組み合わせて効率的にデータを扱うことが可能です。

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

これらは呼び出された時点でシーケンスを走査し、結果を返します。

遅延実行と即時実行の違いはパフォーマンスに大きく影響します。

例えば、遅延実行のクエリを複数回列挙すると、そのたびにデータ走査が発生します。

逆に、ToList()で一度メモリに展開しておけば、以降の処理は高速に行えますが、メモリ使用量が増加します。

ToListとToArrayのコスト

ToList()ToArray()はどちらも即時実行でシーケンスをメモリ上に展開しますが、内部の動作やコストに若干の違いがあります。

  • ToList()

内部で可変長のリストを使い、要素を追加しながらサイズを自動調整します。

要素数が不明な場合に便利ですが、サイズ調整のための再割り当てが発生することがあります。

  • ToArray()

可能であれば最初に要素数を取得し、そのサイズの配列を確保してコピーします。

要素数が事前に分かっている場合は効率的です。

どちらも大量のデータを扱う場合はメモリ消費が増えるため、必要な場合のみ使うことが望ましいです。

以下は、ToList()ToArray()の使い方の例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 1000000);
        var list = numbers.Where(n => n % 2 == 0).ToList();
        var array = numbers.Where(n => n % 2 == 0).ToArray();
        Console.WriteLine($"Listの要素数: {list.Count}");
        Console.WriteLine($"Arrayの要素数: {array.Length}");
    }
}
Listの要素数: 500000
Arrayの要素数: 500000

一回の走査で複数集計を取る最適化

複数の集計を別々に呼び出すと、シーケンスを複数回走査することになり、パフォーマンスが低下します。

例えば、Count()Sum()Average()を個別に呼ぶと3回走査されます。

これを防ぐために、一回の走査で複数の集計を同時に計算する方法があります。

Aggregateメソッドを使って、累積的に複数の値を計算するのが代表的な手法です。

以下は、1回の走査で最小値、最大値、合計、要素数、平均を同時に求める例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] numbers = { 3, 7, 2, 9, 4 };
        var stats = numbers.Aggregate(
            (Min: int.MaxValue, Max: int.MinValue, Sum: 0, Count: 0),
            (acc, x) => (
                Min: x < acc.Min ? x : acc.Min,
                Max: x > acc.Max ? x : acc.Max,
                Sum: acc.Sum + x,
                Count: acc.Count + 1
            ),
            acc => new
            {
                acc.Min,
                acc.Max,
                acc.Sum,
                acc.Count,
                Average = acc.Count > 0 ? (double)acc.Sum / acc.Count : 0
            }
        );
        Console.WriteLine($"最小値: {stats.Min}, 最大値: {stats.Max}, 合計: {stats.Sum}, 要素数: {stats.Count}, 平均: {stats.Average}");
    }
}
最小値: 2, 最大値: 9, 合計: 25, 要素数: 5, 平均: 5

この方法により、シーケンスを1回だけ走査して複数の集計結果を効率的に取得できます。

PLINQによる並列集計

PLINQ(Parallel LINQ)は、LINQクエリを並列処理に変換し、複数のCPUコアを活用して高速化を図る機能です。

大量のデータを集計する際に有効ですが、並列化のオーバーヘッドやスレッドセーフな処理が必要な点に注意が必要です。

PLINQを使うには、AsParallel()メソッドを呼び出してからLINQクエリを記述します。

集計メソッドも並列で実行されます。

以下は、PLINQで大きな配列の合計を計算する例です。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        var numbers = Enumerable.Range(1, 10000000);
        long sum = numbers.AsParallel().Sum(n => (long)n);
        Console.WriteLine($"合計: {sum}");
    }
}
合計: 50000005000000

PLINQは自動的にデータを分割し、複数スレッドで処理します。

複雑な集計や副作用のある処理は注意が必要ですが、純粋な集計処理では大幅な高速化が期待できます。

パフォーマンスを最大化するためには、データのサイズや処理内容に応じてPLINQの利用を検討し、必要に応じてWithDegreeOfParallelismで並列度を調整してください。

IEnumerableとIQueryableの違い

メモリ内コレクションとデータベースクエリ

IEnumerable<T>IQueryable<T>はどちらもLINQでデータを操作するためのインターフェースですが、処理の実行場所やタイミングに大きな違いがあります。

  • IEnumerable<T>

メモリ内のコレクション(配列やリストなど)を対象にしたインターフェースです。

LINQ to Objectsとも呼ばれ、クエリはメモリ上で実行されます。

すべてのデータはすでにメモリに読み込まれており、LINQの各メソッドはC#のコードとして実行されます。

  • IQueryable<T>

主にデータベースなどの外部データソースに対してクエリを表現するためのインターフェースです。

LINQ to EntitiesやLINQ to SQLなどで使われます。

クエリは式ツリー(Expression Tree)として構築され、実際のデータベースクエリ(SQL)に変換されて実行されます。

これにより、必要なデータだけを効率的に取得できます。

例えば、IEnumerable<T>はすべてのデータをメモリに読み込んでからフィルタリングや集計を行いますが、IQueryable<T>はSQLのWHERE句やGROUP BY句に変換してデータベース側で処理します。

この違いにより、IQueryable<T>は大規模なデータセットを扱う際にパフォーマンスが向上しますが、クエリの構築や実行に注意が必要です。

LINQ to ObjectsとEntity Frameworkの集計挙動

LINQ to ObjectsIEnumerable<T>とEntity FrameworkIQueryable<T>では、同じLINQクエリでも集計の挙動や実行タイミングが異なります。

  • LINQ to Objects

クエリはC#のコードとして実行され、すべてのデータはメモリ上に存在します。

集計メソッド(SumAverageGroupByなど)は即時実行され、結果はすぐに返されます。

空のシーケンスに対しては例外がスローされることがあります。

  • Entity Framework

クエリは式ツリーとして解析され、SQLに変換されてデータベースで実行されます。

集計メソッドはSQLの集計関数にマッピングされ、効率的に処理されます。

空のシーケンスに対してはSQLの挙動に依存し、NULLが返ることがあります。

Entity Frameworkはこれを適切に.NET型に変換します。

例えば、以下のようなコードはLINQ to Objectsではメモリ上で処理されますが、Entity FrameworkではSQLに変換されてデータベースで集計されます。

var averageScore = context.Students
    .Where(s => s.Score >= 60)
    .Average(s => s.Score);

Entity FrameworkはこのクエリをSQLのSELECT AVG(Score) FROM Students WHERE Score >= 60に変換し、データベース側で平均値を計算します。

この違いにより、Entity Frameworkでは不要なデータの転送を避けられ、パフォーマンスが向上しますが、LINQの一部の機能やメソッドはSQLに変換できないため注意が必要です。

まとめると、IEnumerable<T>はメモリ内のデータ操作に適し、IQueryable<T>はデータベースなどの外部データソースに対して効率的なクエリを実行するために使い分けます。

開発時には処理対象のデータやパフォーマンス要件に応じて適切なインターフェースを選択してください。

典型的なユースケースサンプル

売上データの期間別集計

売上データを期間ごとに集計することは、ビジネス分析でよくある処理です。

LINQを使うと、日付情報をキーにして簡単に月次や年次の売上集計ができます。

月次売上

月ごとの売上合計を求める例です。

売上データは日付と金額を持つクラスのリストとします。

using System;
using System.Collections.Generic;
using System.Linq;
class Sale
{
    public DateTime Date { get; set; }
    public decimal Amount { get; set; }
}
class Program
{
    static void Main()
    {
        var sales = new List<Sale>
        {
            new Sale { Date = new DateTime(2023, 1, 10), Amount = 1000 },
            new Sale { Date = new DateTime(2023, 1, 20), Amount = 1500 },
            new Sale { Date = new DateTime(2023, 2, 5), Amount = 2000 },
            new Sale { Date = new DateTime(2023, 2, 25), Amount = 2500 },
            new Sale { Date = new DateTime(2023, 3, 15), Amount = 3000 }
        };
        var monthlySales = sales
            .GroupBy(s => new { s.Date.Year, s.Date.Month })
            .Select(g => new
            {
                Year = g.Key.Year,
                Month = g.Key.Month,
                TotalAmount = g.Sum(s => s.Amount)
            })
            .OrderBy(x => x.Year).ThenBy(x => x.Month);
        foreach (var month in monthlySales)
        {
            Console.WriteLine($"{month.Year}{month.Month}月の売上合計: {month.TotalAmount}円");
        }
    }
}
2023年1月の売上合計: 2500円
2023年2月の売上合計: 4500円
2023年3月の売上合計: 3000円

この例では、匿名型で年と月をキーにグループ化し、各グループの売上金額を合計しています。

OrderByで年月順に並べ替えています。

年次売上

年ごとの売上合計を求める例です。

月次集計とほぼ同様ですが、キーは年だけにします。

using System;
using System.Collections.Generic;
using System.Linq;
class Sale
{
    public DateTime Date { get; set; }
    public decimal Amount { get; set; }
}
class Program
{
    static void Main()
    {
        var sales = new List<Sale>
        {
            new Sale { Date = new DateTime(2022, 12, 31), Amount = 5000 },
            new Sale { Date = new DateTime(2023, 1, 10), Amount = 1000 },
            new Sale { Date = new DateTime(2023, 5, 20), Amount = 3000 },
            new Sale { Date = new DateTime(2024, 2, 15), Amount = 4000 }
        };
        var yearlySales = sales
            .GroupBy(s => s.Date.Year)
            .Select(g => new
            {
                Year = g.Key,
                TotalAmount = g.Sum(s => s.Amount)
            })
            .OrderBy(x => x.Year);
        foreach (var year in yearlySales)
        {
            Console.WriteLine($"{year.Year}年の売上合計: {year.TotalAmount}円");
        }
    }
}
2022年の売上合計: 5000円
2023年の売上合計: 4000円
2024年の売上合計: 4000円

年単位でグループ化し、売上合計を計算しています。

こちらもOrderByで昇順に並べています。

ログ解析での多段階GroupBy

ログデータを複数の条件でグループ化し、階層的に集計する例です。

例えば、日付とログレベル(情報、警告、エラー)でグループ化し、各グループの件数を集計します。

using System;
using System.Collections.Generic;
using System.Linq;
enum LogLevel { Info, Warning, Error }
class LogEntry
{
    public DateTime Date { get; set; }
    public LogLevel Level { get; set; }
    public string Message { get; set; }
}
class Program
{
    static void Main()
    {
        var logs = new List<LogEntry>
        {
            new LogEntry { Date = new DateTime(2023, 4, 1), Level = LogLevel.Info, Message = "開始" },
            new LogEntry { Date = new DateTime(2023, 4, 1), Level = LogLevel.Error, Message = "例外発生" },
            new LogEntry { Date = new DateTime(2023, 4, 2), Level = LogLevel.Warning, Message = "警告" },
            new LogEntry { Date = new DateTime(2023, 4, 2), Level = LogLevel.Info, Message = "処理中" },
            new LogEntry { Date = new DateTime(2023, 4, 2), Level = LogLevel.Error, Message = "例外発生" }
        };
        var groupedLogs = logs
            .GroupBy(log => log.Date.Date)
            .Select(dateGroup => new
            {
                Date = dateGroup.Key,
                Levels = dateGroup
                    .GroupBy(log => log.Level)
                    .Select(levelGroup => new
                    {
                        Level = levelGroup.Key,
                        Count = levelGroup.Count()
                    })
                    .OrderBy(l => l.Level)
            })
            .OrderBy(g => g.Date);
        foreach (var dateGroup in groupedLogs)
        {
            Console.WriteLine($"{dateGroup.Date:yyyy-MM-dd}");
            foreach (var levelGroup in dateGroup.Levels)
            {
                Console.WriteLine($"  {levelGroup.Level}: {levelGroup.Count}件");
            }
        }
    }
}
2023-04-01
  Info: 1件
  Error: 1件
2023-04-02
  Info: 1件
  Warning: 1件
  Error: 1件

この例では、まず日付でグループ化し、その中でさらにログレベルでグループ化しています。

多段階のGroupByを使うことで階層的な集計が可能です。

折れ線グラフ用データポイント生成

折れ線グラフの描画に使うデータポイントを生成する例です。

日付ごとの売上やアクセス数などの時系列データをLINQで集計し、グラフ描画用の形式に整形します。

using System;
using System.Collections.Generic;
using System.Linq;
class DataPoint
{
    public DateTime Date { get; set; }
    public int Value { get; set; }
}
class Program
{
    static void Main()
    {
        var rawData = new[]
        {
            new { Date = new DateTime(2023, 5, 1), Count = 10 },
            new { Date = new DateTime(2023, 5, 1), Count = 5 },
            new { Date = new DateTime(2023, 5, 2), Count = 8 },
            new { Date = new DateTime(2023, 5, 3), Count = 12 },
            new { Date = new DateTime(2023, 5, 3), Count = 7 }
        };
        var dataPoints = rawData
            .GroupBy(d => d.Date)
            .Select(g => new DataPoint
            {
                Date = g.Key,
                Value = g.Sum(x => x.Count)
            })
            .OrderBy(dp => dp.Date)
            .ToList();
        Console.WriteLine("折れ線グラフ用データポイント:");
        foreach (var point in dataPoints)
        {
            Console.WriteLine($"{point.Date:yyyy-MM-dd}: {point.Value}");
        }
    }
}
折れ線グラフ用データポイント:
2023-05-01: 15
2023-05-02: 8
2023-05-03: 19

この例では、同じ日付のデータをまとめて合計し、DataPointクラスのリストとして返しています。

グラフ描画ライブラリに渡しやすい形式に整形する際に役立ちます。

よくある落とし穴と対策

空シーケンスでのInvalidOperationException

LINQの集計メソッドの中には、空のシーケンスに対して呼び出すとInvalidOperationExceptionをスローするものがあります。

代表的なものはAverageMinMaxです。

これらは要素が存在しない場合に計算できないため例外が発生します。

例えば、空の配列に対してAverageを呼ぶと以下のようになります。

using System;
using System.Linq;
class Program
{
    static void Main()
    {
        int[] empty = { };
        try
        {
            double avg = empty.Average();
            Console.WriteLine($"平均値: {avg}");
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"例外発生: {ex.Message}");
        }
    }
}
例外発生: Sequence contains no elements

対策としては以下の方法があります。

  • 事前にAny()Count()で空かどうかをチェックします
  • DefaultIfEmptyを使って空シーケンスにデフォルト値を設定します
  • カスタム集計で空の場合の処理を明示的に行います

DefaultIfEmptyを使った例:

double avg = empty.DefaultIfEmpty(0).Average();
Console.WriteLine($"平均値(空の場合は0): {avg}");

このように空シーケンスに対して安全に集計を行う工夫が必要です。

オーバーフローとdecimal変換

SumAggregateで大量の数値を集計する際、特にint型で合計を計算するとオーバーフローが発生する可能性があります。

intの最大値は約21億なので、それを超えると例外や誤った結果になります。

対策としては、以下の方法があります。

  • 集計対象の型をlongdecimalに変換してから集計します
  • Sumのオーバーロードでlongdecimalを使います

例えば、int配列の合計をlongで計算する例:

int[] largeNumbers = { int.MaxValue, int.MaxValue, 10 };
long sum = largeNumbers.Select(n => (long)n).Sum();
Console.WriteLine($"合計(long型): {sum}");
合計(long型): 4294967312

また、金額など精度が重要な場合はdecimal型を使うと安全です。

decimal[] prices = { 1000000000.5m, 2000000000.75m };
decimal total = prices.Sum();
Console.WriteLine($"合計(decimal型): {total}");
合計(decimal型): 3000000001.25

同期ミスによる結果の食い違い

LINQの集計処理を複数のスレッドや非同期処理で同時に行う場合、データの同期が取れていないと結果が食い違うことがあります。

特に、元のコレクションがスレッドセーフでない場合や、途中で変更される場合に問題が発生します。

例えば、別スレッドでコレクションに要素を追加しながら集計を行うと、予期しない結果や例外が発生します。

対策としては以下の方法があります。

  • 集計前にコレクションをコピーして不変にする(例:ToList()で複製)
  • スレッドセーフなコレクション(ConcurrentBag<T>など)を使います
  • ロック(lock文)を使って排他制御を行います

以下はコピーしてから集計する例です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
class Program
{
    static void Main()
    {
        var list = new List<int> { 1, 2, 3, 4, 5 };
        // 別スレッドで要素を追加
        Task.Run(() =>
        {
            for (int i = 6; i <= 10; i++)
            {
                list.Add(i);
                System.Threading.Thread.Sleep(10);
            }
        });
        // コピーしてから集計
        var snapshot = list.ToList();
        int sum = snapshot.Sum();
        Console.WriteLine($"合計(コピー時点): {sum}");
    }
}
合計(コピー時点): 15

このように、集計時点の状態を固定することで同期ミスを防げます。

マルチスレッド環境ではデータの整合性を意識した設計が重要です。

まとめ

この記事では、C#のLINQを使ったデータ集計の基本から応用までを解説しました。

標準的な集計メソッドの特徴やGroupByによる多次元集計、Aggregateを活用したカスタム集計、SelectManyZipを使った派生的な集計方法、さらに安全な集計のためのNull値対策や例外回避、カスタム比較器によるキー制御、実行タイミングとパフォーマンス最適化、IEnumerableIQueryableの違い、典型的なユースケース、そしてよくある落とし穴とその対策まで幅広く理解できます。

これらを活用することで、効率的かつ安全にデータ集計を行うスキルが身につきます。

関連記事

Back to top button
目次へ