CodeGym /課程 /C# SELF /用 SelectMany 來「攤平」集合

用 SelectMany 來「攤平」集合

C# SELF
等級 31, 課堂 4
開放

1. 前言

當你只是在處理「平坦」的物件清單(比如我們之前課程裡的商品清單或用戶清單),一切都很簡單:filter、transform、sort 隨你玩。但在真實的商業應用、面試(甚至是作業!)裡,常常會遇到裡面還包著其他集合的集合。

比如說,有個 User 類別,裡面有訂單清單。或者 Product 類別,裡面有評論清單。現在問題來了:怎麼拿到所有用戶的所有訂單?或者把所有商品的所有評論都收集起來?還是你要拿到所有訂單裡的所有商品?

這種時候,單靠 Select,甚至 C# 的魔法都不夠力。這時候就要請出主角 — SelectMany,它能把「list of lists」變成單一「list」。

廚房比喻

如果你有個箱子,裡面還有箱子,箱子裡有餅乾,你的任務是把所有餅乾倒進一個大碗。你不會一個箱子一個箱子慢慢倒,而是直接把所有箱子裡的餅乾全倒進碗裡。

這就是 SelectMany 在做的事:它把「箱子裡的箱子」攤平成一個「碗」。

2. 回顧一下我們的應用範例

前幾堂課我們做了一個簡單的用戶和商品管理應用。這次我們要加點「巢狀」元素的集合。

假設我們有這些類別:


public class User
{
    public string Name { get; set; }
    public List<Order> Orders { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public List<Product> Products { get; set; }
}

public class Product
{
    public string Name { get; set; }
}

現在我們有一個用戶清單,每個用戶有訂單清單,每個訂單又有商品清單。

怎麼拿到所有用戶所有訂單裡的所有商品?

假設我們要把所有商品都攤平成一個「碗」。如果你直接用 Select,會怎樣?


List<User> users = ...; // 假設資料已經有了

var allOrders = users.Select(u => u.Orders);

變數 allOrders 的型別會是... IEnumerable<List<Order>>。你拿到的是「list of lists」,也就是一堆訂單集合。我們想要的是一條平坦的訂單清單。

再試一次:


var allProducts = users.Select(u => u.Orders)
                       .Select(oList => oList.Select(o => o.Products));

現在呢?你得到的是「list of lists of lists」!反而把結構搞更複雜了,完全沒簡化。

3. 解法:SelectMany 操作符

這就是 SelectMany 的威力。它的任務就是把巢狀集合「攤平」(flatten)成一個。

基本語法:


collection.SelectMany(item => item.內部集合)

我們這個例子:


var allOrders = users.SelectMany(u => u.Orders);
// 型別:IEnumerable<Order>

現在你就有所有用戶的所有訂單的單一序列了。

再深入一點!所有用戶所有訂單裡的所有商品:


var allProducts = users
    .SelectMany(u => u.Orders)
    .SelectMany(o => o.Products);
// 型別:IEnumerable<Product>

如果要一行寫完?

可以這樣:


var allProducts = users.SelectMany(u => u.Orders.SelectMany(o => o.Products));

這串鍊建議還是分段讀,除非你是機器人。

4. 大範例來拆解

我們來做點測試資料,實際看看 SelectMany 怎麼運作。


var users = new List<User>
{
    new User
    {
        Name = "愛麗絲",
        Orders = new List<Order>
        {
            new Order
            {
                Id = 1,
                Products = new List<Product>
                {
                    new Product { Name = "餅乾" },
                    new Product { Name = "牛奶" },
                }
            },
            new Order
            {
                Id = 2,
                Products = new List<Product>
                {
                    new Product { Name = "巧克力" }
                }
            }
        }
    },
    new User
    {
        Name = "鮑勃",
        Orders = new List<Order>
        {
            new Order
            {
                Id = 3,
                Products = new List<Product>
                {
                    new Product { Name = "咖啡" },
                    new Product { Name = "茶" }
                }
            }
        }
    }
};

把所有商品「倒進一個碗」


var allProducts = users
    .SelectMany(u => u.Orders)
    .SelectMany(o => o.Products);

foreach (var product in allProducts)
{
    Console.WriteLine(product.Name);
}

程式執行結果:

餅乾
牛奶
巧克力
咖啡
茶

每一步發生什麼事?

