自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

C#

  1. 01C# 入门:从第一行代码到可运行的小程序
  2. 02函数与逻辑:从方法契约到 Lambda
  3. 03面向对象:从有效对象到可靠边界
  4. 04C# 类型与引用:从复制语义到安全转换
  5. 05C# 继承与运行时多态
  6. 06C# 接口与抽象:从契约到可测试设计
  7. 07C# 异步与 JSON:从 Task 到可靠数据边界
  8. 08C# 错误处理:从异常传播到可靠边界
  9. 09C# LINQ:从集合流水线到可靠的数据边界
  10. 10C# 委托与事件:从类型安全回调到生命周期管理
  11. 11C# MVVM:从可绑定状态到可测试交互
正在加载课程章节内容
课程编程C#C# LINQ:从集合流水线到可靠的数据边界

C# LINQ:从集合流水线到可靠的数据边界

业务代码里的数据很少恰好长成页面或接口需要的样子:报名表要筛掉未通过的人,成绩榜要稳定排序,班级要生成统计摘要,学生与课程还要按键关联。LINQ 的价值,是把这些变化写成一条可读、可组合、可测试的数据流水线。

本章以“训练营报名看板”为贯穿案例。你不仅会写出查询,还会判断它何时执行、执行几次、需要多少额外空间,以及操作究竟发生在内存还是数据库端。

LINQ 从数据源经过筛选、投影、排序抵达结果的查询流水线。

学习路线:先描述,再执行,最后守住边界

完成本章后,你应该能做到:

  1. 在查询语法与方法语法之间自由转换,并选择更易读的一种。
  2. 用 Where、Select、排序、分组、聚合、连接与 SelectMany 组织真实查询。
  3. 区分延迟执行、立即执行与物化快照,避免重复枚举。
  4. 用 DTO、DistinctBy 与 Chunk 建立清晰的数据出口。
  5. 判断 IEnumerable<T> 与 IQueryable<T> 的执行边界,减少数据库传输与翻译失败。

建议按“数据源 → 运算符 → 执行时机 → 关系运算 → 远程边界”的顺序阅读。代码默认使用现代 C# 集合表达式;若项目版本较旧,可把 [...] 改为 new List<T> { ... }。

本节自测

1
评审一条 LINQ 查询时,除结果正确外,还应重点关注哪些运行特征?
2
把筛选、投影和排序分别写成可组合的查询步骤,通常比把状态、分支和输出全部塞进一个循环更容易维护。

贯穿数据:查询的三部分

先建立稳定的数据模型。关联关系应使用 Id,不要用可能重名、改名的姓名作为键。

csharp
public sealed record Student(
    int Id,
    string Name,
    int Score,
    string Team,
    string[] Tags);
 
public sealed record Enrollment(int StudentId, string Course, decimal Fee);
public sealed record EnrollmentDto(string Name, string Course, int Score);
 
List<Student> students =
[
    new(1, "小林", 92, "A组", ["C#", "API"]),
    new(2, "小周", 78, "B组", ["SQL"]),
    new(3, "小陈", 85, "A组", ["LINQ", "测试"]),
    new(4, "小何", 88, "B组", [])
];
 
List<Enrollment> enrollments =
[
    new(1, "Web API", 399m),
    new(1, "LINQ", 199m),
    new(3, "单元测试", 299m)
];

一条查询包含三个角色:

角色本例需要问的问题
数据源students数据在内存、文件还是数据库?
查询定义Where(...).Select(...)每一步改变了数量、顺序还是形状?
执行触发点foreach、ToList()、Count()何时开始读取?会不会再次读取?
csharp
var plan = students
    .Where(s => s.Score >= 85)
    .Select(s => s.Name);       // 只定义计划
 
Console.WriteLine(string.Join(",", plan)); // 此处枚举
console
小林,小陈,小何

本节自测

3
示例中的哪一步真正开始遍历 students?
4
学生与报名记录进行连接时,最适合作为连接键的是哪一项?

方法语法与查询语法:两种表面,一套能力

方法语法把标准查询运算符写成扩展方法链,覆盖面最完整:

csharp
var methodResult = students
    .Where(s => s.Score >= 85)
    .OrderByDescending(s => s.Score)
    .ThenBy(s => s.Name)
    .Select(s => new { s.Name, s.Score });

查询语法更接近一句数据描述,复杂连接时尤其容易扫读:

csharp
var queryResult =
    from s in students
    where s.Score >= 85
    orderby s.Score descending, s.Name
    select new { s.Name, s.Score };

