C# LINQ 入门到上手:一篇讲透查询语法、常用操作符与延迟执行(含示例与速查表)
写 C# 时,你一定遇到过这些需求:从一堆对象里挑出符合条件的、按某个字段排序、按城市分组、统计平均分......如果全用
foreach + if,代码又长又绕。LINQ(Language-Integrated Query,语言集成查询) 就是 C# 内置的"查询工具",它用统一的写法操作各种数据源,让"筛选、排序、分组、聚合"像写 SQL 一样自然。本文面向初学者,从是什么、常用操作符,到最重要的"延迟执行",配合一份学生示例数据讲清楚,文末附速查表,也适合复习时快速回顾。
一、什么是 LINQ
LINQ 全称 Language-Integrated Query(语言集成查询),一句话概括:
LINQ 用统一的查询语法操作各种数据源,让"筛选、排序、分组、聚合"像写 SQL 一样自然。
使用前需要引入命名空间(大多数 .NET 项目模板已默认包含):
csharp
using System.Linq;
1.1 几个核心接口
| 接口 | 说明 |
|---|---|
IEnumerable |
内存中的可枚举序列,对应 LINQ to Objects |
IQueryable |
可被表达式树翻译,例如 EF Core 会把它翻译成 SQL |
IOrderedEnumerable |
排序后的序列,可继续 ThenBy 追加排序条件 |
IGrouping |
分组结果,包含一个 Key 和该组的元素集合 |
1.2 传统写法 vs LINQ 写法
同样是"挑出分数不低于 80 的学生姓名并排序",传统 foreach:
csharp
var result = new List<string>();
foreach (var student in students)
{
if (student.Score >= 80)
{
result.Add(student.Name);
}
}
result.Sort();
用 LINQ 方法语法,链式调用一气呵成:
csharp
var result = students
.Where(s => s.Score >= 80) // 筛选
.OrderBy(s => s.Name) // 排序
.Select(s => s.Name) // 投影:只要姓名
.ToList(); // 转成 List
LINQ 的优势:可读性强、可自由组合、编译期类型安全,配合 Lambda、泛型和扩展方法使用;同一套思路既能查内存集合,也能查数据库。
1.3 LINQ 是怎么"跑"起来的
LINQ 查询分为"定义 "和"执行 "两个阶段:Where/OrderBy/Select 等只是在描述查询(延迟执行),直到遇到 foreach、ToList()、Count() 等才真正遍历数据。下图展示了完整过程:
【插图:LINQ 查询执行流程图】 (发布时在此插入图片
LINQ查询执行流程图.png)
这个"延迟执行"是 LINQ 最重要的概念,第四节会详细讲。
二、示例数据
后续示例统一使用下面的学生与城市数据,建议你也建一个控制台项目跟着敲:
csharp
public class Student
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Age { get; set; }
public string City { get; set; } = "";
public int Score { get; set; }
}
public class CityInfo
{
public string City { get; set; } = "";
public string Province { get; set; } = "";
}
var students = new List<Student>
{
new() { Id = 1, Name = "张三", Age = 20, City = "郑州", Score = 85 },
new() { Id = 2, Name = "李四", Age = 19, City = "北京", Score = 92 },
new() { Id = 3, Name = "王五", Age = 21, City = "郑州", Score = 78 },
new() { Id = 4, Name = "赵六", Age = 22, City = "上海", Score = 88 },
new() { Id = 5, Name = "钱七", Age = 18, City = "北京", Score = 95 },
};
var cities = new List<CityInfo>
{
new() { City = "郑州", Province = "河南" },
new() { City = "北京", Province = "北京" },
new() { City = "上海", Province = "上海" },
};
三、常用操作符详解
LINQ 操作符大多是 IEnumerable<T> 上的扩展方法,返回值通常还是 IEnumerable<T>,因此可以一直链式拼接。
3.1 筛选:Where、OfType
Where 接收一个返回 bool 的条件(Func<T, bool>),保留满足条件的元素:
csharp
var highScore = students.Where(s => s.Score >= 85); // 分数 >= 85
var zhengzhou = students.Where(s => s.City == "郑州"); // 郑州的学生
OfType 用于从混合集合里筛选出指定类型:
csharp
object[] mixed = { 1, "hello", 2, 3.5, "world" };
var ints = mixed.OfType<int>(); // 结果:1, 2
var strings = mixed.OfType<string>(); // 结果:hello, world
3.2 投影:Select、SelectMany
Select 做"一对一"转换,把每个元素映射成新的形式:
csharp
var names = students.Select(s => s.Name); // 只要姓名
var brief = students.Select(s => new // 投影成匿名对象
{
s.Name,
s.Score
});
SelectMany 用于"一对多"并把结果拍平成一个序列。例如把每个学生姓名拆成字符:
csharp
var allChars = students.SelectMany(s => s.Name);
foreach (var c in allChars)
{
Console.Write(c + " ");
}
// 如果每个学生有多门课程,则拍平所有课程:
// var allCourses = students.SelectMany(s => s.Courses);
关于 Select 有三个容易混淆的点,和"值类型/引用类型"密切相关:
Select(s => s):只是复制对象引用,不会新建对象;改返回集合里对象的属性,原集合里的对象也会变;Select(s => new { s.Name }):创建了全新的匿名对象,改新对象不影响原始数据;ToList()只是新建了一个 List 容器,容器里的引用类型对象仍是原来的对象,不会自动拷贝实例。
3.3 排序:OrderBy、ThenBy、OrderByDescending
OrderBy 升序、OrderByDescending 降序;多条件排序用 ThenBy / ThenByDescending 追加:
csharp
var ordered = students
.OrderBy(s => s.City) // 先按城市升序
.ThenByDescending(s => s.Score); // 城市相同,再按分数降序
注意:连续两次 OrderBy,第二次会覆盖 第一次排序,多条件必须用 ThenBy:
csharp
// 错误写法:第二个 OrderBy 覆盖第一个,最终只按 Score 排
students.OrderBy(s => s.City).OrderBy(s => s.Score);
// 正确写法:多条件排序用 ThenBy / ThenByDescending
students.OrderBy(s => s.City).ThenBy(s => s.Score);
补充:
List.Sort()是实例方法,原地修改 原 List 且没有返回值;LINQ 的OrderBy返回一个新序列,不改动原集合。
3.4 分组:GroupBy、ToLookup
GroupBy 按指定的键分组,结果是 IGrouping<TKey, TElement>,每组有 Key:
csharp
var groups = students.GroupBy(s => s.City); // 按城市分组
foreach (var group in groups)
{
Console.WriteLine($"{group.Key}: {group.Count()} 人");
foreach (var student in group)
{
Console.WriteLine($" {student.Name}");
}
}
ToLookup 也按键分组,但它会立即执行并生成一个可按键索引的"只读字典":
csharp
var lookup = students.ToLookup(s => s.City);
foreach (var s in lookup["郑州"]) // 直接取郑州这一组
{
Console.WriteLine(s.Name);
}
3.5 连接:Join、GroupJoin、Zip
Join 是内连接,按两个序列共有的键匹配(写法是:外键选择、内键选择、结果构造):
csharp
var query = students.Join(
cities,
s => s.City, // 学生的连接键
c => c.City, // 城市的连接键
(s, c) => new { s.Name, c.Province }); // 匹配后的结果
GroupJoin 是分组连接,常用来实现"左连接",为每个左序列元素带上一组匹配项:
csharp
var query3 = students.GroupJoin(
cities,
s => s.City,
c => c.City,
(s, cGroup) => new { Student = s, CityList = cGroup });
Zip 按位置(第 1 个对第 1 个)把两个序列合并:
csharp
var zipped = students.Zip(cities, (s, c) => $"{s.Name}-{c.Province}");
3.6 聚合:Count、Sum、Min、Max、Average、Aggregate
聚合操作把一个序列"算成"一个值:
csharp
int count = students.Count(); // 元素个数
int sum = students.Sum(s => s.Score); // 分数总和
int max = students.Max(s => s.Score); // 最高分
int min = students.Min(s => s.Score); // 最低分
double avg = students.Average(s => s.Score); // 平均分
int passCount = students.Count(s => s.Score >= 80); // 带条件的统计
Aggregate 是最灵活的自定义聚合,第一个参数是累加器初始值,第二个参数是每步怎么累加:
csharp
// 累加所有分数,等价于 Sum(s => s.Score)
var total = students.Aggregate(0, (sum, s) => sum + s.Score);
3.7 元素操作:First、FirstOrDefault、Single、Last
用于从序列里取单个元素:
csharp
var first = students.First(s => s.Score > 90); // 第一个分数>90的
var firstOrDefault = students.FirstOrDefault(s => s.Score > 100); // 没有则返回 null
var single = students.Single(s => s.Id == 1); // 唯一匹配 Id==1
var last = students.Last(); // 最后一个
它们在"找不到"和"有多个匹配"时的行为不同,这是高频考点:
| 方法 | 找不到时 | 有多个匹配时 |
|---|---|---|
First |
抛异常 | 返回第一个 |
FirstOrDefault |
返回默认值(引用类型为 null) | 返回第一个 |
Single |
抛异常 | 抛异常 |
SingleOrDefault |
返回默认值 | 抛异常 |
Last |
抛异常 | 返回最后一个 |
LastOrDefault |
返回默认值 | 返回最后一个 |
记忆:名字带 Single 的要求"有且仅有一个",多了就抛异常;带 OrDefault 的找不到不抛异常、给默认值。
3.8 量词:Any、All、Contains
Any 判断"是否存在",All 判断"是否全部满足":
csharp
bool anyHigh = students.Any(s => s.Score > 90); // 有没有分数>90的
bool allAdult = students.All(s => s.Age >= 18); // 是否都成年
bool hasZhang = students.Any(s => s.Name == "张三");
判断集合是否非空,用无参 Any() 比 Count() > 0 更高效------Any 找到第一个元素就返回,Count 要遍历全部:
csharp
if (students.Any()) { } // 推荐
// if (students.Count() > 0) {} // 不推荐
Contains 判断是否包含某个元素,常配合一组 id 做"在范围内"筛选:
csharp
var ids = new[] { 1, 3,5 };
bool exists = students.Any(s => ids.Contains(s.Id));
3.9 转换:ToList、ToArray、ToDictionary、ToLookup
csharp
var list = students.Where(s => s.Score >= 80).ToList(); // 转 List
var array = students.Select(s => s.Name).ToArray(); // 转数组
var dict = students.ToDictionary(s => s.Id, s => s.Name); // 转字典:Id -> Name
var lookup = students.ToLookup(s => s.City); // 转 Lookup
ToDictionary 的键不能重复,否则抛异常。若键可能重复,先 GroupBy 再转:
csharp
// 城市有重复,直接 ToDictionary(s => s.City) 会抛异常
var dict3 = students
.GroupBy(s => s.City)
.ToDictionary(g => g.Key, g => g.ToList());
四、延迟执行与执行时机(重点)
这是 LINQ 最重要、也最容易踩坑的概念。
4.1 什么是延迟执行
csharp
var query = students.Where(s => s.Score >= 80); // 只是定义查询
Console.WriteLine("查询已定义");
students.Add(new Student { Id = 99, Name = "新同学", Age = 22, City = "郑州", Score = 95 });
foreach (var s in query) // 此刻才真正执行
{
Console.WriteLine(s.Name);
}
输出里会包含"新同学" 。因为 Where 只保存了"怎么查"的逻辑,并没有立刻执行;真正遍历发生在 foreach 时,而那时集合里已经有新同学了。
延迟执行的操作符只定义查询、不立即访问数据;真正执行通常发生在
foreach、ToList()、Count()、First()、Any()等时刻。
4.2 哪些操作立即执行
下面这些操作一调用就会遍历数据、返回具体结果:
ToList()、ToArray()、ToDictionary()、ToLookup()Count()、Sum()、Min()、Max()、Average()First()、FirstOrDefault()、Single()及其OrDefaultAny()、All()
4.3 延迟执行带来的"多次枚举"
csharp
var query = students.Where(s => s.Score >= 80);
Console.WriteLine(query.Count()); // 第一次枚举
Console.WriteLine(query.Count()); // 又枚举一次
对内存集合只是多遍历两遍;但如果数据源是数据库(IQueryable),这会向数据库发两次查询。需要重复使用结果时,先物化一次:
csharp
var list = query.ToList(); // 只查一次,结果放进内存
Console.WriteLine(list.Count);
Console.WriteLine(list.Count);
4.4 流式与非流式操作符
| 类型 | 操作符 | 说明 |
|---|---|---|
| 流式 | Where、Select、Take |
逐个处理,来一个处理一个,不用加载全部 |
| 非流式 | OrderBy、GroupBy、Join |
需要缓冲全部(或部分)数据才能处理 |
例如 OrderBy 必须看到所有元素才能确定顺序,所以它是非流式的。
五、最佳实践
-
判断"是否存在"用
Any(),不要用Count(...) > 0:前者遇到第一个匹配就返回。csharpif (students.Any(s => s.Score > 90)) { } // 推荐 // if (students.Count(s => s.Score > 90) > 0) {} // 不推荐 -
结果要复用就先
ToList(),避免对同一个延迟查询多次枚举(数据库场景尤其重要)。 -
投影只取需要的字段 :
Select(s => s.Name),数据库场景能减少读取的列。 -
条件直接传给
FirstOrDefault:students.FirstOrDefault(s => s.Id == 1),不必先Where再FirstOrDefault。 -
复杂条件抽成命名方法 ,比堆一长串
&&更易读:csharpstudents.Where(IsTargetStudent); static bool IsTargetStudent(Student s) { return s.Age > 18 && s.City == "郑州" && s.Score > 80 && s.Name.StartsWith("张"); }
六、常见坑
-
First在找不到时抛异常 。不确定是否存在,用FirstOrDefault并判空。 -
Single在有多个匹配时也抛异常。它要求"有且仅有一个",像"郑州的学生"这种有多个的就不能用。 -
多条件排序别用两个
OrderBy,第二个会覆盖第一个;追加条件用ThenBy。 -
延迟执行导致重复查询 :同一个查询调用两次
Count()会枚举两遍,数据库就是两次 SQL,记得先ToList。 -
ToDictionary键重复会抛异常 :键可能重复时改用ToLookup,或先GroupBy。 -
Distinct对引用类型默认按引用比较 :属性相同但不是同一个实例不会被去重;需要按属性去重时,重写Equals/GetHashCode或传入比较器。 -
闭包捕获的是变量、不是值。看下面例子:
csharpvar actions = new List<Func<int>>(); for (int i = 0; i < 3; i++) { actions.Add(() => i); // 捕获的是循环变量 i 本身 } foreach (var action in actions) { Console.Write(action() + " "); // 输出:3 3 3 }因为三个 Lambda 捕获的是同一个 循环变量
i,循环结束时它已经是 3,所以都返回 3。在循环内拷贝一份局部变量即可:csharpfor (int i = 0; i < 3; i++) { int copy = i; // 每次循环一个新变量 actions.Add(() => copy); // 输出:0 1 2 } -
别用 LINQ 堆超长单行:又筛选又分组又排序的一大串难以维护,应拆分成几步、起好名字、必要时加注释。
七、LINQ 操作符速查表
| 分类 | 操作符 | 说明 |
|---|---|---|
| 筛选 | Where、OfType |
条件筛选、类型筛选 |
| 投影 | Select、SelectMany |
转换、拍平 |
| 排序 | OrderBy、ThenBy、OrderByDescending |
排序 |
| 分组 | GroupBy、ToLookup |
分组 |
| 连接 | Join、GroupJoin、Zip |
连接、按位置合并 |
| 聚合 | Count、Sum、Min、Max、Average、Aggregate |
聚合为一个值 |
| 元素 | First、FirstOrDefault、Single、Last |
取单个元素 |
| 量词 | Any、All、Contains |
判断存在 / 全部满足 |
| 分页 | Skip、Take、SkipWhile、TakeWhile |
跳过 / 取若干 |
| 集合 | Distinct、Union、Intersect、Except |
集合运算 |
| 转换 | ToList、ToArray、ToDictionary、ToLookup |
立即执行并转换 |
| 生成 | Range、Repeat、Empty |
生成序列 |
| 其他 | SequenceEqual、DefaultIfEmpty、Cast |
辅助 |
一句话总结:LINQ 是 C# 中统一的查询工具,核心是
IEnumerable和IQueryable上的一组扩展方法,配合 Lambda 完成筛选、排序、分组、连接、聚合和转换。
最后记住三件事:
Where筛选、Select投影、OrderBy排序、GroupBy分组、Join连接;- LINQ 默认延迟执行,
ToList()、foreach、Count()、First()才会触发执行; IEnumerable用于内存查询,IQueryable用于数据库查询,数据库场景不要过早ToList()。
