3.3 LINQ 与表达式树


3.3 LINQ 与表达式树

本节摘要:LINQ 的查询是一个"先构建、后执行"的两阶段过程:链式调用只是搭管道,真正枚举才开始取数据。本节拆解延迟执行的机制与陷阱、管道的单遍流式模型,以及 Lambda 编译成委托还是表达式树的分野——后者让查询能被翻译成 SQL,是 ORM 的基石。

链式调用其实什么都没做

var orders = new List<Order> { /* 一万条 */ }; var query = orders .Where(o => o.Total > 1000) .OrderBy(o => o.Created) .Take(10);

这三行执行完,一条数据都没被碰过。Where 等标准查询运算符全是 2.3 节讲的扩展方法,它们做的事只有一件:把前一个序列包进一个新的枚举对象,返回这个包装。query 变量持有的是三层包装的管道。直到枚举发生——foreachToList——数据才逐个流过管道:

var top10 = query.ToList(); // 此刻才开始过滤、排序、截断

这就是延迟执行。它的红利是组合自由:同一个 query 在数据更新后再次枚举会拿到新结果(查询是活的模板不是快照);它的坑同样是"活着"——data source 变了结果跟着变,多次枚举重复计算,甚至"先关连接后枚举"直接抛异常。需要冻结结果就显式物化:ToList、ToArray、ToDictionary 把流水线倒进容器。

图 LINQ 管道的流式执行模型

图 LINQ 管道的流式执行模型

注意图中的一个例外:绝大多数算子是单遍流式的(元素一个个穿过,内存占用 O(1)),但 OrderByGroupByReverse 这类"要看全局才能出结果"的算子必须缓冲。把 OrderBy 放在无限序列后面会内存爆炸,这不是 bug,是算子语义的必然。

短路:管道的隐藏效率

Take(10) 配合 Where 的组合有个精妙性质:取满 10 个后,管道通知上游停止,剩余九千多条从未被枚举。"找满足条件的前十条"用 LINQ 写出来是 Where(...).Take(10),实际只扫描到凑满为止——这与 SQL 的 limit 思想同源。反过来,list.Where(...).Count() > 0 可以写成 Any(...),后者找到第一个满足的就返回,前者必须数完全程。知道哪些算子短路(Any、Take、First、TakeWhile),是 LINQ 效率的基本功。

委托 vs 表达式树:Lambda 的两种去向

第二章留的机关在此揭开。同一个 Lambda,目标类型不同,编译产物截然不同:

Func<Order, bool> f = o => o.Total > 1000; // 编译成 IL 方法:可执行 Expression<Func<Order, bool>> e = o => o.Total > 1000; // 编译成数据结构:可分析

前者(IEnumerable<T> 的世界)Lambda 变成 2.6 节的委托,枚举时直接调用,本地内存中执行。后者(IQueryable<T> 的世界)Lambda 变成表达式树——一串描述"读 Total 属性、与 1000 比较"的节点对象。表达式树不能执行,但能被翻译:EF Core 拿到它翻译成 SQL 的 WHERE 子句送到数据库执行。这就是为什么 IQueryable 上只能放"能翻译"的操作——你在 Where 里调一个本地 C# 方法,翻译器无从下手,要么报错要么被迫拉全表到内存再过滤(经典的"查询慢十倍"来源)。

db.Orders.Where(o => LocalRule(o)) // 翻译失败:整表拉回内存 db.Orders.Where(o => o.Total > 1000) // 干净的 SQL:WHERE total 大于参数

⚠️ 常见坑三连:一是查询后数据源被修改或连接已关闭才枚举,抛运行时异常;二是 IQueryable 链尾接了个 IEnumerable 方法(如 AsEnumerable 之前的某些自定义扩展),翻译边界悄悄位移;三是在 EF 查询里插 C# 方法导致全表回传。排查时先看变量声明是 IQueryable 还是 IEnumerable——那条分界线就是翻译边界。

查询语法与另一个查询世界

C# 还提供 SQL 风格的查询语法:from o in orders where ... select o。它与扩展方法语法编译产物完全相同,纯个人口味问题;方法语法更全能(Count、First 等无对应查询子句),团队里更常见。LINQ 的思想也早已溢出集合:LINQ to XML 操作文档、PLINQ 并行执行(第四章)、async 流的 await foreach 查询。一次学习,处处复用——因为它们都建立在"序列 + 算子 + 延迟执行"这同一套抽象上。

收束时给一条组合拳式的实践建议。写业务查询时先用一句话问自己:数据在哪、翻译边界在哪、何时物化。本地内存数据全程 IEnumerable,放心用委托与任何本地方法;数据库数据在 IQueryable 上保持可翻译形态,直到必须调用无法翻译的逻辑那一刻再用 AsEnumerable 显式跨线;查询结果若要多次使用或长期持有,入口处立即 ToList 冻结。这三问三答覆盖了绝大多数 LINQ 使用错误,而它们全部建立在延迟执行与表达式树这两个机制之上——机制吃透,规则就不再是清单而是推论。

本节要点回顾

  • 两阶段执行:链式调用搭管道,枚举才流动数据;需要快照就物化;
  • 流式为主,缓冲有例外:OrderBy 与 GroupBy 必须缓冲,无限序列后接它们会爆内存;
  • 短路算子是效率杠杆:Any、Take、First 找到即停;
  • 委托执行,表达树翻译:IQueryable 的边界就是 SQL 翻译的边界,越界即全表回传;
  • 两套语法一种产物:查询语法与方法语法自由选择,别争。

下一节打开程序集的元数据黑盒:反射如何让程序阅读自己,特性又如何给它加标注。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U