【C#】LINQでマスターするデータ集計テクニック:SumからGroupBy・Aggregateまで
C#のLINQは配列やコレクションをSQLライクに扱い、Sum
やAverage
などの標準メソッド、GroupBy
とSelect
の組み合わせ、自由度の高いAggregate
で一度に複数集計を実現できます。
宣言的記述で可読性と保守性が向上し、ループよりも記述量を削減しながら性能も確保できます。
LINQによるデータ集計の基本
LINQ(Language Integrated Query)は、C#でデータの集計や操作を簡潔に記述できる強力な機能です。
ここでは、LINQの基本的な集計メソッドの種類や使い方、そしてメソッド構文とクエリ構文の違いについて詳しく解説します。
集計メソッドの種類
LINQには、データの集計を行うための標準的なメソッドがいくつか用意されています。
代表的なものにCount
、LongCount
、Sum
、Min
、Max
、Average
があります。
これらは数値データの合計や平均、最大値・最小値、要素数の取得などに使われます。
以下でそれぞれの特徴や注意点を説明します。
CountとLongCountの違い
Count
メソッドは、シーケンス内の要素数を取得するために使います。
戻り値はint
型で、最大で約21億(2,147,483,647)までの要素数を扱えます。
通常のコレクションであればCount
で十分ですが、非常に大きなデータセットを扱う場合はLongCount
を使うことが推奨されます。
LongCount
は戻り値がlong
型(64ビット整数)で、より大きな要素数をカウントできます。
例えば、巨大なログファイルの行数や大規模なデータベースのレコード数を扱う際に役立ちます。
以下はCount
とLongCount
の使い分け例です。
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
は複数のオーバーロードが用意されており、int
、long
、float
、double
、decimal
などの型に対応しています。
返却型は入力の型に依存します。
例えば、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のカスタムセレクタ
Min
とMax
はシーケンスの最小値・最大値を取得するメソッドです。
これらも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歳
注意点として、Min
やMax
は空のシーケンスに対して呼び出すと例外が発生します。
空の可能性がある場合は事前にチェックするか、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)
メソッド構文は、Where
やSelect
、GroupBy
などの拡張メソッドを連結して記述します。
柔軟で直感的に書けるため、実務でよく使われます。
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
この例では、GroupBy
でSubject
をキーにグループ化し、各グループのScore
の平均を計算しています。
g.Key
でグループのキーにアクセスでき、g
はそのキーに属する要素のシーケンスです。
複合キーを使ったグループ化
複数のプロパティを組み合わせてグループ化したい場合、複合キーを使います。
C#では匿名型やTuple
をキーとして利用できます。
複合キーを使うことで、より細かい条件でグループ化が可能です。
匿名型キー活用
匿名型を使うと、複数のプロパティをまとめてキーにできます。
匿名型はEquals
とGetHashCode
が自動的に適切に実装されているため、グループ化に便利です。
以下は、学生の成績を科目と名前の頭文字でグループ化し、各グループの平均点を求める例です。
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
は軽量で構造化された複合キーを作成でき、匿名型と同様にEquals
とGetHashCode
が適切に実装されています。
以下は、匿名型の代わりに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.MaxValue
やint.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
この例では、初期値としてMin
にint.MaxValue
、Max
にint.MinValue
を設定し、Sum
とCount
は0
からスタートしています。
集計処理の最後に平均値を計算して返しています。
中間状態オブジェクトの設計
複雑な集計処理では、中間状態を表すオブジェクトを設計して管理するとコードが分かりやすくなります。
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つの異なるシーケンスを同期させて効率的に集計できます。
SelectMany
とZip
を組み合わせることで、ネストされたデータのフラット化と異なるシーケンスの同期処理を柔軟に行えます。
例えば、複数のクラスの学生ごとの成績と目標点を同期させて比較するようなシナリオにも応用可能です。
Null値と例外を防ぐ安全な集計
DefaultIfEmptyによる初期値設定
LINQの集計メソッド(Sum
、Average
、Min
、Max
など)は、空のシーケンスに対して呼び出すと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を返すようにしています。
Average
やMin
、Max
でも同様に使えます。
nullable型と集計メソッド
LINQの集計メソッドは、int?
やdouble?
などのnullable型にも対応しています。
nullable型のシーケンスに対してSum
やAverage
を呼ぶと、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
の場合は、Sum
はnull
を返し、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のGroupBy
やDistinct
などのメソッドは、デフォルトでキーの比較にEquals
とGetHashCode
を使います。
文字列の場合は大文字小文字を区別するため、”apple”と”Apple”は別のキーとして扱われます。
大文字小文字を無視して集計したい場合は、IEqualityComparer<string>
を実装したカスタムコンパレータを渡す必要があります。
.NETには大文字小文字を無視する比較を行うStringComparer.OrdinalIgnoreCase
やStringComparer.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.OrdinalIgnoreCase
をGroupBy
の第2引数に渡すことで、大文字小文字を区別せずにグループ化しています。
結果として”Apple”と”apple”が同じグループにまとめられています。
マニュアルハッシュとEquals実装
独自の複雑なキー型を使ってグループ化や重複排除を行う場合は、IEqualityComparer<T>
を自作してEquals
とGetHashCode
を適切に実装する必要があります。
これにより、キーの比較方法を細かく制御できます。
以下は、Person
クラスのName
とAge
をキーにしてグループ化するためのカスタム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
を実装する際は、Equals
とGetHashCode
の整合性を保つことが重要です。
ハッシュコードが異なる2つのオブジェクトは必ず異なるとみなされますが、同じハッシュコードでもEquals
がfalse
の場合は別のオブジェクトと判断されます。
適切な実装で正確なグループ化や重複排除を行いましょう。
実行タイミングとパフォーマンス
遅延実行と即時実行の影響
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#のコードとして実行され、すべてのデータはメモリ上に存在します。
集計メソッド(Sum
、Average
、GroupBy
など)は即時実行され、結果はすぐに返されます。
空のシーケンスに対しては例外がスローされることがあります。
- 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
をスローするものがあります。
代表的なものはAverage
、Min
、Max
です。
これらは要素が存在しない場合に計算できないため例外が発生します。
例えば、空の配列に対して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変換
Sum
やAggregate
で大量の数値を集計する際、特にint
型で合計を計算するとオーバーフローが発生する可能性があります。
int
の最大値は約21億なので、それを超えると例外や誤った結果になります。
対策としては、以下の方法があります。
- 集計対象の型を
long
やdecimal
に変換してから集計します Sum
のオーバーロードでlong
やdecimal
を使います
例えば、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
を活用したカスタム集計、SelectMany
やZip
を使った派生的な集計方法、さらに安全な集計のためのNull値対策や例外回避、カスタム比較器によるキー制御、実行タイミングとパフォーマンス最適化、IEnumerable
とIQueryable
の違い、典型的なユースケース、そしてよくある落とし穴とその対策まで幅広く理解できます。
これらを活用することで、効率的かつ安全にデータ集計を行うスキルが身につきます。