两者得到相同的元素。查询语法会被编译器改写为查询运算符调用,并不是另一套运行引擎。也可以混用:先用查询语法完成 join,再调用只存在于方法语法中的运算符。

查询语法与方法语法经由编译映射表达同一条查询。

本节自测

5
对于等价查询,查询语法天然比方法语法执行得更快。
6
已有主排序后,为什么应使用 ThenBy 添加次级排序?

过滤、投影与排序:先缩小,再塑形

Where 改变元素数量,Select 改变元素形状,OrderBy 改变顺序。常见的高效顺序是先过滤,再只投影所需字段,最后按需求排序;但排序键如果不在投影结果中,可以先排序再投影。

csharp
var leaderboard = students
    .Where(s => s.Score >= 80)                 // O(n),流式
    .OrderByDescending(s => s.Score)           // O(n log n),需要缓冲
    .ThenBy(s => s.Name)
    .Select(s => new { s.Name, s.Score });     // O(n),流式
 
foreach (var row in leaderboard)
    Console.WriteLine($"{row.Name}: {row.Score}");
console
小林: 92
小何: 88
小陈: 85

稳定结果不能依赖源集合“碰巧有序”。凡是分页、排行榜或测试断言需要固定顺序,就明确写出完整排序键。

现代集合查询还常用两个工具:

csharp
var bestPerTeam = students
    .OrderByDescending(s => s.Score)
    .DistinctBy(s => s.Team);
 
foreach (Student[] batch in students.Chunk(2))
    Console.WriteLine(string.Join("、", batch.Select(s => s.Name)));
console
小林、小周
小陈、小何

DistinctBy 保留每个键首次遇到的元素,因此“每组最高分”必须先按分数降序。Chunk(2) 的最后一批最多两个元素,不保证刚好装满。

本节自测

7
若要用 DistinctBy 取得每组最低分,正确的查询顺序是什么?
8
分页查询即使没有确定性排序,也能保证同一份数据在多次请求中不会跨页重复或遗漏。

延迟执行、立即执行与重复枚举

许多返回 IEnumerable<T> 的运算符只是保存查询逻辑。每次枚举,都会从当前数据源重新开始:

csharp
List<int> numbers = [1, 2, 3];
IEnumerable<int> evens = numbers.Where(n => n % 2 == 0);
 
numbers.Add(4);
Console.WriteLine(string.Join(", ", evens));
 
List<int> snapshot = evens.ToList();
numbers.Add(6);
Console.WriteLine(string.Join(", ", snapshot));
Console.WriteLine(string.Join(", ", evens));
console
2, 4
2, 4
2, 4, 6

查询定义、数据变化、枚举与 ToList 快照之间的执行时间线。

立即返回标量的 Any、First、Count、Sum 会触发枚举;ToList、ToArray、ToDictionary 既触发枚举又保存结构。Any 和 First 能短路,找到答案即可停止。

避免无意的重复枚举:

csharp
// 远程查询可能因此执行两次。
if (query.Any())
    foreach (var item in query) { /* ... */ }
 
// 需要同时判断并消费时,在明确边界物化一次。
var rows = query.ToList();
if (rows.Count > 0)
    foreach (var item in rows) { /* ... */ }

本节自测

9
延迟查询的 Where 谓词包含日志副作用时,枚举查询两次通常会让日志也记录两遍。
10
只需要判断序列中是否存在匹配元素时,应优先使用哪种写法?

分组与聚合:从明细变成摘要

GroupBy 生成多个 IGrouping<TKey,TElement>:每一组既有 Key,又是可继续枚举的元素序列。

csharp
var summary = students
    .GroupBy(s => s.Team)
    .Select(g => new
    {
        Team = g.Key,
        Count = g.Count(),
        Average = g.Average(s => s.Score),
        Max = g.Max(s => s.Score)
    })
    .OrderBy(x => x.Team);
 
foreach (var row in summary)
    Console.WriteLine($"{row.Team}: {row.Count}人, 均分{row.Average:F1}, 最高{row.Max}");
console
A组: 2人, 均分88.5, 最高92
B组: 2人, 均分83.0, 最高88

哈希分组平均需要 O(n) 时间与 O(n) 额外空间;排序分组还会增加排序成本。Average、Min、Max 面对空序列会抛出异常,但 GroupBy 产生的实际组不会是空组;自己聚合可空集合时应先判断或使用可空数值。

本节自测

11
关于 GroupBy 产生的分组,下列哪些说法正确?
12
对同一分组反复调用多个昂贵的自定义枚举可能重复遍历组内元素。

