C#のLINQのメソッドの一つ、GroupByメソッドはシーケンス(IEnumerable<TSource>
)の要素をグループ化するメソッドです。
このメソッドは8個のオーバーロードがあります。公式リファレンスを読めば、それぞれのオーバーロードの違い・使い分けはわかりますが、人によっては混乱するかもしれません。本記事では、公式リファレンスよりもわかりやすい解説を目指して、GroupByメソッドを紹介します。
※ 本記事では、LINQ to ObjectsのGroupByメソッドについて説明します。
※ 本記事では、.NET 9時点の情報に基づいて執筆しています。
本記事で使う型とデータ
本記事では、次の型を使います。
// 本記事で使うPurchase型
public record Purchase(
string Name,
decimal PriceYen,
string CategoryId
);
また、次のデータを使います。
// 本記事で使うpurchaseList
var purchaseList = new List<Purchase>
{
new("PC", 300000, "id_electronics"),
new("乾電池", 600, "id_electronics"),
new("靴下", 800, "id_clothing"),
new("少年漫画J", 500, "id_books"),
new("コーラ", 150, "id_groceries"),
new("青年漫画YJ", 600, "id_books"),
new("雑誌", 1200, "id_books"),
new("技術書", 5000, "id_books"),
new("牛乳", 180, "id_groceries"),
new("Tシャツ", 2500, "id_clothing"),
};
基本のオーバーロード
まずは、GroupByメソッドの一番簡単で基本的なオーバーロード
GroupBy<TSource,TKey>(IEnumerable<TSource>, Func<TSource,TKey>)
を紹介します。次のコードは、この基本のオーバーロードを使い
「シーケンスpurchaseListから、CategoryIdが同じ要素ごとにグループ化したシーケンスを作る」
コードです。
// GroupByメソッドの基本のオーバーロードの返値型は、IEnumerable<IGrouping<TKey, TSource>>
// ここでは、TKeyはstringで、TSourceはPurchase
IEnumerable<IGrouping<string, Purchase>> groups = purchaseHistory.GroupBy(
// CategoryIdが同じものをまとめて一つのグループにしたい。
// 元の要素を引数に取り、どのグループにまとめるかの判定を行うための「キー」を返すデリゲート、
// 「キーセレクター」を引数に取る
keySelector: purchase => purchase.CategoryId
);
foreach (IGrouping<string, Purchase> grouping in groups)
{
// IGrouping<TKey, TElement>インターフェースはKeyプロパティーを持つ
// グループ化で使ったキーである
Console.WriteLine(grouping.Key);
// IGrouping<TKey, TElement>インターフェースはIEnumerable<TElement>を実装している
// foreachでまわせるし、LINQも使える
foreach (Purchase purchase in grouping)
{
// IGrouping内の順番は、もとのシーケンスの順序を保つ
Console.WriteLine($" {purchase}");
}
}
↑のコードでは、同じCateoryIdをもつPurchaseを同じグループにまとめています。keySelectorは「シーケンスの要素を引数に取り、グループにまとめる判定を行うための"キー"を返すデリゲート」です。
実行結果は次のようになります。
id_electronics
Purchase { Name = PC, PriceYen = 300000, CategoryId = id_electronics }
Purchase { Name = 乾電池, PriceYen = 600, CategoryId = id_electronics }
id_clothing
Purchase { Name = 靴下, PriceYen = 800, CategoryId = id_clothing }
Purchase { Name = Tシャツ, PriceYen = 2500, CategoryId = id_clothing }
id_books
Purchase { Name = 少年漫画J, PriceYen = 500, CategoryId = id_books }
Purchase { Name = 青年漫画YJ, PriceYen = 600, CategoryId = id_books }
Purchase { Name = 雑誌, PriceYen = 1200, CategoryId = id_books }
Purchase { Name = 技術書, PriceYen = 5000, CategoryId = id_books }
id_groceries
Purchase { Name = コーラ, PriceYen = 150, CategoryId = id_groceries }
Purchase { Name = 牛乳, PriceYen = 180, CategoryId = id_groceries }
「指定されたキーセレクター関数に従ってシーケンスの要素をグループ化します。」
という公式リファレンスの説明も、こうやってみると納得ですね。
GroupByメソッドの基本のオーバーロードの返値型は、
「IEnumerable<IGrouping<TKey, TSource>>
」型
です。(この例だと、IEnumerable<IGrouping<string, Purchase>
型)
IGrouping<TKey, TElement>
インターフェースは、IEnumerable<TElement>
を実装していて、foreachでまわせますし、LINQも使えます。
元シーケンスと違う型でグループ化するオーバーロード
次は、元のシーケンスとは違う型のグループを作るGroupByメソッドのオーバーロード
GroupBy<TSource,TKey,TElement>(IEnumerable<TSource>, Func<TSource,TKey>, Func<TSource,TElement>)
を紹介ます。次のコードは、
「シーケンスpurchaseListから、"CategoryIdが同じ要素ごとにグループ化した、商品名Nameのグループ"のシーケンスを作る」
コードです。
IEnumerable<IGrouping<string, string>> groups = purchaseList.GroupBy(
// 元の要素を、どのグループにまとめるかの判定を行うための「キー」を返すデリゲート
keySelector: purchase => purchase.CategoryId,
// 元の要素を、どのように変換(射影)するかのデリゲート
elementSelector: purchase => purchase.Name
);
foreach (IGrouping<string, string> grouping in groups)
{
Console.WriteLine(grouping.Key);
// IGrouping<string, string>の要素は、Purchaseではなくてstring。
// PurchaseのNameが入っている
foreach (var name in grouping)
{
Console.WriteLine($" {name}");
}
}
このオーバーロードでは、keySelectorに加えて、引数として「グループ要素をどのように変換するかを司る、elementSelector」を取っていますね。
実行結果は次のようになります。
id_electronics
PC
乾電池
id_clothing
靴下
Tシャツ
id_books
少年漫画J
青年漫画YJ
雑誌
技術書
id_groceries
コーラ
牛乳
基本のオーバーロードと違い、このオーバーロードでは、グループ要素の型を変更することができますね。
指定されたキーセレクター関数に従ってシーケンスの要素をグループ化し、指定された関数を使用して各グループの要素を射影します。
という公式リファレンスの説明も、こうやってみると納得ですね。
キーとグループ要素から結果の要素を生成するオーバーロード
次は、返値型としてIGrouping<TKey, TSource>
のシーケンスではなく、TResult
なシーケンスを返すオーバーロード
を紹介ます。新たに次のような型を定義します。
public record CategoryGroupedPurchase(
string CategoryId,
List<Purchase> Purchases
);
次のコードは、
「シーケンスpurchaseListから、CategoryIdが同じ要素ごとにグループ化し、そのグループのキーとグループ要素から、CategoryGroupedPurchaseのシーケンスを作る」
コードです。
// keySelectorとresultSelectorを引数にとるオーバーロードでは、
// 返値型が`IEnumerable<IGrouping<TKey, TSource>>`ではなくて、`IEnumerable<TResult>`。
// ここでは、IEnumerable<CategoryGroupedPurchase>。
IEnumerable<CategoryGroupedPurchase> groups = purchaseList.GroupBy(
// 元の要素を、どのグループにまとめるかの判定を行うための「キー」を返すデリゲート
keySelector: purchase => purchase.CategoryId,
// グループのキーとグループ要素から、返値の要素を生成するデリゲート
resultSelector: (categoryId, purchases) => new CategoryGroupedPurchase(
CategoryId: categoryId,
Purchases: purchases.ToList()
)
);
// groupsは、IEnumerable<CategoryGroupedPurchase>だから
// その要素は、CategoryGroupedPurchase型
foreach (CategoryGroupedPurchase group in groups)
{
Console.WriteLine(group.CategoryId);
foreach (Purchase purchase in group.Purchases)
{
Console.WriteLine($" {purchase}");
}
}
このオーバーロードでは、keySelectorに加えて、引数として「グループキーとグループ要素から、結果の要素を生成するデリゲート、resultSelector」を取っていますね。
また、返値型がIEnumerable<CategoryGroupedPurchase>
であることにも注目してください。IEnumerable<IGrouping<TKey, TSource>
ではありません。
実行結果は次のようになります。
id_electronics
Purchase { Name = PC, PriceYen = 300000, CategoryId = id_electronics }
Purchase { Name = 乾電池, PriceYen = 600, CategoryId = id_electronics }
id_clothing
Purchase { Name = 靴下, PriceYen = 800, CategoryId = id_clothing }
Purchase { Name = Tシャツ, PriceYen = 2500, CategoryId = id_clothing }
id_books
Purchase { Name = 少年漫画J, PriceYen = 500, CategoryId = id_books }
Purchase { Name = 青年漫画YJ, PriceYen = 600, CategoryId = id_books }
Purchase { Name = 雑誌, PriceYen = 1200, CategoryId = id_books }
Purchase { Name = 技術書, PriceYen = 5000, CategoryId = id_books }
id_groceries
Purchase { Name = コーラ, PriceYen = 150, CategoryId = id_groceries }
Purchase { Name = 牛乳, PriceYen = 180, CategoryId = id_groceries }
指定されたキーセレクター関数に従ってシーケンスの要素をグループ化し、各グループとそのキーから結果値を作成します。
という公式リファレンスの説明も、こうやってみると納得ですね。
一度要素を変換しグループ化し、キーとグループ要素をさらに変換するオーバーロード
次は一度要素を変換してグループ化し、キーとグループ要素をさらに変換するオーバーロード
を紹介します。新たに次のような型を定義します。
public record CategoryPriceStatistics(
string CategoryId,
decimal TotalPriceYen,
decimal AveragePriceYen,
decimal MaxPriceYen,
decimal MinPriceYen,
int Count
);
次のコードは、
シーケンスpurchaseListから、
CategoryIdが同じ要素ごとにグループ化し、
グループ化する際に要素はdecimal型のPriceYenに変換(射影)して、
そのグループのキーとグループ要素からCategoryPriceStatisticsのシーケンスを作る
コードです。
// keySelector、elementSelector、resultSelectorを引数にとるオーバーロードでは、
// 返値型が`IEnumerable<IGrouping<TKey, TSource>>`ではなくて、`IEnumerable<TResult>`。
// ここでは、IEnumerable<CategoryPriceStatistics>。
IEnumerable<CategoryPriceStatistics> groups = purchaseList.GroupBy(
// 元の要素を、どのグループにまとめるかの判定を行うための「キー」を返すデリゲート
keySelector: purchase => purchase.CategoryId,
// 元の要素から、グループ要素に変換するデリゲート
elementSelector: purchase => purchase.PriceYen,
// グループのキーとグループ要素から、返値の要素を生成するデリゲート
resultSelector: (categoryId, prices) => new CategoryPriceStatistics(
CategoryId: categoryId,
TotalPriceYen: prices.Sum(),
AveragePriceYen: prices.Average(),
MaxPriceYen: prices.Max(),
MinPriceYen: prices.Min(),
Count: prices.Count()
)
);
// groupsは、IEnumerable<CategoryPriceStatistics>だから
// その要素は、CategoryPriceStatistics型
foreach (CategoryPriceStatistics group in groups)
{
Console.WriteLine(group.CategoryId);
Console.WriteLine($" {group}");
}
先に説明した
- 元シーケンスと違う型でグループ化するオーバーロード
- キーとグループ要素から結果の要素を生成するオーバーロード
を組み合わせたようなオーバーロードですね。
指定されたキーセレクター関数に従ってシーケンスの要素をグループ化し、各グループとそのキーから結果値を作成します。 各グループの要素は、指定された関数を使用して射影されます。
という公式リファレンスの説明も・・・。まぁ、うん。なんとなく納得できますね。
追加でIEqualityComparer<TKey>
を引数にとる4つのオーバーロード
次の4個のGroupByメソッドのオーバーロードを紹介しました。
- 基本のオーバーロード
- 元シーケンスと違う型でグループ化するオーバーロード
- キーとグループ要素から結果の要素を生成するオーバーロード
- 一度要素を変換しグループ化し、キーとグループ要素をさらに変換するオーバーロード
それらのオーバーロードそれぞれに、IEqualityComparer<TKey>
も追加で引数にとるオーバーロードが4個あります。
GroupBy<TSource,TKey>(IEnumerable<TSource>, Func<TSource,TKey>, IEqualityComparer<TKey>)
GroupBy<TSource,TKey,TElement>(IEnumerable<TSource>, Func<TSource,TKey>, Func<TSource,TElement>, IEqualityComparer<TKey>)
GroupBy<TSource,TKey,TResult>(IEnumerable<TSource>, Func<TSource,TKey>, Func<TKey,IEnumerable<TSource>,TResult>, IEqualityComparer<TKey>)
GroupBy<TSource,TKey,TElement,TResult>(IEnumerable<TSource>, Func<TSource, TKey>, Func<TSource,TElement>, Func<TKey,IEnumerable<TElement>, TResult>, IEqualityComparer<TKey>)
GroupByメソッドの補足
GroupByメソッドを呼び出すだけでは、シーケンス要素のグループ化は実行されません。
現在の実装では、GroupByメソッドの戻り値の1つ目を列挙した際に、元シーケンスの要素が全て列挙される点に注意してください。(全部の要素をすべて読み込まないとグループ化できないですよね。)
例に使ったデータと、基本のオーバーロードのコード例を再掲します。
var purchaseList = new List<Purchase>
{
new("PC", 300000, "id_electronics"),
new("乾電池", 600, "id_electronics"),
new("靴下", 800, "id_clothing"),
new("少年漫画J", 500, "id_books"),
new("コーラ", 150, "id_groceries"),
new("青年漫画YJ", 600, "id_books"),
new("雑誌", 1200, "id_books"),
new("技術書", 5000, "id_books"),
new("牛乳", 180, "id_groceries"),
new("Tシャツ", 2500, "id_clothing"),
};
IEnumerable<IGrouping<string, Purchase>> groups = purchaseHistory.GroupBy(
keySelector: purchase => purchase.CategoryId
);
foreach (IGrouping<string, Purchase> grouping in groups)
{
Console.WriteLine(grouping.Key);
foreach (Purchase purchase in grouping)
{
Console.WriteLine($" {purchase}");
}
}
↑の実行結果を再掲します。
id_electronics
Purchase { Name = PC, PriceYen = 300000, CategoryId = id_electronics }
Purchase { Name = 乾電池, PriceYen = 600, CategoryId = id_electronics }
id_clothing
Purchase { Name = 靴下, PriceYen = 800, CategoryId = id_clothing }
Purchase { Name = Tシャツ, PriceYen = 2500, CategoryId = id_clothing }
id_books
Purchase { Name = 少年漫画J, PriceYen = 500, CategoryId = id_books }
Purchase { Name = 青年漫画YJ, PriceYen = 600, CategoryId = id_books }
Purchase { Name = 雑誌, PriceYen = 1200, CategoryId = id_books }
Purchase { Name = 技術書, PriceYen = 5000, CategoryId = id_books }
id_groceries
Purchase { Name = コーラ, PriceYen = 150, CategoryId = id_groceries }
Purchase { Name = 牛乳, PriceYen = 180, CategoryId = id_groceries }
GroupByの結果の順序は、元のシーケンスの要素において、グループのキーが登場した順序を保持します。
元のシーケンスでは、まずid_electronics、次にid_clothing、そしてid_books・・・と登場しており、確かにその順序を保持していますね。
GroupByの結果のIGrouping内の要素の順序は、元のシーケンスの要素の順序を保持します。
キーがid_booksのグループに注目すると、少年漫画J、青年漫画YJ、雑誌、技術書と確かに元のシーケンスの順序を保持していますね。
まとめ
GroupByのオーバーロード、これでもう迷いませんね!
あなたが求めているのはToLookupかもしれない
本記事ではGroupByメソッドを紹介しましたが、あなたが本当に必要なのはToLookupメソッドかもしれません。
ILookup<string, Purchase> purchaseLookup = purchaseList.ToLookup(purchase => purchase.CategoryId);
// 2と表示(id_groceriesグループの要素数)
Console.WriteLine(purchaseLookup["id_groceries"].Count());
// 4と表示(id_booksグループの要素数)
Console.WriteLine(purchaseLookup["id_books"].Count());
foreach (var category in purchaseLookup)
{
// 各グループのキーを表示
Console.WriteLine(category.Key);
foreach (var purchase in category)
{
Console.WriteLine($" {purchase}");
}
}
GroupByメソッドは、メソッドを呼び出すだけでは、シーケンス要素のグループ化は実行されません。GroupByの戻値は、グループ化の実行に必要なすべての情報を格納するオブジェクトです(遅延実行)。また、戻値を複数回列挙(foreachで回したり、LINQのメソッドを使ったり)すると、複数回グループ化の処理が実行されます。
一方で、ToLookupメソッドはメソッド呼び出し時に、即座にグループ化の処理を行い、すぐにILookup<TKey, TSource>
を返します。また、グループ化した結果を複数回列挙しても、グループ化処理は一度だけ実行されます。
グループ化した結果を複数回列挙するのであればToLookupを使うことも検討してください。
こちらの記事もどうぞ。