1. 前言
想像一下經典的情境:你有兩個集合,它們邏輯上有關聯。比如說,商品分類清單跟商品清單。你要對每個分類拿到這個分類下的所有商品。或者有公司部門清單和員工清單,要列出每個部門的所有員工。
在 SQL 裡這叫「群組連接」(GROUP JOIN,更精確一點是 LEFT OUTER JOIN 加上分組)。LINQ 有個專門的 operator —— GroupJoin。它有點像一般的 Join(每個左邊的記錄對應一個右邊的),又有點像根據 key 分組。GroupJoin 會把一個集合的每個元素,跟另一個集合所有關聯的元素組成一個集合。
比喻
如果一般的 Join 就像是把「爸爸和兒子」根據姓氏配對,那 GroupJoin 就是建一棵樹:每個爸爸底下放他所有的兒子。
圖解
分類 商品
+--------------+ +---------------------+
| Id | 名稱 | | 名稱 | CatId |
+----+---------+ +-----------+---------+
| 1 | 麵包 | ---> | 法國麵包 | 1 |
| 2 | 飲料 | | 香腸 | 3 |
| 3 | 肉類 | | 百事可樂 | 2 |
| | | | 茶 | 2 |
+----+---------+ +-----------+---------+
經過 GroupJoin 之後:
- 麵包 — [法國麵包]
- 飲料 — [百事可樂, 茶]
- 肉類 — [香腸]
2. 方法簽名與基本概念
擴充方法
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // 「外部」集合(例如分類)
IEnumerable<TInner> inner, // 「內部」集合(例如商品)
Func<TOuter, TKey> outerKeySelector, // 怎麼從外部元素拿 key
Func<TInner, TKey> innerKeySelector, // 怎麼從內部元素拿 key
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // 組合結果物件/記錄的工廠
)
- outer:要遍歷的集合,會把元素 join 進來(例如分類)。
- inner:要被 join 進來的集合(例如商品)。
- outerKeySelector:lambda,回傳「左邊」元素的 key。
- innerKeySelector:lambda,回傳「右邊」元素的 key。
- resultSelector:function,決定每組(左+右群組)結果長什麼樣。
3. 實作範例:分類和商品
假設我們有這樣的 model:
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
範例集合:
var categories = new List<Category>
{
new Category { Id = 1, Name = "麵包" },
new Category { Id = 2, Name = "飲料" },
new Category { Id = 3, Name = "肉類" }
};
var products = new List<Product>
{
new Product { Name = "法國麵包", CategoryId = 1 },
new Product { Name = "百事可樂", CategoryId = 2 },
new Product { Name = "茶", CategoryId = 2 },
new Product { Name = "香腸", CategoryId = 3 }
};
用 GroupJoin(方法語法)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // 分類的 key
product => product.CategoryId, // 商品的 key
(category, prods) => new // 直接組結果
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // 這個分類下所有商品名稱
}
);
怎麼遍歷結果:
foreach (var group in groupJoin)
{
Console.WriteLine($"分類: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
輸出:
分類: 麵包
- 法國麵包
分類: 飲料
- 百事可樂
- 茶
分類: 肉類
- 香腸
4. GroupJoin:查詢語法(Query Syntax)
LINQ 支援很像 SQL 的語法。group join 用 join ... into ...,這種查詢跟上面的方法幾乎一樣。
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
這很像 SQL 查詢裡的 LEFT OUTER JOIN ... GROUP BY。
圖解:GroupJoin 怎麼運作
[分類] [商品] 分組(GroupJoin)
麵包 --------> 法國麵包 => 麵包: [法國麵包]
飲料 --------> 百事可樂 => 飲料: [百事可樂, 茶]
飲料 --------> 茶
肉類 --------> 香腸 => 肉類: [香腸]
每個分類都會有自己的「口袋」(IEnumerable<Product>),裡面放這個分類下的所有商品。
5. 特點與陷阱
GroupJoin vs. 一般 Join
一般 Join 跟 GroupJoin 差別在於結果數量。Join 每個 match 回傳一組 pair,GroupJoin 則是每個外部元素回傳一個,裡面包一個所有 match 的集合。
如果有分類沒有商品,用 GroupJoin 它還是會出現,只是商品集合是空的。這就跟 SQL 的 LEFT OUTER JOIN(左外連接)一樣。
來個沒有商品的分類範例:
categories.Add(new Category { Id = 4, Name = "起司" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"分類: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (沒有商品)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
分類: 麵包
- 法國麵包
分類: 飲料
- 百事可樂
- 茶
分類: 肉類
- 香腸
分類: 起司
(沒有商品)
這種情境在商業應用很常見:要顯示所有分類(或群組),即使有些裡面沒有東西。
用 GroupBy 來實作?千萬別!
很多人會搞混,想用雙重 GroupBy 來「模擬」 GroupJoin。別這樣 —— GroupJoin 就是為這種需求設計的,直接做「左連接」最原生。
6. 實戰應用
延續前面的課程,來給我們的學習 app 加個報表功能:「每個分類下有哪些商品」。這在電商、CRM、會計或報表系統都很常見。
加到 demo app 的程式碼:
// 假設已經有 Category 和 Product 類別,集合也建好了
Console.WriteLine("分類與商品報表:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"分類: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (沒有商品)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. 巢狀群組與聚合運算
GroupJoin 可以跟聚合函式一起用,做更複雜的報表。
範例:算每個分類下有幾個商品
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // 聚合函式!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count} 個商品");
}
假設 categories 跟 products 集合內容如下:
var categories = new[]
{
new { Id = 1, Name = "水果" },
new { Id = 2, Name = "蔬菜" },
new { Id = 3, Name = "乳製品" }
};
var products = new[]
{
new { Id = 1, Name = "蘋果", CategoryId = 1 },
new { Id = 2, Name = "香蕉", CategoryId = 1 },
new { Id = 3, Name = "紅蘿蔔", CategoryId = 2 }
};
Console 輸出會是:
分類: 水果 — 2 個商品
分類: 蔬菜 — 1 個商品
分類: 乳製品 — 0 個商品
8. GroupJoin 與新手常見錯誤
常見錯誤 —— 以為 GroupJoin 會回傳扁平表格的 pair,就像一般 Join。這裡的「扁平」意思是每一行就是一個 pair:一個外部元素配一個對應的內部元素(像 SQL INNER JOIN 之後的表格)。
這對有用過資料庫的人特別容易搞混:他們會以為 GroupJoin 跟 LEFT JOIN 一樣,但回傳的是一行一 pair,不是群組。其實 GroupJoin 回傳的是外部集合的元素加上一個相關內部元素的集合 —— 本質上是巢狀結構。
記得要「展開」巢狀集合(比如用 SelectMany),如果你想要一般的 pair 序列。
另一個常見錯誤 —— 忘了沒 match 的元素會拿到空集合。這是預設行為,不是 bug —— 但要記得,不然會納悶為什麼輸出「沒東西」。
什麼時候該用 GroupJoin?什麼時候不要?
用 GroupJoin 的時機:
- 你有兩組資料(像部門和員工、分類和商品),要階層式顯示:每個「父」底下所有「子」。
- 要做複雜報表,需要顯示所有主體元素,即使「子」是空的。
- 要做 SQL LEFT OUTER JOIN 加 key 分組的效果。
不要用 GroupJoin 在只要交集或一對一 pair 的情境 —— 這種用一般 Join 就好。
GO TO FULL VERSION