1. 引言
当你只处理“平铺”的对象列表(比如我们前面讲过的商品列表或用户列表)时,一切都很简单:过滤、转换、排序都很顺手。但在真实的业务应用、面试(甚至作业!)中,经常会遇到集合里还套着集合的情况。
比如,有个User类,里面有个订单列表。或者说,有个Product类,里面有个评论列表。现在问题来了:怎么拿到所有用户的所有订单?或者收集所有商品的所有评论?再或者,怎么拿到所有订单里的所有商品?
这种情况下,普通的Select,甚至C#的魔法都不够用了。这时候就轮到主角SelectMany登场了,它能把“列表的列表”变成一个“列表”。
厨房类比
假如你有个大盒子,里面还有小盒子,小盒子里装着饼干,你的任务是把所有饼干倒进一个大碗里。你肯定不会一个盒子一个盒子地拿出来,每次只倒一块饼干,而是直接把所有盒子里的饼干全都倒进碗里。
这就是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>>。得到了“列表的列表”,也就是一堆订单集合。我们其实想要一个平铺的订单列表。
再试试这样:
var allProducts = users.Select(u => u.Orders)
.Select(oList => oList.Select(o => o.Products));
现在呢?现在我们得到了“列表的列表的列表”!结构反而更复杂了,没变简单。
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);
}
程序输出:
饼干
牛奶
巧克力
咖啡
茶
每一步发生了什么?
- users.SelectMany(u => u.Orders) — 把所有用户的订单“摊平”成一个订单集合(现在有3个订单)。
- .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 | |
|---|---|---|
| 返回 | “集合的集合” | 平铺集合 |
| 例子 | → |
→ |
| 适用场景 | 需要保留嵌套结构时 | 需要一个平铺序列时 |
表格:什么时候用哪个操作符
| 需求 | 用什么 | 结果 |
|---|---|---|
| 得到列表的列表(不扁平化) | Select | |
| 得到一个“平铺”的序列 | SelectMany | |
| 得到“父-子”对 | SelectMany带结果选择器 | |
| 只转换“平铺”集合的元素 | Select | |
7. 有用的小细节
Query Syntax语法(喜欢“SQL风格”的同学)
如果你喜欢表达力强的写法——LINQ还支持类似SQL的查询语法:
var allProducts = from user in users
from order in user.Orders
from product in order.Products
select product;
这段代码和SelectMany链式写法效果一样,只是看起来像多层“from”。
“扁平化”矩阵
SelectMany的经典用法——处理二维数组或列表的列表。
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的重载,可以返回内部集合元素的同时,还能带上外部的信息。
比如:要得到每个订单里每个商品的“用户名—商品名”对。
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能得到“平铺”集合。实际上它只要内部函数返回集合,就一定返回集合的集合。结果你得写双重循环,或者类型搞混。
第二个坑——没注意到“扁平化”会丢失外层信息:比如你只处理商品时,如果不用带结果选择器的重载,你就不知道这个商品属于哪个订单或哪个用户了。
第三个细节——如果你的内部列表可能是null,用SelectMany前一定要加保护:要么过滤,要么替换成空列表,否则你会比按F5还快遇到NullReferenceException。
GO TO FULL VERSION