C# LINQ 从入门到精通——用 SQL 一样的语法操作集合,Where、Select、GroupBy 带你告别 foreach 地狱
目录
为什么需要 LINQ?
LINQ 的两种写法:查询语法与方法语法
最常用的 10 个标准查询运算符
延迟执行 vs 立即执行(重要概念)
LINQ to Objects 实战:学生成绩分析
LINQ to SQL 与 Entity Framework 初探
综合实战:员工薪资报表系统
练习题答案及解析
性能提示与避坑指南
下一步学习建议
1. 为什么需要 LINQ?
回想一下,没有 LINQ 之前,我们要从一个集合中筛选数据,通常要写繁琐的foreach循环 +if判断:
csharp
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; List<int> evenNumbers = new List<int>(); foreach (var n in numbers) { if (n % 2 == 0) evenNumbers.Add(n); }这还只是最简单的筛选,如果需要排序、分组、连接多个集合,代码会变得又长又难维护。
LINQ(Language Integrated Query,语言集成查询)就是为了解决这个问题而生的。它允许你用类似 SQL 的语法直接写在 C# 代码中,对数组、集合、数据库、XML 等数据源进行统一的查询操作。
核心优势:
可读性强:查询意图一目了然
类型安全:编译时检查,避免运行时错误
统一 API:无论数据源是什么,查询语法都一样
2. LINQ 的两种写法
LINQ 提供了两种等价的写法,你可以根据自己的喜好选择。
2.1 查询语法(Query Syntax)
看起来就像 SQL 语句,使用from、where、select等关键字:
csharp
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // 查询语法:筛选出偶数并按降序排列 var result = from n in numbers where n % 2 == 0 orderby n descending select n; foreach (var item in result) Console.Write(item + " "); // 输出:8 6 4 22.2 方法语法(Method Syntax / Fluent Syntax)
使用扩展方法(如Where、Select)配合 Lambda 表达式:
csharp
var result = numbers.Where(n => n % 2 == 0) .OrderByDescending(n => n); // 输出同上
2.3 哪种写法更好?
查询语法更接近 SQL,容易上手,但功能有限(不支持所有操作符,如
TakeWhile)。方法语法更强大、更灵活,几乎所有 LINQ 操作都能用。
推荐混用:复杂查询用查询语法,特殊操作用方法语法。大多数 C# 开发者偏好方法语法。
3. 最常用的 10 个标准查询运算符
| 运算符 | 作用 | 示例(方法语法) |
|---|---|---|
Where | 条件筛选 | list.Where(x => x > 10) |
Select | 投影(转换) | list.Select(x => x * 2) |
SelectMany | 展平嵌套集合 | list.SelectMany(x => x.Items) |
OrderBy/OrderByDescending | 升序/降序排序 | list.OrderBy(x => x.Age) |
ThenBy/ThenByDescending | 次要排序 | list.OrderBy(x => x.Age).ThenBy(x => x.Name) |
GroupBy | 分组 | list.GroupBy(x => x.Category) |
Join | 内连接 | list1.Join(list2, ...) |
GroupJoin | 分组连接(左外连接) | list1.GroupJoin(list2, ...) |
Any/All | 是否存在/是否全部满足 | list.Any(x => x > 5) |
First/FirstOrDefault | 取第一个元素 | list.First(x => x > 5) |
Single/SingleOrDefault | 取唯一元素 | list.Single(x => x == 5) |
Skip/Take | 分页 | list.Skip(10).Take(20) |
Aggregate | 累计(求和、拼接等) | list.Aggregate((a,b) => a + b) |
每个运算符都返回一个新的集合或单个值,不会修改原始集合。
4. 延迟执行 vs 立即执行(重要概念)
这是新手最容易踩的坑!
4.1 延迟执行(Deferred Execution)
像Where、Select、OrderBy这些返回IEnumerable<T>的操作符,它们不会立即执行,而是在你开始遍历结果时才真正去查询数据源。
csharp
var numbers = new List<int> { 1, 2, 3 }; var query = numbers.Where(n => n > 1); // 此时还没执行查询 numbers.Add(4); // 修改了数据源 foreach (var n in query) // 这里才执行查询,会包含 4 { Console.WriteLine(n); // 输出 2, 3, 4 }好处:每次遍历都获取最新数据,且可以组合多个操作,只遍历一次集合。
4.2 立即执行(Immediate Execution)
像ToList()、ToArray()、Count()、First()等操作,会立即执行查询并返回结果(集合或单个值)。
csharp
var numbers = new List<int> { 1, 2, 3 }; var listResult = numbers.Where(n => n > 1).ToList(); // 立即执行 numbers.Add(4); foreach (var n in listResult) // 不会包含 4,因为已经固化 { Console.WriteLine(n); // 输出 2, 3 }记住:
延迟执行的查询结果是一个“查询对象”,每次遍历都会重新执行。
立即执行的结果是快照,不受后续数据源变化影响。
5. LINQ to Objects 实战:学生成绩分析
我们用一个学生列表来演示各种 LINQ 操作。
5.1 准备数据
csharp
public class Student { public int Id { get; set; } public string Name { get; set; } public string Class { get; set; } // 班级 public int Score { get; set; } } List<Student> students = new List<Student> { new Student { Id = 1, Name = "张三", Class = "A班", Score = 85 }, new Student { Id = 2, Name = "李四", Class = "B班", Score = 92 }, new Student { Id = 3, Name = "王五", Class = "A班", Score = 78 }, new Student { Id = 4, Name = "赵六", Class = "B班", Score = 88 }, new Student { Id = 5, Name = "孙七", Class = "A班", Score = 95 }, new Student { Id = 6, Name = "周八", Class = "C班", Score = 60 }, };5.2 常见查询示例
csharp
// 1. 成绩大于 80 的学生(按分数降序) var highScorers = students.Where(s => s.Score > 80) .OrderByDescending(s => s.Score); foreach (var s in highScorers) Console.WriteLine($"{s.Name} - {s.Score}"); // 2. 只显示姓名和班级(投影) var nameClass = students.Select(s => new { s.Name, s.Class }); foreach (var item in nameClass) Console.WriteLine($"{item.Name} 在 {item.Class}"); // 3. 按班级分组,计算每个班的平均分 var groupByClass = students.GroupBy(s => s.Class) .Select(g => new { Class = g.Key, AvgScore = g.Average(s => s.Score), Count = g.Count() }); foreach (var g in groupByClass) Console.WriteLine($"班级 {g.Class}:平均分 {g.AvgScore:F2},人数 {g.Count}"); // 4. 最高分的学生(使用 OrderByDescending + First) var topStudent = students.OrderByDescending(s => s.Score).First(); Console.WriteLine($"最高分:{topStudent.Name} - {topStudent.Score}"); // 5. 是否存在不及格学生(Any) bool hasFailed = students.Any(s => s.Score < 60); Console.WriteLine($"存在不及格?{hasFailed}"); // 6. 所有学生是否都及格(All) bool allPassed = students.All(s => s.Score >= 60); Console.WriteLine($"全部及格?{allPassed}"); // 7. 取前 3 名(分页) var top3 = students.OrderByDescending(s => s.Score).Take(3); foreach (var s in top3) Console.WriteLine($"前三名:{s.Name} - {s.Score}"); // 8. 跳过前 3 名,取剩下的(分页) var rest = students.OrderByDescending(s => s.Score).Skip(3); foreach (var s in rest) Console.WriteLine($"其余:{s.Name} - {s.Score}");6. LINQ to SQL 与 Entity Framework 初探
LINQ 不仅能查内存中的集合,还能查数据库!这是通过LINQ to SQL或Entity Framework Core实现的。
当你使用 EF Core 时,可以这样查询数据库:
csharp
using (var db = new SchoolContext()) { // 查询成绩大于 80 的学生(实际会生成 SQL 语句) var highScorers = db.Students .Where(s => s.Score > 80) .OrderByDescending(s => s.Score) .ToList(); }EF Core 会把你的 LINQ 表达式翻译成 SQL,发送给数据库执行,并返回对象。这就是LINQ to Entities。
核心要点:
对于
IQueryable<T>(EF Core 查询),LINQ 操作是延迟执行的,直到ToList()等立即执行方法才会生成 SQL。你写的 LINQ 语句不会加载所有数据到内存,而是生成高效的 SQL。
7. 综合实战:员工薪资报表系统
我们将构建一个控制台程序,使用 LINQ 对员工数据进行复杂的统计分析。
7.1 数据模型
csharp
public class Employee { public int Id { get; set; } public string Name { get; set; } public string Department { get; set; } public double Salary { get; set; } public DateTime HireDate { get; set; } }7.2 生成测试数据
csharp
List<Employee> employees = new List<Employee> { new Employee { Id = 1, Name = "Alice", Department = "HR", Salary = 5000, HireDate = new DateTime(2020, 1, 15) }, new Employee { Id = 2, Name = "Bob", Department = "IT", Salary = 8000, HireDate = new DateTime(2019, 5, 20) }, new Employee { Id = 3, Name = "Charlie", Department = "IT", Salary = 9500, HireDate = new DateTime(2018, 3, 10) }, new Employee { Id = 4, Name = "David", Department = "Sales", Salary = 6000, HireDate = new DateTime(2021, 7, 1) }, new Employee { Id = 5, Name = "Eva", Department = "HR", Salary = 5200, HireDate = new DateTime(2020, 11, 12) }, new Employee { Id = 6, Name = "Frank", Department = "IT", Salary = 7000, HireDate = new DateTime(2022, 2, 28) }, new Employee { Id = 7, Name = "Grace", Department = "Sales", Salary = 7500, HireDate = new DateTime(2019, 9, 5) }, };7.3 完整报表功能
csharp
static void Main() { // 1. 各部门平均薪资(降序) var deptAvg = employees.GroupBy(e => e.Department) .Select(g => new { Dept = g.Key, AvgSalary = g.Average(e => e.Salary) }) .OrderByDescending(x => x.AvgSalary); Console.WriteLine("=== 各部门平均薪资 ==="); foreach (var item in deptAvg) Console.WriteLine($"{item.Dept}: {item.AvgSalary:C}"); // 2. 薪资最高的前 3 名员工 var top3 = employees.OrderByDescending(e => e.Salary) .Take(3) .Select(e => new { e.Name, e.Salary, e.Department }); Console.WriteLine("\n=== 薪资 Top 3 ==="); foreach (var e in top3) Console.WriteLine($"{e.Name} ({e.Department}) - {e.Salary:C}"); // 3. 各部门入职人数(按年份分组) var hireByDeptYear = employees.GroupBy(e => e.Department) .Select(g => new { Dept = g.Key, Data = g.GroupBy(e => e.HireDate.Year) .Select(y => new { Year = y.Key, Count = y.Count() }) }); Console.WriteLine("\n=== 各部门年度入职人数 ==="); foreach (var dept in hireByDeptYear) { Console.WriteLine($"【{dept.Dept}】"); foreach (var yearData in dept.Data.OrderBy(y => y.Year)) Console.WriteLine($" {yearData.Year}年: {yearData.Count}人"); } // 4. 统计各部门薪资总额、最高、最低 var stats = employees.GroupBy(e => e.Department) .Select(g => new { Dept = g.Key, Total = g.Sum(e => e.Salary), Max = g.Max(e => e.Salary), Min = g.Min(e => e.Salary), Count = g.Count() }); Console.WriteLine("\n=== 各部门薪资统计 ==="); foreach (var s in stats) Console.WriteLine($"{s.Dept}: 总额 {s.Total:C}, 最高 {s.Max:C}, 最低 {s.Min:C}, 人数 {s.Count}"); // 5. 找出公司内薪资高于部门平均薪资的员工(关联子查询) var aboveDeptAvg = employees.Select(e => new { Employee = e, DeptAvg = employees.Where(emp => emp.Department == e.Department) .Average(emp => emp.Salary) }) .Where(x => x.Employee.Salary > x.DeptAvg) .Select(x => new { x.Employee.Name, x.Employee.Salary, DeptAvg = x.DeptAvg }); Console.WriteLine("\n=== 高于部门平均薪资的员工 ==="); foreach (var item in aboveDeptAvg) Console.WriteLine($"{item.Name} (薪资 {item.Salary:C}) 高于部门平均 {item.DeptAvg:C}"); }7.4 运行效果(部分输出)
text
=== 各部门平均薪资 === IT: $8,166.67 Sales: $6,750.00 HR: $5,100.00 === 薪资 Top 3 === Charlie (IT) - $9,500.00 Bob (IT) - $8,000.00 Grace (Sales) - $7,500.00 ...
8. 练习题
练习 1:基本筛选与投影
给定一个整数列表
List<int> numbers = new List<int> { 5, 12, 8, 3, 20, 15, 7, 9 };
用 LINQ 筛选出大于 10 的数字,并按降序输出。
将每个数字乘以 2,然后输出。
取出前 3 个偶数。
练习 2:分组与聚合(订单系统)
定义一个
Order类(OrderId,CustomerId,Product,Quantity,Price)。创建包含 6~8 个订单的列表。
按
CustomerId分组,计算每个客户的订单总金额。按
Product分组,计算每种产品的总销量和总销售额。找出消费总额最高的客户 ID。
练习 3:复杂查询——员工与部门关联
定义
Department类(DeptId,DeptName)和Employee类(EmpId,Name,DeptId,Salary)。
创建部门和员工的对应关系(多个员工属于一个部门)。
使用
Join查询每个员工的姓名、薪资和部门名称。按部门分组,计算每个部门的平均薪资,并输出部门名称和平均薪资。
(选做)查询平均薪资高于公司平均薪资的部门。
9. 练习题答案及解析
练习1 答案
csharp
// 1. 大于10降序 var result1 = numbers.Where(n => n > 10).OrderByDescending(n => n); // 2. 每个乘2 var result2 = numbers.Select(n => n * 2); // 3. 前3个偶数 var result3 = numbers.Where(n => n % 2 == 0).Take(3);
练习2 答案(核心代码)
csharp
// 按客户分组,计算总金额 var customerTotal = orders.GroupBy(o => o.CustomerId) .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Quantity * o.Price) }); // 按产品分组 var productStats = orders.GroupBy(o => o.Product) .Select(g => new { Product = g.Key, TotalQuantity = g.Sum(o => o.Quantity), TotalSales = g.Sum(o => o.Quantity * o.Price) }); // 最高消费客户 var topCustomer = customerTotal.OrderByDescending(x => x.Total).First().CustomerId;练习3 答案(Join 示例)
csharp
// 1. Join 查询 var empWithDept = employees.Join(departments, e => e.DeptId, d => d.DeptId, (e, d) => new { e.Name, e.Salary, DeptName = d.DeptName }); // 2. 按部门平均薪资 var deptAvg = employees.GroupBy(e => e.DeptId) .Join(departments, g => g.Key, d => d.DeptId, (g, d) => new { DeptName = d.DeptName, AvgSalary = g.Average(e => e.Salary) }); // 3. 高于公司平均的部门(选做) double companyAvg = employees.Average(e => e.Salary); var aboveAvgDepts = deptAvg.Where(x => x.AvgSalary > companyAvg);10. 性能提示与避坑指南
⚠️ 陷阱1:多次遍历同一个延迟查询
csharp
var query = numbers.Where(n => n > 5); int count = query.Count(); // 第一次遍历 foreach (var n in query) { } // 第二次遍历如果数据源是数据库查询,这会导致两次 SQL 执行。解决:如果反复使用,先ToList()缓存。
⚠️ 陷阱2:在 LINQ 中修改数据源
在foreach遍历查询结果时修改原始集合会引发异常。应先ToList()再遍历。
⚠️ 陷阱3:滥用Count()判断是否为空
csharp
if (list.Count() > 0) { } // ❌ 会遍历整个集合 if (list.Any()) { } // ✅ 只要发现一个元素就返回 true,性能更好⚠️ 陷阱4:在Where中调用耗时方法
Where中的委托会在每次遍历时执行,如果里面有耗时操作(如网络请求),性能会很差。解决:先在内存中准备好数据,或使用Select预处理。
✅ 最佳实践
优先使用
Any()而非Count() > 0。链式调用时,把
Where等筛选操作放在前面,减少后续处理的数据量。对数据库查询,避免在
Where中使用不能翻译为 SQL 的本地方法(如DateTime.Now的复杂计算)。使用
Select投影时,只选择需要的字段,减少数据传输量。
11. 下一步学习建议
表达式树(Expression Trees):了解 LINQ to SQL 背后的编译原理。
PLINQ(并行 LINQ):利用多核处理器加速查询。
动态 LINQ:在运行时构建查询字符串(可用于动态报表)。
深入 EF Core:掌握导航属性、Include、过滤等高级查询。
实战项目:用 LINQ 重构之前的学生管理系统、游戏角色系统,把循环全部替换成 LINQ 语句。
最后的话
LINQ 是 C# 开发者最爱的特性之一,它让数据查询变得像呼吸一样自然。你可能一开始觉得查询语法和方法语法有点绕,但只要坚持写几个实际场景,就会爱上它。
今天的员工报表系统演示了 LINQ 在真实业务中的威力——分组、聚合、排序、关联查询一气呵成,代码清晰且易于维护。
记住两条黄金法则:
延迟执行是双刃剑——善用它,但也别忘了
ToList()来固定结果。能 LINQ 的就别手写循环——除非有极端的性能要求,否则 LINQ 的可读性和生产力远超手写。
把上面的练习做一遍,再试着用 LINQ 重写你以前写过的最复杂的查询逻辑,你会感受到质的飞跃。
下一篇我们将进入异常处理与调试的世界,让你的程序在出错时也能优雅应对。我们下一篇见!🚀
下一篇预告:C# 异常处理与调试技巧——
try-catch-finally全方位解读,教你写健壮的程序,并掌握 Visual Studio 调试利器。敬请期待!
(全文完)
