ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# LINQ SelectMany实战:从嵌套循环到数据扁平化

C# LINQ SelectMany实战:从嵌套循环到数据扁平化 1. 多层集合遍历的本能写法与 SelectMany 的思维切换1.1 三层 for 循环背后的控制流思维做 .NET 的朋友大多都有这种经历需求本身很简单——要把一个客户的订单明细汇总成一张总表我当时的本能反应是堆循环。第一层遍历客户第二层遍历订单第三层遍历订单项遇到需要去重的地方还要在外层维护一个集合。代码缩进一层比一层深像极了层层包裹的购物袋功能倒是没问题单元测试也能过。var customers GetCustomers(); var result new ListOrderItem(); foreach (var customer in customers) { foreach (var order in customer.Orders) { foreach (var item in order.Items) { if (item.Status OrderStatus.Paid) { result.Add(item); } } } }这种写法的问题不在性能而在表达失真。你明明想要的是订单项这个一维集合代码却写成了客户集合里的订单集合里的订单项集合把数据的真实形状搞复杂了。Code Review 的时候同事问了一句为什么不用 SelectMany我当时愣了一下因为压根没意识到还有这种思路。后来我想明白了嵌套循环背后的思维是控制流思维你关心的是怎么一个格子一个格子走过去而 SelectMany 背后的思维是数据流思维你只需要描述我要把客户展开成订单再把订单展开成订单项最后合成一个平铺的序列。这两种思维的差距就是普通写法和表达清晰的写法之间最关键的差距。1.2 用 SelectMany 重新描述需求之后把上面那段循环改成 SelectMany直观的感受是代码开始说实话var paidItems customers .SelectMany(c c.Orders) .SelectMany(o o.Items) .Where(i i.Status OrderStatus.Paid);第一次 SelectMany 把客户序列展开成订单序列第二次把订单序列继续展开成订单项序列。整个过程就是典型的数据扁平化把多级嵌套集合摊平成一维集合。注意这里我用了两次 SelectMany它不是只能处理一层而是每一层都要显式指定展开规则层级再多也只是链式调用继续往后接。与其说 SelectMany 是一个扁平化方法不如说它是一把处理一对多关系的瑞士军刀。只要你的数据结构里存在一个对象包含一个集合而这个集合里的元素又继续包含集合这种嵌套SelectMany 就比手动循环更安全、更简洁、更容易维护。我自己在带新人时最常说的一句话是当你看到IEnumerableIEnumerableT这种类型出现在代码里第一个反应应该是——这里是不是应该用 SelectMany如果答案是是马上改掉别等 Code Review 再被拎出来。2. 重载矩阵集合选择器、ResultSelector 与带索引版本2.1 第一种重载集合选择器最容易被记住也最常用SelectMany 最常见的重载签名是这样的public static IEnumerableTResult SelectManyTSource, TResult( this IEnumerableTSource source, FuncTSource, IEnumerableTResult selector);它做的事可以拆成两步先对源序列里的每个元素调用 selector得到一个内层集合再把这个内层集合的所有元素按顺序合并成一个大的一维序列。很多人第一次接触它会误以为它只是在把二维变一维其实光看参数就知道selector 是可以返回任意集合的不要求源一定是二维的。举个最不起眼但是特别能说明问题的例子var numbers new[] { 1, 2, 3 }; var result numbers .SelectMany(n Enumerable.Repeat($数字{n}, n)); // 执行结果数字1, 数字2, 数字2, 数字3, 数字3, 数字31 对应一个数字12 对应两个数字23 对应三个数字3最后合并成一个 6 元素的序列。这个例子里每个源元素产生的内层序列长度不同但 SelectMany 完全不在意它只负责展开后拼接。这正是它最核心的价值不要求内层集合等长也不要求源序列有固定维度。我见过很多老项目里这种场景是用ListT.AddRange在循环里拼的写出来的代码又长又容易在边界条件下出问题。用 SelectMany 之后一行搞定后面的 Where、OrderBy、GroupBy 都可以直接接上去管道式处理一气呵成。2.2 第二种重载ResultSelector 解决父子对象上下文丢失问题当你只用第一种重载时内层集合里的元素会保留但父元素的上下文会丢失。比如我想输出订单号 商品名如果只用orders.SelectMany(o o.Items)得到的只有商品列表订单号信息没了还得想别的办法把它带出来。第二重重载专门解决这个问题public static IEnumerableTResult SelectManyTSource, TCollection, TResult( this IEnumerableTSource source, FuncTSource, IEnumerableTCollection collectionSelector, FuncTSource, TCollection, TResult resultSelector);参数看起来多了一个实际意义就是把源元素和展开出的子元素一起传给你自定义的 resultSelector让你把它们组合成新的结果var rows orders.SelectMany( o o.Items, (o, item) new { OrderNo o.OrderNo, Customer o.Customer, ProductName item.ProductName, UnitPrice item.UnitPrice });这样每条订单项都带着所属订单的信息非常像 SQL 里的 Inner Join 结果。我不止一次在报表导出、对账、批量通知这类需求里用到它它比先 SelectMany 再 Join 回来的方式直接得多也省掉一次不必要的集合匹配。2.3 第三种重载索引参与映射冷门但特定的场景很有用第三个重载稍微冷门一点但偶尔能救命public static IEnumerableTResult SelectManyTSource, TResult( this IEnumerableTSource source, FuncTSource, int, IEnumerableTResult selector);selector 多了一个int参数是源元素在序列里的下标。凡是需要根据位置决定展开结果的场景这个重载就比手动维护计数器靠谱得多。典型的例子是给二维数组的每个元素带上坐标var matrix new[] { new[] { 10, 20, 30 }, new[] { 40, 50, 60 } }; var flattened matrix.SelectMany((row, rowIndex) row.Select((value, colIndex) new { Row rowIndex, Col colIndex, Value value }));结果会得到 6 个带(Row, Col, Value)的元素不再需要之前反复声明循环变量 i、j这类中间状态。对于数据导入、矩阵计算、九宫格这类场景这种写法会顺手很多。三种重载的适用场景大概可以用下面这张表说明重载形式核心能力典型场景只有 selector把嵌套集合展开成一维一对多关系的扁平化selector resultSelector展开时保留父元素上下文报表行、关联明细、Join 式查询selector 带索引展开规则依赖源元素位置矩阵坐标、蛇形遍历、分块处理3. Select 和 SelectMany 的差异用一张表把扁平化这件事说明白3.1 类型层面的区别第一眼就能看出问题很多开发者会把 Select 和 SelectMany 弄混尤其是刚开始学 LINQ 的时候。其实从类型上就分得很清楚var scores students.Select(s s.Scores); // scores : IEnumerableListint两层嵌套 var flatScores students.SelectMany(s s.Scores); // flatScores : IEnumerableint一层把所有学生的成绩合在一起当Select的 lambda 返回的是一个集合时结果类型就变成了集合的集合。你要访问真正的成绩还得两层 foreach。而SelectMany的语义就是把 lambda 返回的多个集合直接合并得到所有集合里元素的平铺序列。如果从函数式编程的背景来看Select 对应 mapSelectMany 对应 flatMap。map 做的是一对一映射flatMap 多做了一步把映射结果摊平。理解了这个联系你会发现 SelectMany 并非只在 C# 里有用Java 的Stream.flatMap、Kotlin 的flatMap、TypeScript 里的flatMap都是同一个思想。3.2 语义层面什么时候用 Select什么时候用 SelectMany选错的根本原因是对结果类型不够敏感。我给自己定的判断规则非常简单如果 lambda 返回的就是 T 本身用 Select。如果 lambda 返回的是 IEnumerable并且你关心的是展开后的 T用 SelectMany。如果 lambda 返回的是 IEnumerable但你确实想保留这层嵌套就是想要IEnumerableIEnumerableT那才用 Select。这里容易绕的地方来了很多时候代码里写 Select 也不会编译报错因为它返回的嵌套集合在后续操作里可能碰巧能用。比如下面这段var nested products.Select(p p.Tags); // IEnumerableListstring var output nested.SelectMany(tags tags); // IEnumerablestring它的效果和直接products.SelectMany(p p.Tags)一模一样但绕了一个圈子还额外构造了一次中间集合。这种先 Select 再 SelectMany的组合写法在我看来是思维不清晰的表现。没有人禁止这么写但你会发现直接使用 SelectMany 时代码更短也更贴近真实的数据形状。3.3 一张表整理 Select 与 SelectMany 的对比对比维度SelectSelectMany映射结果一对一元素数量不变一对多再合并元素数量可能变化结果类型IEnumerableTResultIEnumerableTResult但 TResult 是展开后的元素嵌套集合保留嵌套消除一层嵌套类似概念mapflatMap典型误用返回集合后还要再循环一次该用时没用被迫写多层循环这张表是我做培训时最常摆出来的东西。很多人看过一遍之后恍然大悟但真正形成语感还需要在实际项目里多碰几次。4. 复杂映射实战订单明细、权限树和笛卡尔组合4.1 一对多报表客户、订单、订单项三层扁平化前面提到过的订单统计这里放出完整写法。如果数据有三层关系客户 - 订单 - 订单项要生成一张客户/订单号/商品名/金额的报表行用嵌套循环会非常痛苦但用 SelectMany 递进展开就很自然var reportRows customers .SelectMany(c c.Orders, (c, o) new { c, o }) .SelectMany(x x.o.Items, (x, item) new { 客户名 x.c.Name, 订单号 x.o.OrderNo, 商品名 item.ProductName, 金额 item.Amount });第一次 SelectMany 用第二重载把客户和订单绑定起来第二次 SelectMany 继续把订单和订单项绑定起来最后形成一张可以直接导出 Excel 的明细表。每一步都清清楚楚客户展开成订单订单展开成订单项。这是 SelectMany 处理复杂映射最典型的样子。实际项目里我还会在这个链式调用的末尾追加长 Where、OrderBy甚至 GroupBy。有一点要特别注意如果数据量很大建议先弄清楚是要做内存计算还是数据库端查询。内存里几十万条数据这么写没问题但如果背后是 EF Core 查询同样的写法会翻译成不同的 SQL这个放到第 5 节专门讲。4.2 树形结构的递归扁平化权限树、菜单树、组织架构SelectMany 结合递归函数可以把任意层级的树结构拍平。我最常用到的就是权限模型里的角色-用户关系以及分类树IEnumerableCategory Flatten(Category node) { return new[] { node } .Concat(node.Children.SelectMany(Flatten)); }要给根分类调用一次就能得到整棵树的所有分类节点。这里的关键不是Concat而是.SelectMany(Flatten)—— 它对每个子节点都递归执行整个 Flatten 函数再把所有子节点递归展开的结果合并成一个序列。递归函数搭配 SelectMany天然适合无限层级的结构比手工维护栈的写法精准得多。用同样思路可以快速找出某个组织架构下的所有用户或者把多级菜单一次性拉出来做权限校验var allUsers departments.SelectMany(FlattenUsers);需要注意递归深度问题。如果树的层级非常深比如几千层递归写法可能栈溢出。实际项目中遇到这种极端情况我会先评估树的规模绝大多数业务树在几十层以内递归没有压力。4.3 两个集合的笛卡尔积规格组合、SKU 生成另一个高价值场景是集合之间的交叉组合。比如前台要生成颜色的 SKU 列表也有尺码的 SKU 列表想得到所有组合var colors new[] { 红色, 蓝色, 黑色 }; var sizes new[] { S, M, L }; var skus colors.SelectMany( color sizes, (color, size) ${color}-{size}); // 红色-S, 红色-M, 红色-L, 蓝色-S, ...这在 SQL 里叫 Cross Join在 LINQ 里最自然的就是 SelectMany。如果你需要给每个组合附加一个不会冲突的编号第三重载就能派上用场var skuWithIndex colors.SelectMany((color, ci) sizes.Select((size, si) new { ColorIndex ci, SizeIndex si, Sku ${color}-{size} }));这种组合逻辑在电商后台、规格模板、批量任务生成里非常常见。用循环写同样逻辑需要双层 for而 SelectMany 把组合这个意图直接写进了语句里读代码的人不用自己去解两层循环。5. EF Core 查询中的 SQL 翻译与笛卡尔爆炸避坑5.1 SelectMany 在 EF Core 中如何变成 JOIN而不是循环SelectMany 不只是在 LINQ to Objects 里有用在 EF Core 里它直接参与 SQL 生成。如果实体之间已经定义了导航集合最直观的写法是var rows await db.Orders .SelectMany(o o.Items, (o, item) new { OrderId o.Id, CustomerName o.Customer.Name, item.ProductName, item.Price }) .ToListAsync();EF Core 会把它翻译成一条带 JOIN 的 SQL而不是把整个 Orders 表加载到内存再逐层展开。这是因为o.Items是导航属性EF 知道它对应一条外键关系SelectMany 在这里就是把一对多关系转换成 Join 结果。如果实体没有直接定义集合导航也可以写成SelectMany(o db.OrderItems.Where(i i.OrderId o.Id))EF 一样能识别成关联查询。这一点让IQueryable下的 SelectMany 天然具备了查询组合能力它不只是本地扁平化而是把展开子集合这个意图翻译给了数据库。5.2 多个集合导航同时出现时笛卡尔爆炸怎么破EF Core 里最大的坑之一就是同一个查询里同时展开多个集合导航。比方说一个订单既有明细 Items又有操作日志 Logs你写这样的查询var badRows db.Orders .Where(o o.Id someId) .Select(o new { o.OrderNo, Items o.Items.ToList(), Logs o.Logs.ToList() }) .ToList();翻译出来的 SQL 会同时 JOIN 两张子表返回的记录数是 Items 行数乘以 Logs 行数。一个订单如果 10 条明细和 8 条日志就膨胀成 80 行。这不叫笛卡尔爆炸也是内存的巨大浪费。解决办法有几种拆成多条独立查询分别加载 Items 和 Logs。使用AsSplitQuery()让 EF Core 自动拆分成多条 SQLEF Core 5 及以上。谨慎使用SelectMany展开两个独立导航集合的情况。var betterRows await db.Orders .AsSplitQuery() .Select(o new { o.OrderNo, Items o.Items.ToList(), Logs o.Logs.ToList() }) .ToListAsync();我的建议是只要查询里同时出现两个以上的集合导航先下意识想一下会不会膨胀。这不是说不能用 SelectMany而是要在用它的时候对结果行数有预判。本地 LINQ 里展开一万条没问题数据库里膨胀成十亿行就是事故。5.3 被客户端评估坑过之后的教训EF Core 有一条规则查询如果无法完全翻译成 SQL剩下的部分可能在客户端执行。早期版本里这很容易导致性能问题——你以为数据库只算了必要的数据实际上它先把大量中间行拉回内存再筛选。我在一次统计报表里就栽过SelectMany里用的 lambda 比较复杂EF 判断无法翻译直接退化成客户端评估几百万行记录被拉回来查询跑了几分钟。排查时用日志看生成的 SQL 就行任何 ORM 都该养成这个习惯。如果发现 SQL 里出现大范围数据扫描或者查询计划里行数估算异常第一反应应该是去查表达式能否被完整翻译。能拆分就拆分能用原生 SQL 就用原生 SQL别让 LINQ 的便利性掩盖掉数据库端的昂贵代价。6. 迭代器的延迟执行细节为什么看似没执行却可能反复执行6.1 延迟执行与迭代器的关系SelectMany 是一个延迟执行的方法。调用它的时候它不立刻把结果算出来而是返回一个迭代器对象。真正执行 selector 是在你开始枚举结果的时候而且是逐个元素地执行。下面的代码可以验证var query numbers.SelectMany(n { Console.WriteLine($执行selector当前n {n}); return Enumerable.Repeat(n, n); }); // 此刻控制台什么都没有输出 foreach (var item in query) { Console.WriteLine(item); } // 枚举时才输出执行selector当前n 1然后1执行selector当前n 2然后22……这正是迭代器模型的精髓按需生产边生产边消费。如果你只需要结果序列的前几个元素后面元素的 selector 根本不会执行。比如配合 Take 使用时整个链路只计算必要的前缀var firstFew numbers .SelectMany(n ExpensiveGenerate(n)) .Take(3) .ToList();这个特性在大数据量下非常友好它保证了不取用不计算。但也正因如此很多初学者会在调试时发现断点不进、日志不打印误以为自己写错了方法。其实不是只是还没开始枚举。6.2 惰性链可重复枚举带来的重复计算延迟执行的另一面是同一个 LINQ 查询变量如果你 foreach 两次selectors 会执行两次。比如这个场景var query source.SelectMany(x LoadDetails(x)); var first query.Count(); // LoadDetails 执行 N 次 var second query.Sum(d d.Amount); // LoadDetails 又执行 N 次query只是一个可重复枚举的迭代器不是快照。foreach 两次等于把展开过程重复了两遍。如果 LoadDetails 内部有数据库查询或者远程调用这就是实打实的性能浪费。解决办法也很简单用一次.ToList()把结果固定下来后面用 List 访问var list source.SelectMany(x LoadDetails(x)).ToList(); var first list.Count; var second list.Sum(d d.Amount);但这种处理要分场景。如果只是一次性消费直接枚举更省内存要多次复用才需要 ToList。别看到 ToList 就无脑用也别因为怕重复执行而把所有查询先物化内存压力也是成本。6.3 调试技巧如何观察一个 LINQ 链的执行过程调试 SelectMany 链的时候最容易困惑的是这个元素到底经历了哪几步。我的习惯是用一个临时文件加上日志选择器把每一步的输入输出打出来var debugQuery customers .Select(c { Console.WriteLine($客户{c.Name}); return c; }) .SelectMany(c c.Orders, (c, o) { Console.WriteLine($订单{o.OrderNo}); return new { c, o }; });这是临时调试写法线上永远不要留。真正要定位性能问题时我会借助 Count 事件在 lambda 里统计调用次数或者在 EF Core 里打开日志看 SQL 行数与迭代次数。理解了延迟执行很多奇怪现象就不奇怪了不是 LINQ 有 bug而是枚举时机造成的。7. 实际项目中的取舍经验可读性、性能与可维护性7.1 什么时候真的不该用 SelectMany虽然 SelectMany 很好用但它不是万能锤。下面几种情况我会主动放弃它需要提前终止遍历。比如找到第一个满足条件的元素就停。SelectMany 虽然有 Take 可以配合但如果逻辑里有复杂的提前 break普通循环反而更直白。嵌套层级非常深而且每个层级的处理差异很大。这种时候用 SelectMany 链会很长末尾的 lambda 也臃肿不如拆成几个独立方法处理。多个独立集合要按位置配对比如把学生和成绩一一对应。这种情况应该用 Zip而不是 SelectMany 的笛卡尔积否则会产生大量无意义组合。只需要扁平化不需要后续任何 LINQ 操作。那么SelectMany(x x.Items)和两层 foreach 的差别不大看团队习惯选择即可。SelectMany 是用来表达一对多展开并合并这个语义的不是用来消灭所有循环的。工具主义地套用反而会带来反效果。7.2 和元组、Where、GroupBy 串联的复杂映射写法在 C# 7 之后元组和 SelectMany 搭配起来非常舒服我把项目里的报表代码基本都改了这种风格var stats customers .SelectMany(c c.Orders, (c, o) (c, o)) .SelectMany(x x.o.Items, (x, item) (x.c, x.o, item)) .Where(t t.item.Status OrderStatus.Paid) .GroupBy(t t.c.Id) .Select(g new { CustomerId g.Key, TotalAmount g.Sum(t t.item.Amount), OrderCount g.Select(t t.o.OrderNo).Distinct().Count() });每一步的类型变化是清晰的客户展开成(客户, 订单)再展开成(客户, 订单, 订单项)后面接 Where、GroupBy 都顺理成章。匿名类型在局部使用没问题但跨方法传递时元组写起来更干净尤其是配合解构foreach (var (customer, order, item) in orderDetailRows) { Console.WriteLine(${customer.Name} {order.OrderNo} {item.ProductName}); }这种连续 SelectMany 的写法熟练之后几乎是条件反射。它把一层一层的映射关系直接摊在调用链上读代码的人从上往下看就知道数据是如何被一步步塑形的。最顶级的使用感受是写完之后整个查询读起来和 SQL 很像但比 SQL 更类型安全。7.3 我现在的代码规范如何避免团队里 SelectMany 滥用带团队这几年我总结了几条关于 SelectMany 的实用规矩写在这里供参考selector 里禁止写超过 3 行的逻辑。一旦需要多行处理就提取成独立方法否则链的可读性会急剧下降。连续两次 SelectMany 之后优先命名中间结果。要么拆成两步变量要么用带 resultSelector 的重载保留上下文别让匿名对象层层嵌套到失控。数据库端查询用 SelectMany 时看一眼生成的 SQL。这是纪律不是建议。尤其要注意笛卡尔膨胀和客户端评估。需要多次枚举的 LINQ 链先物化再使用。避免 SelectMany 里的昂贵操作被重复触发。不要用 SelectMany 展开空集合后继续期待父元素保留。如果你还需要父元素请使用第二重载直接传父元素和子元素给 resultSelector。我知道有很多团队把尽量不用循环全用 LINQ当口号但我更愿意强调关键不是用不用循环而是表达是否准确。SelectMany 解决的是我有一对多关系想要得到合并后的子元素集合这件事如果你的代码确实在表达这个那就大胆用它。如果只是为了让代码显得LINQ那不如保持简单直接。最后说一点个人体会学 SelectMany 真正难的不是记住三个重载而是建立看到数据形状先想到扁平化的直觉。我在 Code Review 里见过最漂亮的写法不是因为用了多高级的语法而是它一眼看过去就知道数据是怎么流转的——从客户到订单从订单到订单项每一条扁平化的调用都在回答数据从哪里来又要到哪里去。这种代码维护者看到的第一秒就会感谢你。
返回列表