  1. users.SelectMany(u => u.Orders) — 把所有用戶的訂單攤平成一個訂單集合(現在有 3 筆訂單)。
  2. .SelectMany(o => o.Products) — 把所有訂單裡的商品攤平。

注意: 如果你用 Select,你會拿到集合的集合(例如 IEnumerable<List<Product>>)。用 SelectMany 就能拿到「平坦」的商品集合:IEnumerable<Product>。

5. 圖解

用 SelectMany 前


users (List<User>)
   └── User #1
   │      └── Orders (List<Order>)
   │           ├── Order #1 -> Products (List<Product>)
   │           └── Order #2 -> Products (List<Product>)
   └── User #2
          └── Orders (List<Order>)
               └── Order #3 -> Products (List<Product>)

第一次 SelectMany(u => u.Orders) 後


IEnumerable<Order>: [Order #1, Order #2, Order #3]

第二次 SelectMany(o => o.Products) 後


IEnumerable<Product>: [餅乾, 牛奶, 巧克力, 咖啡, 茶]

6. 比較:Select vs SelectMany

Select SelectMany
回傳 集合的「集合」 平坦的集合
範例
users.Select(u => u.Orders)
→
IEnumerable<List<Order>>
users.SelectMany(u => u.Orders)
→
IEnumerable<Order>
用在 想保留巢狀結構時 想要一條平坦序列時

表格:什麼時候用哪個操作符

你要做什麼 用什麼 結果
拿到 list of lists(不攤平) Select
IEnumerable<ICollection<T>>
拿到一條「平坦」序列 SelectMany
IEnumerable<T>
拿到「父子」配對 SelectMany + 結果 selector
IEnumerable<CustomType>
只要轉換「平坦」集合的元素 Select
IEnumerable<TransformedType>

7. 有用的小細節

Query Syntax(SQL 風格)

如果你喜歡寫得像 SQL 一樣漂亮 — LINQ 也可以用這種語法:


var allProducts = from user in users
                  from order in user.Orders
                  from product in order.Products
                  select product;

這段跟 SelectMany 鏈起來一樣,只是看起來像多層的「from」。

攤平成矩陣

最經典的 SelectMany 用法 — 處理二維陣列或 list of lists。


List<List<int>> matrix = new List<List<int>>
{
    new List<int> { 1, 2, 3 },
    new List<int> { 4, 5 },
    new List<int> { 6 }
};

怎麼拿到所有數字的單一清單?


var flat = matrix.SelectMany(row => row);
foreach (var value in flat)
{
    Console.Write(value + " "); // 1 2 3 4 5 6
}

8. 進階語法

有時候用 SelectMany 的 overload,可以回傳內部元素同時帶外部資訊。

舉例:要拿到「用戶名 — 商品名」配對,每個訂單裡的每個商品都要。


var userProductPairs = users.SelectMany(
    user => user.Orders.SelectMany(
        order => order.Products,
        (order, product) => new { UserName = user.Name, ProductName = product.Name }
    )
);

foreach (var pair in userProductPairs)
{
    Console.WriteLine($"{pair.UserName} 訂購了 {pair.ProductName}");
}

這裡 lambda 的第二個參數就是外部集合的元素。這樣就不會「丟失」外層資訊。

9. 實用場景

一堆訂單,一堆商品

你是網店工程師。想算所有客戶買過多少種不同商品?這樣寫:


var uniqueProductNames = users
    .SelectMany(user => user.Orders)
    .SelectMany(order => order.Products)
    .Select(product => product.Name)
    .Distinct();

foreach (var name in uniqueProductNames)
{
    Console.WriteLine(name);
}

從階層攤平成平坦集合

你有公司部門清單,每個部門有員工。想拿到全公司所有員工清單:


var allEmployees = departments.SelectMany(d => d.Employees);

處理字串:把單字陣列攤平成字元


string[] words = { "hello", "world" };
var allChars = words.SelectMany(w => w.ToCharArray());
foreach (var c in allChars)
{
    Console.Write(c + " "); // h e l l o w o r l d
}

10. 常見錯誤與細節

現在你已經被 SelectMany 的威力圈粉了,來提醒幾個常見錯誤。

最常見的 — 以為 Select 會給你「平坦」集合。其實只要內部 function 回傳集合,它就永遠給你集合的集合。結果你要寫雙層迴圈或被型別搞混。

第二個錯誤 — 沒注意到「攤平」會讓你失去外層資訊:比如你只處理商品時,根本不知道它是哪個訂單、哪個用戶的,除非你用 overload 帶 selector。

第三個細節 — 如果你的內部清單可能是 null,用 SelectMany 前要加防護:要嘛先 filter,要嘛換成空清單,不然你按 F5 之前就先拿到 NullReferenceException 了。

2
任務
C# SELF, 等級 31, 課堂 4
上鎖
從巢狀清單中提取所有物品
從巢狀清單中提取所有物品
1
問卷/小測驗
LINQ 入門,等級 31,課堂 4
未開放
LINQ 入門
LINQ 基礎:簡單查詢跟資料過濾
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION