C#
LINQ、迭代器与惰性查询
理解 IEnumerable、yield 与 LINQ 惰性执行,构建可控、可物化的任务查询。
发布于 2026年7月23日
LINQ、迭代器与惰性查询
LINQ 把筛选、映射、分组和聚合统一为可组合查询。它的力量来自 IEnumerable<T> 与委托,也因此常常是惰性的:保存查询变量通常没有执行查询,真正枚举时才读取数据、运行 Lambda 并可能抛出异常。
一、学习目标
- 理解
IEnumerable<T>与枚举器协议 - 使用
yield return编写迭代器 - 掌握常用 LINQ 查询运算符
- 区分惰性查询与立即物化
- 避免重复枚举、隐藏 I/O 和意外副作用
- 为任务列表实现查询与统计
二、枚举协议
foreach 会获取枚举器,并不断调用 MoveNext:
using IEnumerator<StudyTask> enumerator = tasks.GetEnumerator();
while (enumerator.MoveNext())
{
StudyTask current = enumerator.Current;
Console.WriteLine(current.Title);
}
大多数代码直接使用 foreach,但理解协议可以解释:
- 为什么序列可以逐项产生而无需全部进入内存
- 为什么同一枚举器通常只能向前移动
- 为什么枚举中集合被修改会失败
- 为什么枚举器可能需要释放资源
IEnumerable<T> 表示“可以创建枚举器”,不保证数据在内存、可重复枚举、顺序稳定或没有副作用。
三、使用 yield 编写迭代器
static IEnumerable<StudyTask> Pending(
IEnumerable<StudyTask> source)
{
foreach (StudyTask task in source)
{
if (task.Status == TaskStatus.Todo)
{
yield return task;
}
}
}
调用 Pending(tasks) 时方法体不会立即完整执行。每次请求下一个元素时,状态机会从上次 yield return 后继续。
迭代器适合流式转换和按需读取。资源生命周期必须覆盖枚举期:
static IEnumerable<string> ReadNonEmptyLines(string path)
{
using StreamReader reader = File.OpenText(path);
while (reader.ReadLine() is { } line)
{
if (!string.IsNullOrWhiteSpace(line))
{
yield return line;
}
}
}
StreamReader 会在枚举完成或枚举器被释放时关闭。若调用方从不枚举,文件也不会打开。
四、筛选与映射
string[] pendingTitles = tasks
.Where(task => task.Status == TaskStatus.Todo)
.Select(task => task.Title)
.ToArray();
Where 保留满足条件的元素,Select 把元素转换为另一形状。方法链按数据流方向阅读。
需要索引的重载:
var numbered = tasks.Select(
static (task, index) => new
{
Number = index + 1,
task.Title
});
匿名类型适合方法内部投影,跨层契约使用命名记录。
五、排序、分组与聚合
var ordered = tasks
.OrderBy(task => task.Status)
.ThenByDescending(task => task.CreatedAt)
.ThenBy(task => task.Id);
稳定展示应写出完整决胜规则,不能只按可能相同的状态排序。
分组统计:
var counts = tasks
.GroupBy(task => task.Status)
.Select(group => new
{
Status = group.Key,
Count = group.Count()
})
.OrderBy(item => item.Status)
.ToArray();
聚合前考虑空序列。Average、Max 等部分运算在空序列上会抛异常,可先检查 Any 或使用可空投影。
六、元素运算符的契约
First:必须至少有一个,取第一个FirstOrDefault:允许没有,返回默认值Single:必须恰好一个SingleOrDefault:允许零个,但不允许多个
按唯一 ID 查询时,SingleOrDefault 能暴露重复数据:
StudyTask? task = tasks.SingleOrDefault(task => task.Id == id);
若数据量很大或来自数据库,正确做法仍是让仓储按索引查询,而不是加载全部后 LINQ 扫描。
FirstOrDefault 的默认值对值类型可能与合法值相同。需要区分时返回可空值、显式结果类型,或先投影。
七、惰性执行
IEnumerable<StudyTask> query = tasks
.Where(task => task.Status == TaskStatus.Todo);
tasks.Add(newTask);
StudyTask[] result = query.ToArray();
新任务可能出现在结果中,因为查询在 ToArray 时才执行。需要调用时快照,就立即物化:
StudyTask[] snapshot = tasks
.Where(task => task.Status == TaskStatus.Todo)
.ToArray();
异常也可能推迟:
IEnumerable<int> ids = inputs.Select(int.Parse);
// 直到枚举 ids 才可能抛 FormatException
try 块必须包围实际枚举,而不只是查询定义。
八、避免重复枚举
if (query.Any())
{
foreach (StudyTask task in query)
{
Console.WriteLine(task.Title);
}
}
这会枚举至少两次。对内存列表可能只是额外计算;对文件、生成器或数据库可能重复 I/O,甚至得到不同结果。
需要复用一致结果时:
StudyTask[] items = query.ToArray();
if (items.Length > 0)
{
foreach (StudyTask task in items)
{
Console.WriteLine(task.Title);
}
}
如果只需处理第一个元素,不要为判断非空而物化全部。
九、IEnumerable 与 IQueryable
IEnumerable<T> 上的 Lambda 编译为委托,在 .NET 代码中执行。IQueryable<T> 上常编译为表达式树,由提供程序翻译成 SQL 或其他查询语言。
不是所有 C# 方法都能翻译:
queryable.Where(task => CustomNormalize(task.Title) == expected);
提供程序可能拒绝翻译。数据库查询应使用其明确支持的表达式,并检查生成 SQL 和查询计划。不要为了“继续链式调用”过早切换到内存,导致整表加载。
本系列的 Microsoft.Data.Sqlite 仓储直接使用参数化 SQL,因此 LINQ 主要处理已经进入应用内存的数据。
十、任务查询对象
public sealed record TaskQuery(
TaskStatus? Status,
string? Search,
int Limit = 100);
public static IReadOnlyList<StudyTask> ApplyQuery(
IEnumerable<StudyTask> source,
TaskQuery query)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(query.Limit);
IEnumerable<StudyTask> result = source;
if (query.Status is { } status)
{
result = result.Where(task => task.Status == status);
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
result = result.Where(task =>
task.Title.Contains(
query.Search,
StringComparison.OrdinalIgnoreCase));
}
return result
.OrderBy(task => task.Id)
.Take(query.Limit)
.ToArray();
}
边界处物化为快照,让调用方知道查询已经执行,且数量受限。
十一、常见错误
在 LINQ Lambda 中修改外部状态
惰性和重复枚举会让副作用次数难以预测。查询尽量保持纯函数。
对未知来源反复枚举
先确认序列契约;需要稳定复用时一次物化。
用 FirstOrDefault 掩盖重复唯一键
它只取第一个。唯一性重要时使用仓储约束和 SingleOrDefault 验证。
查询链过长且混合业务规则
提取命名条件或查询对象,使每一步能解释。
十二、练习与自测
练习:
- 用
yield实现只返回逾期任务的迭代器。 - 对任务按状态分组并输出数量。
- 构造一个有副作用的序列,观察重复枚举结果。
- 为任务查询加入搜索、上限和稳定排序。
自测:
IEnumerable<T>不保证哪些行为?- LINQ 查询何时真正执行?
SingleOrDefault与FirstOrDefault的契约差异是什么?- 为什么异常可能不在查询定义行抛出?
十三、官方资料
上一篇:委托、事件、Lambda 与扩展成员 | 下一篇:文件、JSON、正则与时间