[C#] Linq進階API用法 & 組合技 (SelectMany, ToLookup, 笛卡兒積...)
C# / LINQ
用「資料形狀」思考 LINQ:SelectMany 與 10 個進階 API
Select、Where、GroupBy 之後的下一步。每個範例都標出資料怎麼進、結果怎麼出——綠色註解就是答案。
進階 LINQ 有個比背定義更好用的讀法:看它讓資料的「形狀」怎麼變。巢狀變扁平、兩條變一條、一條變一個值——記住形狀,API 自然就記住了。這篇每個 API 都附上一行形狀簽名,以及完整的輸入資料與輸出結果。
//SelectMany:進階 LINQ 的入口
它做的事只有一件:把「集合中的集合」攤平成一層。但三種用法各有妙處。
帶 result selector
最實用的 overload:攤平的同時保留父層資訊。第二個參數同時拿得到父元素和子元素。
// 資料
var orders = new[] {
new { OrderId = 1, Customer = "小明", Items = new[] {
new { Product = "滑鼠", Price = 500 },
new { Product = "鍵盤", Price = 1200 } } },
new { OrderId = 2, Customer = "小華", Items = new[] {
new { Product = "螢幕", Price = 4500 } } }
};
var flat = orders.SelectMany(o => o.Items,
(o, item) => new { o.OrderId, o.Customer, item.Product, item.Price });
// 結果:2 筆訂單 × 各自明細 → 攤成 3 列,每列都帶著父層的 Customer
// { OrderId=1, Customer=小明, Product=滑鼠, Price=500 }
// { OrderId=1, Customer=小明, Product=鍵盤, Price=1200 }
// { OrderId=2, Customer=小華, Product=螢幕, Price=4500 }
笛卡兒積(Cross Join)
內層集合不依賴外層元素時,就會產生所有組合。
// 資料
var sizes = new[] { "S", "M" };
var colors = new[] { "紅", "藍" };
var skus = sizes.SelectMany(s => colors, (s, c) => $"{s}-{c}");
// 結果:2 × 2 = 4 種組合
// "S-紅", "S-藍", "M-紅", "M-藍"
攤平一對多轉換
每個元素展開成多個(斷詞、拆逗號字串),再全部攤平。
// 資料
var articles = new[] {
new { Title = "文章A", Tags = "C#,LINQ" },
new { Title = "文章B", Tags = "LINQ, .NET" }
};
var allTags = articles
.SelectMany(a => a.Tags.Split(','))
.Select(t => t.Trim())
.Distinct();
// 結果:"C#", "LINQ", ".NET" (LINQ 出現兩次被去重)
//10 個進階 API
每張卡片:形狀簽名 → 一句話 → 看得到頭尾的範例。
Aggregate
自訂累加(reduce / fold)。Sum、Max 其實都是它的特例。
// 資料
var segments = new[] { "usr", "local", "bin" };
var path = segments.Aggregate((acc, s) => acc + "/" + s);
// 結果:"usr/local/bin"
// 帶 seed:一次走訪算出統計
var levels = new[] { "Info", "Error", "Info", "Warning", "Error" };
var counts = levels.Aggregate(
new Dictionary<string, int>(),
(acc, l) => { acc[l] = acc.GetValueOrDefault(l) + 1; return acc; });
// 結果:{ "Info": 2, "Error": 2, "Warning": 1 }
Zip
三條序列需 .NET 6+兩條序列逐對配對,長度取短的那條,多出來的直接丟掉。
// 資料(注意長度不同)
var names = new[] { "小明", "小華", "小美" };
var scores = new[] { 90, 85 };
var labeled = names.Zip(scores, (n, s) => $"{n}: {s} 分");
// 結果:只有 2 筆,小美被丟掉了
// "小明: 90 分", "小華: 85 分"
Chunk
.NET 6+切批次。呼叫外部 API、批次寫 DB 的救星。
// 資料
var ids = new[] { 1, 2, 3, 4, 5, 6, 7 };
var batches = ids.Chunk(3);
// 結果:最後一批不足 3 個也照給
// [1, 2, 3]
// [4, 5, 6]
// [7]
ToLookup
像 GroupBy 但立即執行,而且可以用 key 索引。查不存在的 key 回空集合而不是丟例外——這點比 ToDictionary 好用很多。
// 資料
var errors = new[] {
new { Severity = "Fatal", Msg = "DB 掛了" },
new { Severity = "Warning", Msg = "磁碟空間低" },
new { Severity = "Warning", Msg = "回應慢" }
};
var byLevel = errors.ToLookup(e => e.Severity);
// 結果:可以直接查 key 的分組
// byLevel["Warning"] → { 磁碟空間低 }, { 回應慢 }
// byLevel["Fatal"] → { DB 掛了 }
// byLevel["Info"] → 空集合(不存在也不會爆)
Join / GroupJoin
記憶體內的關聯查詢。Join 是 inner join,配不到的直接消失;GroupJoin 是一對多,每個父元素帶著一串子元素。
// 資料
var customers = new[] {
new { Id = 1, Name = "小明" },
new { Id = 2, Name = "小華" },
new { Id = 3, Name = "小美" } // 沒有任何訂單
};
var orders = new[] {
new { OrderId = 101, CustomerId = 1, Total = 500 },
new { OrderId = 102, CustomerId = 1, Total = 1200 },
new { OrderId = 103, CustomerId = 2, Total = 4500 }
};
// Join(inner join)
var joined = customers.Join(orders, c => c.Id, o => o.CustomerId,
(c, o) => new { c.Name, o.Total });
// 結果:配不到的小美直接消失
// { 小明, 500 }, { 小明, 1200 }, { 小華, 4500 }
// GroupJoin(一對多)
var summary = customers.GroupJoin(orders, c => c.Id, o => o.CustomerId,
(c, os) => new { c.Name, Count = os.Count(), Total = os.Sum(o => o.Total) });
// 結果:每個客戶一列,小美留下來但是 0
// { 小明, Count=2, Total=1700 }
// { 小華, Count=1, Total=4500 }
// { 小美, Count=0, Total=0 }
DistinctBy / MaxBy / MinBy
.NET 6+用 key 去重、取極值,而且回傳的是整個物件——不像 Max 只回傳值。
// 資料
var logins = new[] {
new { UserId = 1, Time = "09:00" },
new { UserId = 2, Time = "09:05" },
new { UserId = 1, Time = "10:00" }
};
var unique = logins.DistinctBy(l => l.UserId);
// 結果:每個 UserId 留「第一次出現」的那筆
// { UserId=1, Time=09:00 }, { UserId=2, Time=09:05 }
var latest = logins.MaxBy(l => l.Time);
// 結果:回傳整個物件,不是只有時間值
// { UserId=1, Time=10:00 }
TakeWhile / SkipWhile
依條件「切斷」而不是「過濾」。和 Where 的差別是遇到第一個不符合就停,對已排序資料特別有效率。
// 資料(注意最後有個 200)
var prices = new[] { 300, 800, 1500, 200 };
var a = prices.TakeWhile(p => p < 1000);
// 結果:[300, 800] —— 走到 1500 就停,後面的 200 連看都不看
var b = prices.Where(p => p < 1000);
// 結果:[300, 800, 200] —— 全部掃過,200 也留下
Except / Intersect / UnionBy
*By 系列需 .NET 6+集合運算:差集、交集、依 key 合併。資料同步、差異比對必備。
// 資料
var oldIds = new[] { 1, 2, 3 };
var newIds = new[] { 2, 3, 4 };
var removed = oldIds.Except(newIds); // 結果:[1]
var added = newIds.Except(oldIds); // 結果:[4]
// 資料
var listA = new[] { new { Id = 1, Name = "A版-甲" }, new { Id = 2, Name = "A版-乙" } };
var listB = new[] { new { Id = 2, Name = "B版-乙" }, new { Id = 3, Name = "B版-丙" } };
var merged = listA.UnionBy(listB, x => x.Id);
// 結果:Id 重複時保留「先出現」(A 版)的那筆
// { 1, A版-甲 }, { 2, A版-乙 }, { 3, B版-丙 }
OfType<T>
過濾 + 轉型一次完成,不符合的默默跳過。對照組 Cast<T> 遇到不符合會直接丟例外。
// 資料
var mixed = new object[] { "hello", 42, "world", null, 3.14 };
var strings = mixed.OfType<string>();
// 結果:["hello", "world"] —— 非字串和 null 都默默跳過
// 對照:mixed.Cast<string>() 遇到 42 直接丟 InvalidCastException
DefaultIfEmpty
空集合時補一個預設值。是實現 left join 的關鍵零件,也能避免 First() 在空集合上爆炸。
// 資料
var candidates = new List<string>(); // 空的
var fallback = "預設方案";
var pick = candidates.DefaultIfEmpty(fallback).First();
// 結果:"預設方案" —— 直接 .First() 會丟 InvalidOperationException
var pick2 = new[] { "方案A" }.DefaultIfEmpty(fallback).First();
// 結果:"方案A" —— 非空時 fallback 不會出現
//組合技
單一 API 是零件,組起來才是武器。
GroupJoin + SelectMany + DefaultIfEmpty
經典模式。沿用上面 Join 那組的 customers 和 orders(小美沒訂單)。
var leftJoin = customers
.GroupJoin(orders, c => c.Id, o => o.CustomerId, (c, os) => new { c, os })
.SelectMany(x => x.os.DefaultIfEmpty(),
(x, o) => new { x.c.Name, Total = o?.Total ?? 0 });
// 結果:和 inner join 比較,小美以 Total=0 留下來了
// { 小明, 500 }, { 小明, 1200 }, { 小華, 4500 }, { 小美, 0 }
SelectMany + GroupBy
所有訂單加總各商品銷量——先打掉「訂單」這層,再依「商品」重組。
// 資料
var orders2 = new[] {
new { Items = new[] { new { Product = "滑鼠", Qty = 2 }, new { Product = "鍵盤", Qty = 1 } } },
new { Items = new[] { new { Product = "滑鼠", Qty = 1 } } }
};
var sales = orders2
.SelectMany(o => o.Items) // 先攤平:滑鼠2, 鍵盤1, 滑鼠1
.GroupBy(i => i.Product)
.Select(g => new { Product = g.Key, Qty = g.Sum(i => i.Qty) })
.OrderByDescending(x => x.Qty);
// 結果:{ 滑鼠, 3 }, { 鍵盤, 1 }
GroupBy + MaxBy
超常見的需求:每個使用者只留最後一筆紀錄。
// 資料
var logs = new[] {
new { UserId = 1, Time = "09:00", Action = "登入" },
new { UserId = 1, Time = "11:00", Action = "下單" },
new { UserId = 2, Time = "10:00", Action = "登入" }
};
var latestPerUser = logs
.GroupBy(l => l.UserId)
.Select(g => g.MaxBy(l => l.Time));
// 結果:每個 UserId 一筆,取時間最大的
// { UserId=1, 11:00, 下單 }
// { UserId=2, 10:00, 登入 }
Chunk + Task.WhenAll
100 個 URL 一次全開會被對方擋,一批 5 個剛剛好。
foreach (var batch in urls.Chunk(5))
await Task.WhenAll(batch.Select(u => FetchAsync(u)));
// 效果:每次最多 5 個請求同時飛,一批完成才換下一批
ExceptBy 雙向比對
.NET 6+同步兩份資料的標準開場。
// 資料
var existing = new[] { new { Id = 1, Name = "舊A" }, new { Id = 2, Name = "舊B" } };
var incoming = new[] { new { Id = 2, Name = "新B" }, new { Id = 3, Name = "新C" } };
var toAdd = incoming.ExceptBy(existing.Select(e => e.Id), x => x.Id);
var toDelete = existing.ExceptBy(incoming.Select(i => i.Id), x => x.Id);
// 結果:
// toAdd → { Id=3, 新C }
// toDelete → { Id=1, 舊A }
// (Id=2 兩邊都有,不動;要不要「更新」它得另外比內容)
//實戰:RulesEngine 的失敗子規則分類
把上面的零件組起來,解一個真實問題:RulesEngine 回傳的結果是一棵樹,想把所有失敗的子規則依 ErrorType 分類。
SelectMany + Where + GroupBy + ToDictionary
// 資料(示意 result.Results 的結構)
// 父規則A ── ChildResults:
// { Rule=檢查庫存, IsSuccess=false, Properties["ErrorType"]="Warning" }
// { Rule=檢查價格, IsSuccess=true }
// 父規則B ── ChildResults:
// { Rule=檢查會員, IsSuccess=false, Properties["ErrorType"]="Error" }
// { Rule=檢查地址, IsSuccess=false, Properties["ErrorType"]="Warning" }
var failuresByType = result.Results
.SelectMany(p => p.ChildResults ?? Enumerable.Empty<RuleResultTree>())
.Where(q => !q.IsSuccess)
.GroupBy(q => q.Rule.Properties?.GetValueOrDefault("ErrorType")?.ToString() ?? "Error")
.ToDictionary(g => g.Key, g => g.Select(q => q.Rule.RuleName).ToList());
// 結果:
// {
// "Warning": [ "檢查庫存", "檢查地址" ], ← 跨越了父規則A和B
// "Error": [ "檢查會員" ]
// }
// (檢查價格因為 IsSuccess=true 在 Where 就被濾掉了)
//收尾:形狀對照表
想不起某個 API 做什麼的時候,回想它讓資料形狀怎麼變,通常比背定義有用。
| API | 形狀變化 |
|---|---|
SelectMany | 巢狀 → 扁平 |
GroupBy / ToLookup | 扁平 → 分組 |
Join / GroupJoin / Zip | 兩條 → 一條 |
Chunk | 一條 → 分段 |
Aggregate | 一條 → 一個值 |
Except / Intersect / UnionBy | 兩堆 → 集合運算 |
.ToList() 物化,不然整條 pipeline 會重跑;ToLookup、ToDictionary、Chunk 拿到的則是已經算好的結果。另外 Chunk 和整個 *By 家族(DistinctBy、MaxBy、UnionBy、ExceptBy…)都是 .NET 6 之後才有,舊專案要注意版本。
留言
張貼留言