Join、GroupJoin 与左连接

Join 是内连接:外层与内层都存在匹配键时才产生结果。

csharp
var paidCourses = students.Join(
    enrollments,
    s => s.Id,
    e => e.StudentId,
    (s, e) => new EnrollmentDto(s.Name, e.Course, s.Score));

GroupJoin 为每个外层元素附上一组匹配项。它很适合表达“学生及其全部报名”:

csharp
var studentsWithCourses = students.GroupJoin(
    enrollments,
    s => s.Id,
    e => e.StudentId,
    (s, matches) => new
    {
        s.Name,
        Courses = matches.Select(e => e.Course).ToList()
    });

左连接需要把空匹配补成一行:

csharp
var leftJoin =
    from s in students
    join e in enrollments on s.Id equals e.StudentId into matches
    from e in matches.DefaultIfEmpty()
    select new { s.Name, Course = e?.Course ?? "未报名" };

GroupBy 分桶、Join 按键配对与 SelectMany 展开的关系运算。

内存中的 Join 通常为一侧建立查找结构,平均时间接近 O(n+m),额外空间与被索引的一侧相关。若键的相等规则特殊,可在 IEnumerable 场景提供比较器;数据库提供程序能否翻译自定义比较器则要另行确认。

本节自测

13
哪种连接方式能够保留没有任何报名记录的学生?
14
一名学生有两条匹配的报名记录时,内连接会为该学生产生 ____ 行结果。

SelectMany:把一对多摊平成明细

Select 会保持嵌套形状,SelectMany 会把内部序列串成一个序列:

csharp
IEnumerable<string[]> nested = students.Select(s => s.Tags);
IEnumerable<string> flat = students.SelectMany(s => s.Tags);

如果展开后仍需知道标签属于谁,使用结果选择器:

csharp
var tagOwners = students.SelectMany(
    s => s.Tags,
    (s, tag) => new { s.Name, Tag = tag });
 
foreach (var row in tagOwners)
    Console.WriteLine($"{row.Name} -> {row.Tag}");

没有标签的小何不会产生任何行。这与左连接的“保留空匹配”不同;若业务要显示“无标签”,可先把空数组替换为只含一个占位值的序列。

本节自测

15
对 string[] 类型的 Tags 执行 Select 与 SelectMany 后,结果类型分别是什么?
16
SelectMany 的二参数结果选择器可以在展开子项时同时保留父项信息。

DTO、去重与批处理:让查询在出口收口

匿名类型适合方法内部,跨层返回应使用明确 DTO。DTO 只携带消费端需要的字段,既减少耦合,也能帮助数据库查询减少列传输。

csharp
var cards =
    from s in students
    join e in enrollments on s.Id equals e.StudentId
    where s.Score >= 85
    orderby s.Score descending, s.Name
    select new EnrollmentDto(s.Name, e.Course, s.Score);

去重前先决定“保留哪一个”:

csharp
var latestPerStudent = records
    .OrderByDescending(x => x.UpdatedAt)
    .DistinctBy(x => x.StudentId);

批量调用外部服务时再分块:

csharp
foreach (EnrollmentDto[] batch in cards.Chunk(100))
    await SendBatchAsync(batch);

不要把 Chunk 当作并发控制;它只切分序列。并发数量、失败重试和幂等性仍需要单独设计。

本节自测

17
为什么 DistinctBy(StudentId) 前的排序属于业务规则?
18
Chunk(100) 能保证生成的每一个批次都恰好包含 100 项。

IEnumerable 与 IQueryable:执行位置的分界线

IEnumerable<T> 上的查询通常接收已编译委托,在 .NET 进程中逐项执行。IQueryable<T> 接收表达式树,由查询提供程序分析并翻译成 SQL 或其他远程查询。

IEnumerable 的内存执行与 IQueryable 的表达式翻译边界。

csharp
public sealed record StudentRow(int Id, string Name, int Score);
 
IQueryable<StudentRow> query = db.Students
    .Where(s => s.Score >= 85)
    .OrderByDescending(s => s.Score)
    .Select(s => new StudentRow(s.Id, s.Name, s.Score));
 
List<StudentRow> rows = await query.ToListAsync();

只要仍在 IQueryable 上,提供程序就有机会把过滤、排序和投影下推。过早 ToList() 会先把数据搬到内存,后续条件只能本地运行。

AsEnumerable() 与 ToList() 不同:前者不物化,只把后续运算切换到 IEnumerable 解析;真正枚举时,前半段远程查询先执行,后半段再在本地继续。它可作为明确的翻译边界,但必须在远程端先尽量缩小数据。

