2025年リリース予定の.NET 10にて、LINQのLeftJoinメソッドとRightJoinメソッドが追加されそうです。
それに備えて、LINQ登場時から存在するJoinメソッド・GroupJoinメソッドをおさらいしましょう。
※ 本記事では、LINQ to ObjectsのJoinメソッド・GroupJoinメソッドについて説明します。
Joinメソッド
Joinメソッドの概要
Joinメソッドは、2つのシーケンスを一致するキーに基づいて要素を結合・変換し、新しいシーケンスを作成するメソッドです。
公式リファレンスはこちら。
次の2つのオーバーロードがあります。
Join<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,TInner,TResult>)
Join<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,TInner,TResult>, IEqualityComparer<TKey>)
戻り値の型はどちらも、IEnumerable<TResult>
です。
違いは、IEqualityComparer<TKey>
の引数を取るかどうかです。
リレーショナデータベースに慣れている方は「内部結合」の実装だと思えば理解が早いでしょう。
Joinメソッドの利用例
Joinメソッドの利用例を示します。次のようなrecordがあります。
record MessageLog(
string MessageId,
string PlayerId,
string Content,
DateTime CreatedAt
);
record Player(
string Id,
string Name
);
record DisplayMessage(
string PlayerName,
string MessageContent
);
次のようなmessages
とplayers
があります。
var messages = new List<MessageLog>
{
new(MessageId: "1", PlayerId: "10", Content: "こんにちは", CreatedAt: DateTime.Parse("2022-01-01 00:00:00")),
new(MessageId: "2", PlayerId: "20", Content: "やあ", CreatedAt: DateTime.Parse("2022-01-01 00:00:00")),
new(MessageId: "3", PlayerId: "30", Content: "おはようございます", CreatedAt: DateTime.Parse("2022-01-01 08:00:00")),
new(MessageId: "4", PlayerId: "40", Content: "おやすみなさい", CreatedAt: DateTime.Parse("2022-01-01 22:00:00")),
new(MessageId: "5", PlayerId: "10", Content: "元気ですか?", CreatedAt: DateTime.Parse("2022-01-02 10:00:00")),
new(MessageId: "6", PlayerId: "20", Content: "元気です", CreatedAt: DateTime.Parse("2022-01-02 10:05:00")),
new(MessageId: "7", PlayerId: "30", Content: "あなたはどうですか?", CreatedAt: DateTime.Parse("2022-01-02 10:10:00")),
new(MessageId: "8", PlayerId: "40", Content: "私も元気です", CreatedAt: DateTime.Parse("2022-01-02 10:15:00")),
new(MessageId: "9", PlayerId: "10", Content: "ゲームをしましょう", CreatedAt: DateTime.Parse("2022-01-03 14:00:00")),
new(MessageId: "10", PlayerId: "20", Content: "もちろん!", CreatedAt: DateTime.Parse("2022-01-03 14:05:00"))
};
var players = new List<Player>
{
new(Id: "10", Name: "たろう"),
new(Id: "20", Name: "じろう"),
new(Id: "30", Name: "さぶろう"),
new(Id: "40", Name: "しろう"),
new(Id: "50", Name: "ごろう")
};
このmessages
とplayers
から、「MessageLog型のPlayerId」と「Player型のId」が一致する要素を結合してDisplayMessage
を作りたいです。
これを簡潔に実現できるのが、Joinメソッドです。
var displayMessages = messages.Join(
inner: players,
outerKeySelector: message => message.PlayerId,
innerKeySelector: player => player.Id,
resultSelector: (message, player) => new DisplayMessage(PlayerName: player.Name, MessageContent: message.Content)
);
foreach (var displayMessage in displayMessages)
{
Console.WriteLine(displayMessage);
}
「messages」が、結合する最初のシーケンス。
innerで名前付き引数呼び出ししている「players」が、最初のシーケンスに結合する2番目のシーケンス。
名前付き引数「outerKeySelector」で渡しているのは、最初のシーケンスの各要素から結合キーを抽出するデリゲート。
名前付き引数「innerKeySelector」で渡しているのは、2番目のシーケンスの各要素から結合キーを抽出するデリゲート。
名前付き引数「resultSelector」で渡しているのは、一致する2つの要素から結果の要素を作成するデリゲート。
です。
これを実行するとこうなります。
DisplayMessage { PlayerName = たろう, MessageContent = こんにちは }
DisplayMessage { PlayerName = じろう, MessageContent = やあ }
DisplayMessage { PlayerName = さぶろう, MessageContent = おはようございます }
DisplayMessage { PlayerName = しろう, MessageContent = おやすみなさい }
DisplayMessage { PlayerName = たろう, MessageContent = 元気ですか? }
DisplayMessage { PlayerName = じろう, MessageContent = 元気です }
DisplayMessage { PlayerName = さぶろう, MessageContent = あなたはどうですか? }
DisplayMessage { PlayerName = しろう, MessageContent = 私も元気です }
DisplayMessage { PlayerName = たろう, MessageContent = ゲームをしましょう }
DisplayMessage { PlayerName = じろう, MessageContent = もちろん! }
Joinメソッドの補足
Joinメソッドを呼び出すだけでは、要素の列挙や結合はされません。
現在の実装では、Joinメソッドの戻り値の1つ目を列挙した際に、innerが全て列挙される点に注意してください。ただし、outer(拡張関数のthis、先のコード例だとmessages)が空の場合、innerは列挙されません。
Joinメソッドはouterの順序を保持します。また、あるouterに一致するinnerの中では、innerの順序も保持します。
次のコードは、先の例のouterとinnerを入れ替えたコードです。
var displayMessages = players.Join(
inner: messages,
innerKeySelector: message => message.PlayerId,
outerKeySelector: player => player.Id,
resultSelector: (player, message) => new DisplayMessage(PlayerName: player.Name, MessageContent: message.Content)
);
foreach (var displayMessage in displayMessages)
{
Console.WriteLine(displayMessage);
}
実行例結果は次のとおりです。
たろう、じろう、さぶろう・・・と、outerはplayerの順序を保持していることに注目してください。
また、PlayerNameが「たろう」のメッセージに注目すると、「こんにちは」、「元気ですか?」、「ゲームをしましょう」と順序を保持していることに注目してください。
DisplayMessage { PlayerName = たろう, MessageContent = こんにちは }
DisplayMessage { PlayerName = たろう, MessageContent = 元気ですか? }
DisplayMessage { PlayerName = たろう, MessageContent = ゲームをしましょう }
DisplayMessage { PlayerName = じろう, MessageContent = やあ }
DisplayMessage { PlayerName = じろう, MessageContent = 元気です }
DisplayMessage { PlayerName = じろう, MessageContent = もちろん! }
DisplayMessage { PlayerName = さぶろう, MessageContent = おはようございます }
DisplayMessage { PlayerName = さぶろう, MessageContent = あなたはどうですか? }
DisplayMessage { PlayerName = しろう, MessageContent = おやすみなさい }
DisplayMessage { PlayerName = しろう, MessageContent = 私も元気です }
IEqualityComparer<TKey>
を引数にとるオーバーロードでは、要素を結合するキーの比較をカスタマイズすることができます。
最初の例でも2個目の例でも、「ごろう」のDisplayMessageがないことに注目してください。「ごろう」のIdに一致するMessageLogがなかったため、戻り値の要素に含まれていません。
GroupJoinメソッド
GroupJoinメソッドの概要
GroupJoinメソッドは、1つ目のシーケンスの各要素に対して、2つ目シーケンスの中からキーに基づき一致する要素全てをグループ化したものを結合し、新しいシーケンスを作成するメソッドです。
公式リファレンスはこちら。
次の2つのオーバーロードがあります。
- GroupJoin(IEnumerable, IEnumerable, Func, Func, Func, TResult>)
- GroupJoin(IEnumerable, IEnumerable, Func, Func, Func, TResult>, IEqualityComparer)
戻り値の型はどちらも、IEnumerable<TResult>
です。
違いは、IEqualityComparer<TKey>
の引数を取るかどうかです。
GroupJoinは、従来のリレーショナルデータベースの用語に直接相当するものはありません。「外部結合」に相当するわけではない点に注意してください。
GroupJoinメソッドの利用例
GroupJoinメソッドの利用例を示します。次のようなrecordがあります。
record Team(string Id, string Name);
record Member(string Id, string Name, string TeamId);
record TeamWithMembers(
string Id,
string Name,
Member[] Members)
{
public override string ToString()
{
return $"ID:{Id}, Name:{Name}, Members:{string.Join(",", Members.Select(it => it.Name))}";
}
}
次のようなteams
とmemberList
とがあります。
var teams = new List<Team>
{
new("1", "Team 1"),
new("2", "Team 2"),
new("3", "Team 3"),
};
var memberList = new List<Member>
{
new("1", "たろう", "1"),
new("2", "じろう", "2"),
new("3", "さぶろう", "2"),
new("4", "しろう", "2"),
new("5", "ごろう", "4"),
};
このteams
とmemberList
から、TeamWithMember
のシーケンスを作成します。
var teamWithMembers = teams.GroupJoin(
inner: memberList,
outerKeySelector: team => team.Id,
innerKeySelector: member => member.TeamId,
resultSelector: (team, members) => new TeamWithMembers(
Id: team.Id,
Name: team.Name,
Members: [..members]
)
);
foreach (var teamWithMember in teamWithMembers)
{
Console.WriteLine(teamWithMember);
}
「teams」が、結合する最初のシーケンス。
innerで名前付き引数呼び出ししている「memberList」が、最初のシーケンスに結合する2番目のシーケンス。
名前付き引数「outerKeySelector」で渡しているのは、最初のシーケンスの各要素から結合キーを抽出するデリゲート。
名前付き引数「innerKeySelector」で渡しているのは、2番目のシーケンスの各要素から結合キーを抽出するデリゲート。
名前付き引数「resultSelector」で渡しているのは、最初のシーケンス要素とそれに一致する2番目の要素グループから結果の要素を作成するデリゲート。
です。
これを実行するとこうなります。
ID:1, Name:Team 1, Members:たろう
ID:2, Name:Team 2, Members:じろう,さぶろう,しろう
ID:3, Name:Team 3, Members:
GroupJoinメソッドの補足
GroupJoinメソッドを呼び出すだけでは、要素の列挙や結合はされません。
現在の実装では、GroupJoinメソッドの戻り値の1つ目を列挙した際に、innerが全て列挙される点に注意してください。ただし、outer(拡張関数のthis、先のコード例だとteams)が空の場合、innerは列挙されません。
GroupJoinメソッドはouterの順序を保持します。
先のコードの実行結果が、「Team 1」、「Team 2」、「Team 3」とteamsの順序を保持していることに注目してください。
また、GroupJoinメソッドはあるouterに一致するinnerグループの中では、innerの順序も保持します。
実行結果の「Team 2」の要素に注目すると、「じろう」、「さぶろう」、「しろう」と順序を保持していることに注目してください。
IEqualityComparer<TKey>
を引数にとるオーバーロードでは、要素を結合するキーの比較をカスタマイズすることができます。
実行例の結果に、「Team 3」の要素が含まれていることに注目してください。GroupJoinは、innerに一致する要素が一つもないouter要素も結果に含まれます。
LINQで外部結合は?
GroupJoinは、外部結合ではないことに注意してください。
.NET 10にて、左外部結合 LeftJoinと右外部結 RightJoinがリリースされそうです。dotnet/runtimeリポジトリで、PRがマージされています。外部結合をしたい場合は、それらを利用しましょう。
issue : https://github.com/dotnet/runtime/issues/110292
PR : https://github.com/dotnet/runtime/pull/110872
.NET 10より古い環境で、外部結合を行いたい場合は、GroupJoin、DefaultIfEmpty、SelectManyを使って実現できます。
コード例を示します。
var messages = /* 略 */
var players = /* 略 */
var displayMessages = players
.GroupJoin(
inner: messages,
outerKeySelector: player => player.Id,
innerKeySelector: message => message.PlayerId,
resultSelector: (player, messageEnumerable) => new { player, messageEnumerable }
)
.SelectMany(
collectionSelector: it => it.messageEnumerable.DefaultIfEmpty(),
resultSelector: (it, message) => new DisplayMessage(
PlayerName: it.player.Name,
MessageContent: message?.Content ?? ""
)
);
foreach (var displayMessage in displayMessages)
{
Console.WriteLine(displayMessage);
}
実行結果は次のとおりです。
DisplayMessage { PlayerName = たろう, MessageContent = こんにちは }
DisplayMessage { PlayerName = たろう, MessageContent = 元気ですか? }
DisplayMessage { PlayerName = たろう, MessageContent = ゲームをしましょう }
DisplayMessage { PlayerName = じろう, MessageContent = やあ }
DisplayMessage { PlayerName = じろう, MessageContent = 元気です }
DisplayMessage { PlayerName = じろう, MessageContent = もちろん! }
DisplayMessage { PlayerName = さぶろう, MessageContent = おはようございます }
DisplayMessage { PlayerName = さぶろう, MessageContent = あなたはどうですか? }
DisplayMessage { PlayerName = しろう, MessageContent = おやすみなさい }
DisplayMessage { PlayerName = しろう, MessageContent = 私も元気です }
DisplayMessage { PlayerName = ごろう, MessageContent = }
公式ドキュメントの結合操作も参照してください。
まとめ
.NET 10にて、左外部結合 LeftJoinと右外部結 RightJoinが追加されるの、楽しみですね!