C# の LINQ で結合する Join

Join は2つのシーケンスをキーで結合するメソッドだ。SQL の内部結合(INNER JOIN)に相当する。

基本的な使い方

2つのコレクションを共通のキーで結合する。

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "Alice" },
    new Customer { Id = 2, Name = "Bob" },
    new Customer { Id = 3, Name = "Charlie" }
};

var orders = new List<Order>
{
    new Order { CustomerId = 1, Product = "Apple" },
    new Order { CustomerId = 2, Product = "Banana" },
    new Order { CustomerId = 1, Product = "Cherry" }
};

var result = customers.Join(
    orders,
    customer => customer.Id,        // 外側のキー
    order => order.CustomerId,      // 内側のキー
    (customer, order) => new        // 結果の形
    {
        customer.Name,
        order.Product
    }
);

foreach (var item in result)
{
    Console.WriteLine($"{item.Name}: {item.Product}");
}
// Alice: Apple
// Alice: Cherry
// Bob: Banana

Join の引数は4つある。

結合する相手のシーケンス
外側シーケンスのキーセレクタ
内側シーケンスのキーセレクタ
結果を作成する関数

キーが一致する要素同士が組み合わされる。Alice は2つの注文を持つので、結果も2行になる。

クエリ構文での表現

クエリ構文では join ... on ... equals を使う。

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "Alice" },
    new Customer { Id = 2, Name = "Bob" }
};

var orders = new List<Order>
{
    new Order { CustomerId = 1, Product = "Apple" },
    new Order { CustomerId = 2, Product = "Banana" }
};

var result = from c in customers
             join o in orders on c.Id equals o.CustomerId
             select new { c.Name, o.Product };

foreach (var item in result)
{
    Console.WriteLine($"{item.Name}: {item.Product}");
}

on ... equals の左側が外側シーケンスのキー、右側が内側シーケンスのキーだ。順番を間違えるとエラーになる。

内部結合の性質

Join は内部結合なので、キーが一致しない要素は結果に含まれない。

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "Alice" },
    new Customer { Id = 2, Name = "Bob" },
    new Customer { Id = 3, Name = "Charlie" }  // 注文なし
};

var orders = new List<Order>
{
    new Order { CustomerId = 1, Product = "Apple" },
    new Order { CustomerId = 2, Product = "Banana" },
    new Order { CustomerId = 99, Product = "Unknown" }  // 顧客なし
};

var result = customers.Join(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => new { c.Name, o.Product }
);

foreach (var item in result)
{
    Console.WriteLine($"{item.Name}: {item.Product}");
}
// Alice: Apple
// Bob: Banana

Charlie は注文を持たないので結果に出ない。CustomerId = 99 の注文は対応する顧客がいないので無視される。

左外部結合(GroupJoin + SelectMany)

すべての顧客を結果に含めたい場合は、GroupJoinSelectMany を組み合わせる。

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "Alice" },
    new Customer { Id = 2, Name = "Bob" },
    new Customer { Id = 3, Name = "Charlie" }
};

var orders = new List<Order>
{
    new Order { CustomerId = 1, Product = "Apple" },
    new Order { CustomerId = 1, Product = "Cherry" }
};

var result = customers.GroupJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (customer, customerOrders) => new { customer, customerOrders }
)
.SelectMany(
    x => x.customerOrders.DefaultIfEmpty(),
    (x, order) => new 
    {
        x.customer.Name,
        Product = order?.Product ?? "(なし)"
    }
);

foreach (var item in result)
{
    Console.WriteLine($"{item.Name}: {item.Product}");
}
// Alice: Apple
// Alice: Cherry
// Bob: (なし)
// Charlie: (なし)

DefaultIfEmpty() が空のシーケンスに対してデフォルト値(null)を返すので、注文のない顧客も結果に含まれる。

クエリ構文ではもう少し読みやすく書ける。

var result = from c in customers
             join o in orders on c.Id equals o.CustomerId into customerOrders
             from o in customerOrders.DefaultIfEmpty()
             select new 
             {
                 c.Name,
                 Product = o?.Product ?? "(なし)"
             };

into でグループ化し、DefaultIfEmpty() で左外部結合を実現している。

複合キーでの結合

複数のプロパティをキーにする場合は、匿名型を使う。

var result = from a in tableA
             join b in tableB 
             on new { a.Year, a.Month } equals new { b.Year, b.Month }
             select new { a, b };

匿名型のプロパティ名が一致していないと結合できない。両方とも YearMonth という名前でなければならない。

パフォーマンスの考慮

Join は内部的にハッシュテーブルを使うため、大量のデータでも効率的に結合できる。

Join

ハッシュベース。O(n + m) の計算量で高速。

Where + Any で代用

ネストしたループ。O(n × m) で大量データには不向き。

2つのシーケンスを結合する場合は、素朴な Where + Any よりも Join を使う方がパフォーマンスが良い。