[C#] Linq進階API用法 & 組合技 (SelectMany, ToLookup, 笛卡兒積...)

C# / LINQ

用「資料形狀」思考 LINQ:SelectMany 與 10 個進階 API

SelectWhereGroupBy 之後的下一步。每個範例都標出資料怎麼進、結果怎麼出——綠色註解就是答案


進階 LINQ 有個比背定義更好用的讀法:看它讓資料的「形狀」怎麼變。巢狀變扁平、兩條變一條、一條變一個值——記住形狀,API 自然就記住了。這篇每個 API 都附上一行形狀簽名,以及完整的輸入資料與輸出結果。

//SelectMany:進階 LINQ 的入口

它做的事只有一件:把「集合中的集合」攤平成一層。但三種用法各有妙處。

1 / 3

帶 result selector

[[a, b], [c]] → [a, b, c] (每個元素還記得自己的爸爸)

最實用的 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 }
2 / 3

笛卡兒積(Cross Join)

[S, M] × [紅, 藍] → 4 種組合

內層集合不依賴外層元素時,就會產生所有組合。

// 資料
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-藍"
3 / 3

攤平一對多轉換

["a,b", "b,c"] → [a, b, c]

每個元素展開成多個(斷詞、拆逗號字串),再全部攤平。

// 資料
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

每張卡片:形狀簽名 → 一句話 → 看得到頭尾的範例。

01

Aggregate

[a, b, c] → x

自訂累加(reduce / fold)。SumMax 其實都是它的特例。

// 資料
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 }
02

Zip

三條序列需 .NET 6+
[a, b, c] + [1, 2] → [(a,1), (b,2)]

兩條序列逐對配對,長度取短的那條,多出來的直接丟掉。

// 資料(注意長度不同)
var names  = new[] { "小明", "小華", "小美" };
var scores = new[] { 90, 85 };

var labeled = names.Zip(scores, (n, s) => $"{n}: {s} 分");

// 結果:只有 2 筆,小美被丟掉了
// "小明: 90 分", "小華: 85 分"
03

Chunk

.NET 6+
[a, b, c, d, e] → [a, b] [c, d] [e]

切批次。呼叫外部 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]
04

ToLookup

[a1, b1, a2] → { a: [a1, a2], b: [b1] }

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"]    → 空集合(不存在也不會爆)
05

Join / GroupJoin

[a] ⋈ [b] → [(a, b)]

記憶體內的關聯查詢。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 }
06

DistinctBy / MaxBy / MinBy

.NET 6+
[a1, a2, b1] → [a1, b1]

用 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 }
07

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 也留下
08

Except / Intersect / UnionBy

*By 系列需 .NET 6+
[a, b, c] − [b, c, d] → [a]

集合運算:差集、交集、依 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版-丙 }
09

OfType<T>

[a, 1, b, null] → [a, b]

過濾 + 轉型一次完成,不符合的默默跳過。對照組 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
10

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

= LEFT JOIN(沒配到的也留下)

經典模式。沿用上面 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兩堆 → 集合運算
兩個通用提醒:這些 API 大多是延遲執行(deferred execution),查詢鏈如果會被列舉多次,記得在適當的點 .ToList() 物化,不然整條 pipeline 會重跑;ToLookupToDictionaryChunk 拿到的則是已經算好的結果。另外 Chunk 和整個 *By 家族(DistinctByMaxByUnionByExceptBy…)都是 .NET 6 之後才有,舊專案要注意版本。

留言

這個網誌中的熱門文章

[C#] 無法載入檔案或組件 或其相依性的其中之一。 找到的組件資訊清單定義與組件參考不符。 (發生例外狀況於 HRESULT: 0x80131040)

[SQL] SQL Server 批次產生單號:流水號與驗證碼實作

[C#] 需有 Oracle 用戶端軟體版本 8.1.7 或以上版本