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


2025年リリース予定の.NET 10にて、LINQのLeftJoinメソッドとRightJoinメソッドが[追加されそう](https://github.com/dotnet/runtime/pull/110872)です。

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

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

# Joinメソッド

## Joinメソッドの概要

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

公式リファレンスは[こちら](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.join?view=net-9.0)。

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

* [`Join<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,TInner,TResult>)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.join?view=net-9.0#system-linq-enumerable-join-4(system-collections-generic-ienumerable((-0))-system-collections-generic-ienumerable((-1))-system-func((-0-2))-system-func((-1-2))-system-func((-0-1-3))))
* [`Join<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,TInner,TResult>, IEqualityComparer<TKey>)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.join?view=net-9.0#system-linq-enumerable-join-4(system-collections-generic-ienumerable((-0))-system-collections-generic-ienumerable((-1))-system-func((-0-2))-system-func((-1-2))-system-func((-0-1-3))-system-collections-generic-iequalitycomparer((-2))))

戻り値の型はどちらも、`IEnumerable<TResult>`です。

違いは、`IEqualityComparer<TKey>`の引数を取るかどうかです。

リレーショナデータベースに慣れている方は「内部結合」の実装だと思えば理解が早いでしょう。

## Joinメソッドの利用例

Joinメソッドの利用例を示します。次のようなrecordがあります。

```csharp
record MessageLog(
    string MessageId,
    string PlayerId,
    string Content,
    DateTime CreatedAt
);

record Player(
    string Id,
    string Name
);

record DisplayMessage(
    string PlayerName,
    string MessageContent
);
```

次のような`messages`と`players`があります。

```csharp
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メソッドです。

```csharp
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メソッドを呼び出すだけでは、要素の列挙や結合はされません。

[現在の実装](https://github.com/dotnet/runtime/blob/v9.0.1/src/libraries/System.Linq/src/System/Linq/Join.cs)では、Joinメソッドの戻り値の1つ目を列挙した際に、innerが全て列挙される点に注意してください。ただし、outer(拡張関数のthis、先のコード例だとmessages)が空の場合、innerは列挙されません。

Joinメソッドはouterの順序を保持します。また、あるouterに一致するinnerの中では、innerの順序も保持します。
次のコードは、先の例のouterとinnerを入れ替えたコードです。

```csharp
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つ目シーケンスの中からキーに基づき一致する要素全てをグループ化したものを結合し、新しいシーケンスを作成するメソッドです。

公式リファレンスは[こちら](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.groupjoin?view=net-9.0)。

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

* [GroupJoin<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,IEnumerable<TInner>, TResult>)](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.groupjoin?view=net-8.0#system-linq-enumerable-groupjoin-4(system-collections-generic-ienumerable((-0))-system-collections-generic-ienumerable((-1))-system-func((-0-2))-system-func((-1-2))-system-func((-0-system-collections-generic-ienumerable((-1))-3))))
* [GroupJoin<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,IEnumerable<TInner>, TResult>, IEqualityComparer<TKey>)](https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.groupjoin?view=net-8.0#system-linq-enumerable-groupjoin-4(system-collections-generic-ienumerable((-0))-system-collections-generic-ienumerable((-1))-system-func((-0-2))-system-func((-1-2))-system-func((-0-system-collections-generic-ienumerable((-1))-3))-system-collections-generic-iequalitycomparer((-2))))

戻り値の型はどちらも、`IEnumerable<TResult>`です。

違いは、`IEqualityComparer<TKey>`の引数を取るかどうかです。

GroupJoinは、従来のリレーショナルデータベースの用語に直接相当するものはありません。「外部結合」に相当するわけではない点に注意してください。

## GroupJoinメソッドの利用例

GroupJoinメソッドの利用例を示します。次のようなrecordがあります。

```csharp
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`とがあります。

```csharp
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`のシーケンスを作成します。


```csharp
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メソッドを呼び出すだけでは、要素の列挙や結合はされません。

[現在の実装](https://github.com/dotnet/runtime/blob/v9.0.1/src/libraries/System.Linq/src/System/Linq/GroupJoin.cs)では、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を使って実現できます。

コード例を示します。

```csharp
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 =  }
```

公式ドキュメントの[結合操作](https://learn.microsoft.com/ja-jp/dotnet/csharp/linq/standard-query-operators/join-operations#perform-left-outer-joins)も参照してください。

# まとめ

.NET 10にて、左外部結合 LeftJoinと右外部結 RightJoinが追加されるの、楽しみですね！