2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINQのJoinメソッド・GroupJoinメソッドについて

Posted at

2025年リリース予定の.NET 10にて、LINQのLeftJoinメソッドとRightJoinメソッドが追加されそうです。

それに備えて、LINQ登場時から存在するJoinメソッド・GroupJoinメソッドをおさらいしましょう。

※ 本記事では、LINQ to ObjectsのJoinメソッド・GroupJoinメソッドについて説明します。

Joinメソッド

Joinメソッドの概要

Joinメソッドは、2つのシーケンスを一致するキーに基づいて要素を結合・変換し、新しいシーケンスを作成するメソッドです。

公式リファレンスはこちら

次の2つのオーバーロードがあります。

戻り値の型はどちらも、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
);

次のようなmessagesplayersがあります。

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: "ごろう")
};

このmessagesplayersから、「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つのオーバーロードがあります。

戻り値の型はどちらも、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))}";
    }
}

次のようなteamsmemberListとがあります。

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"),
};

このteamsmemberListから、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が追加されるの、楽しみですね!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?