浏览知识库目录

C#

LINQ、迭代器与惰性查询

理解 IEnumerable、yield 与 LINQ 惰性执行,构建可控、可物化的任务查询。

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();

聚合前考虑空序列。AverageMax 等部分运算在空序列上会抛异常,可先检查 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 验证。

查询链过长且混合业务规则

提取命名条件或查询对象,使每一步能解释。


十二、练习与自测

练习:

  1. yield 实现只返回逾期任务的迭代器。
  2. 对任务按状态分组并输出数量。
  3. 构造一个有副作用的序列,观察重复枚举结果。
  4. 为任务查询加入搜索、上限和稳定排序。

自测:

  • IEnumerable<T> 不保证哪些行为?
  • LINQ 查询何时真正执行?
  • SingleOrDefaultFirstOrDefault 的契约差异是什么?
  • 为什么异常可能不在查询定义行抛出?

十三、官方资料

上一篇:委托、事件、Lambda 与扩展成员 | 下一篇:文件、JSON、正则与时间