csharp
var labels = db.Students
    .Where(s => s.Score >= 85)       // 尽量在数据库筛选
    .Select(s => new { s.Name, s.Score })
    .AsEnumerable()                  // 从这里开始是本地委托
    .Select(s => FormatLabel(s.Name, s.Score))
    .ToList();

数据库提供程序不一定能翻译任意 .NET 方法、比较器或自定义函数。遇到翻译失败,先寻找提供程序支持的等价表达,再考虑经过小结果集边界转到本地。

本节自测

19
ToList() 与 AsEnumerable() 的核心差异是什么?
20
自定义 FormatLabel 方法为何应放在远程查询充分过滤与投影之后?

性能直觉与常见陷阱

操作典型时间是否可短路/流式主要风险
Where / SelectO(n)流式谓词副作用、重复枚举
Any / First最坏 O(n)可短路空序列语义、条件错误
OrderByO(n log n)需缓冲缺少次级键、内存占用
GroupBy平均 O(n)需建组高基数键、重复聚合
Join平均 O(n+m)需查找结构键不稳定、重复键放大结果
ToListO(n)立即物化过早加载、快照过大

常见陷阱清单:

  • 在 foreach 前先 Count(),让昂贵查询执行两次。
  • 用 First() 假定必有结果;不确定时应选择 FirstOrDefault() 并处理缺失。
  • 对数据库查询调用无法翻译的本地方法。
  • 在分页前没有完整排序,造成跨页漂移。
  • 在投影 DTO 之前加载整实体与导航集合。
  • 误以为查询定义是结果快照,忽略源集合变化。

本节自测

21
哪类运算符通常需要查看并缓冲全部输入,才能给出最终有序结果?
22
使用 FirstOrDefault() 后,调用方无需再区分缺失结果与有效默认值。

综合实践:构建训练营报名看板

目标:输出成绩不低于 85 分的学生及其课程;没有报名也要显示;同一课程只保留一次;最终按成绩降序、姓名和课程升序,并映射为 DTO。

csharp
var dashboard = students
    .Where(s => s.Score >= 85)
    .GroupJoin(
        enrollments,
        s => s.Id,
        e => e.StudentId,
        (s, courses) => new
        {
            Student = s,
            Courses = courses
                .DistinctBy(e => e.Course)
                .DefaultIfEmpty()
        })
    .SelectMany(
        x => x.Courses,
        (x, course) => new EnrollmentDto(
            x.Student.Name,
            course?.Course ?? "未报名",
            x.Student.Score))
    .OrderByDescending(x => x.Score)
    .ThenBy(x => x.Name)
    .ThenBy(x => x.Course)
    .ToList();
 
foreach (var row in dashboard)
    Console.WriteLine($"{row.Score} | {row.Name} | {row.Course}");
console
92 | 小林 | LINQ
92 | 小林 | Web API
88 | 小何 | 未报名
85 | 小陈 | 单元测试

进一步练习:

  1. 把最低分数改成参数,并为空课程增加 Fee = 0 的 DTO 字段。
  2. 增加每组报名总额统计,输出组名、人数、课程数与总额。
  3. 把内存数据替换为数据库查询,标注哪一步可能无法翻译。
  4. 构造 10 万条随机数据,对比单次枚举与重复枚举耗时。

本节自测

23
综合示例先过滤学生再执行 GroupJoin,带来了哪些效果?
24
综合示例中的 DefaultIfEmpty() 承担什么业务语义?

小结:把 LINQ 当作可审查的执行计划

可靠的 LINQ 不只是把循环写短。你需要同时读出四件事:元素如何变化、查询何时执行、成本在哪里产生、远程与本地的边界在哪里。

  • Where 缩小集合,Select 塑造 DTO,完整排序保证稳定结果。
  • 查询语法与方法语法可以按可读性选择和混用。
  • 延迟执行让查询保持鲜活,物化让结果成为快照;重复枚举会重复付费。
  • GroupBy 做摘要,Join 做匹配,GroupJoin 保留一组匹配,SelectMany 展开一对多。
  • IQueryable 的关键是可翻译性;先在远程端过滤与投影,再谨慎越过本地边界。

本节自测

25
代码评审一条 LINQ 查询时,应检查哪些方面?
26
在结果规模可控的前提下,哪些情况适合主动调用 ToList()?
上一章C# 错误处理:从异常传播到可靠边界下一章C# 委托与事件:从类型安全回调到生命周期管理