C# LINQ 联接与分组详解
一、联接和分组做什么?
1.1 生活比喻
联接(Join) 就像是:
你有两份名单——学生名单上只有城市名,城市评级表上有城市的 GDP 和等级。你想把两份表"对"起来,给每个学生补上他所在城市的信息。这就是联接。
分组(GroupBy) 就像是:
一堆散落的学生档案,你要按城市把它们分成几摞——北京的放一摞,上海的放一摞。每摞上贴个标签,这就是分组。
1.2 一句话理解
联接 = Join = 把两张表按某个关联键拼在一起
分组 = GroupBy = 把一张表按某个标准拆成一组一组
二、准备测试数据
以下所有示例都基于这两份数据:
using System;
using System.Collections.Generic;
using System.Linq;
// ===== 学生表 =====
public class Student
{
public int Id;
public string Name;
public int Age;
public int Score;
public string City; // 关联键
public string Subject;
}
List<Student> students = new List<Student>
{
new Student { Id = 1, Name = "张三", Age = 18, Score = 92, City = "北京", Subject = "数学" },
new Student { Id = 2, Name = "李四", Age = 19, Score = 85, City = "上海", Subject = "数学" },
new Student { Id = 3, Name = "王五", Age = 18, Score = 76, City = "北京", Subject = "语文" },
new Student { Id = 4, Name = "赵六", Age = 20, Score = 58, City = "广州", Subject = "数学" },
new Student { Id = 5, Name = "孙七", Age = 19, Score = 88, City = "上海", Subject = "语文" },
new Student { Id = 6, Name = "周八", Age = 20, Score = 95, City = "北京", Subject = "英语" },
new Student { Id = 7, Name = "吴九", Age = 18, Score = 45, City = "广州", Subject = "英语" },
new Student { Id = 8, Name = "郑十", Age = 19, Score = 72, City = "深圳", Subject = "数学" },
};
// ===== 城市信息表 =====
public class CityInfo
{
public string City; // 关联键
public string Province;
public string Rating;
public int GDP; // 亿元
}
List<CityInfo> cities = new List<CityInfo>
{
new CityInfo { City = "北京", Province = "北京", Rating = "一线", GDP = 40000 },
new CityInfo { City = "上海", Province = "上海", Rating = "一线", GDP = 43000 },
new CityInfo { City = "广州", Province = "广东", Rating = "一线", GDP = 28000 },
new CityInfo { City = "深圳", Province = "广东", Rating = "一线", GDP = 30000 },
new CityInfo { City = "杭州", Province = "浙江", Rating = "新一线", GDP = 18000 },
new CityInfo { City = "成都", Province = "四川", Rating = "新一线", GDP = 20000 },
};
第一部分:联接(Join)
三、Join —— 内连接(INNER JOIN)
3.1 什么是内连接?
只返回两张表中能匹配上的数据。 匹配不上的(如杭州、成都没有学生)不出现。
// 学生表和城市表做联接,关联键是 City
var joined = students.Join(
cities, // 1. 第二张表
s => s.City, // 2. 学生表用什么字段关联
c => c.City, // 3. 城市表用什么字段关联
(s, c) => new // 4. 匹配成功后生成什么结果
{
s.Name,
s.City,
c.Province,
c.Rating,
s.Score
}
);
foreach (var item in joined)
Console.WriteLine($"{item.Name,-6} | {item.City,-4} | {item.Province,-4} | {item.Rating} | {item.Score}分");
输出:
张三 | 北京 | 北京 | 一线 | 92分
李四 | 上海 | 上海 | 一线 | 85分
王五 | 北京 | 北京 | 一线 | 76分
赵六 | 广州 | 广东 | 一线 | 58分
孙七 | 上海 | 上海 | 一线 | 88分
周八 | 北京 | 北京 | 一线 | 95分
吴九 | 广州 | 广东 | 一线 | 45分
郑十 | 深圳 | 广东 | 一线 | 72分
注意:杭州和成都在城市表中有,但没有学生,所以不出现。这就是内连接。
3.2 查询语法写 Join(更直观)
// 查询语法——像写 SQL 一样
var joined = from s in students
join c in cities on s.City equals c.City
select new
{
s.Name,
s.City,
c.Province,
c.Rating,
s.Score
};
// 和上面方法语法的结果一模一样
查询语法中 join 的格式:
join 范围变量 in 第二张表 on 左键 equals 右键
注意:是
equals不是==!这是 LINQ 的固定语法。
3.3 Join 后加筛选和排序
// 联表 + 筛选 + 排序
var result = from s in students
join c in cities on s.City equals c.City
where s.Score >= 60 && c.Rating == "一线" // 联表后筛选
orderby s.Score descending // 联表后排序
select new
{
s.Name,
s.City,
c.Province,
c.Rating,
s.Score
};
foreach (var item in result)
Console.WriteLine($"{item.Name,-6} | {item.City,-4} | {item.Province,-4} | {item.Rating} | {item.Score}分");
输出:
周八 | 北京 | 北京 | 一线 | 95分
张三 | 北京 | 北京 | 一线 | 92分
孙七 | 上海 | 上海 | 一线 | 88分
李四 | 上海 | 上海 | 一线 | 85分
王五 | 北京 | 北京 | 一线 | 76分
郑十 | 深圳 | 广东 | 一线 | 72分
3.4 多表联接(三张表)
// 第三张表:科目信息
public class SubjectInfo
{
public string Subject;
public string TeacherName;
public int Credit;
}
List<SubjectInfo> subjects = new List<SubjectInfo>
{
new SubjectInfo { Subject = "数学", TeacherName = "陈老师", Credit = 5 },
new SubjectInfo { Subject = "语文", TeacherName = "刘老师", Credit = 4 },
new SubjectInfo { Subject = "英语", TeacherName = "王老师", Credit = 4 },
};
// 三表联接
var threeJoined = from s in students
join c in cities on s.City equals c.City
join subj in subjects on s.Subject equals subj.Subject
where s.Score >= 60
orderby s.Score descending
select new
{
s.Name,
s.City,
c.Province,
s.Subject,
subj.TeacherName,
subj.Credit,
s.Score
};
foreach (var item in threeJoined)
{
Console.WriteLine($"{item.Name,-6} | {item.City,-4} | {item.Subject,-4} | "
+ $"{item.TeacherName,-6} | {item.Credit}学分 | {item.Score}分");
}
输出:
周八 | 北京 | 英语 | 王老师 | 4学分 | 95分
张三 | 北京 | 数学 | 陈老师 | 5学分 | 92分
孙七 | 上海 | 语文 | 刘老师 | 4学分 | 88分
李四 | 上海 | 数学 | 陈老师 | 5学分 | 85分
王五 | 北京 | 语文 | 刘老师 | 4学分 | 76分
郑十 | 深圳 | 数学 | 陈老师 | 5学分 | 72分
四、GroupJoin —— 分组联接(LEFT JOIN 效果)
4.1 什么是分组联接?
左边表的每条记录都保留,右表中匹配的记录被封装成一个"组"。如果右表没匹配到,这个组就是空的。
相当于 SQL 的 LEFT JOIN。
生活比喻:
城市表是"主人列表"(5 个城市),学生表是"访客列表"。现在以城市表为主,看每个城市都有哪些学生。没有学生的城市(杭州、成都)也会出现,只是学生数为 0。
4.2 方法语法
var groupJoined = cities.GroupJoin(
students, // 第二张表
c => c.City, // 城市表的关联键
s => s.City, // 学生表的关联键
(city, studentGroup) => new // city: 城市表的单条记录
// studentGroup: 这个城市下的所有学生
{
city.City,
city.Province,
city.Rating,
city.GDP,
StudentCount = studentGroup.Count(),
AvgScore = studentGroup.Any()
? studentGroup.Average(s => s.Score)
: (double?)null,
Students = studentGroup // 可以继续操作组内数据
}
);
foreach (var item in groupJoined)
{
Console.Write($"{item.City,-4} ({item.Province}) | {item.Rating} | GDP: {item.GDP}亿 | ");
if (item.StudentCount > 0)
Console.WriteLine($"{item.StudentCount}人, 均分{item.AvgScore:F1}");
else
Console.WriteLine("无学生");
}
输出:
北京 (北京) | 一线 | GDP: 40000亿 | 3人, 均分87.7
上海 (上海) | 一线 | GDP: 43000亿 | 2人, 均分86.5
广州 (广东) | 一线 | GDP: 28000亿 | 2人, 均分51.5
深圳 (广东) | 一线 | GDP: 30000亿 | 1人, 均分72.0
杭州 (浙江) | 新一线 | GDP: 18000亿 | 无学生
成都 (四川) | 新一线 | GDP: 20000亿 | 无学生
注意:杭州和成都虽然没有学生,但依然出现在结果中!这就是 GroupJoin 和内连接的区别。
4.3 查询语法(用 join ... into)
// 查询语法:join ... into 就是 GroupJoin
var groupJoined = from c in cities
join s in students on c.City equals s.City into studentGroup
select new
{
c.City,
c.Province,
c.Rating,
StudentCount = studentGroup.Count(),
AvgScore = studentGroup.Any()
? studentGroup.Average(s => s.Score)
: (double?)null
};
foreach (var item in groupJoined)
{
Console.Write($"{item.City,-4} ({item.Province}) | {item.Rating} | ");
if (item.StudentCount > 0)
Console.WriteLine($"{item.StudentCount}人, 均分{item.AvgScore:F1}");
else
Console.WriteLine("无学生");
}
join ... into 语法格式:
from 左表范围变量 in 左表
join 右表范围变量 in 右表 on 左键 equals 右键 into 分组变量
select ...
4.4 GroupJoin 后展开组内数据
// 列出每个城市的具体学生
var detail = from c in cities
join s in students on c.City equals s.City into studentGroup
select new
{
c.City,
c.Province,
Students = studentGroup.Select(s => new
{
s.Name,
s.Subject,
s.Score
})
};
foreach (var city in detail)
{
Console.WriteLine($"--- {city.City} ({city.Province}) ---");
if (city.Students.Any())
{
foreach (var s in city.Students)
Console.WriteLine($" {s.Name} - {s.Subject} - {s.Score}分");
}
else
{
Console.WriteLine(" (无学生)");
}
}
输出:
--- 北京 (北京) ---
张三 - 数学 - 92分
王五 - 语文 - 76分
周八 - 英语 - 95分
--- 上海 (上海) ---
李四 - 数学 - 85分
孙七 - 语文 - 88分
--- 广州 (广东) ---
赵六 - 数学 - 58分
吴九 - 英语 - 45分
--- 深圳 (广东) ---
郑十 - 数学 - 72分
--- 杭州 (浙江) ---
(无学生)
--- 成都 (四川) ---
(无学生)
五、Join vs GroupJoin —— 核心对比
| 特性 | Join(内连接) | GroupJoin(分组联接) |
|---|---|---|
| 匹配方式 | 只保留两边都匹配的 | 左表全部保留 |
| 没匹配的 | 直接丢弃 | 以空组形式保留 |
| 对应 SQL | INNER JOIN | LEFT JOIN |
| 结果形状 | 扁平的一维表 | 左表行 + 嵌套组 |
| 查询语法 | join ... on ... equals |
join ... on ... equals ... into |
| 适用场景 | 只要匹配数据 | 需要显示"零记录" |
图解区别:
Join(内连接):
学生表 城市表
张三 北京 ──┐ ┌── 北京 一线
李四 上海 ──┼──匹配──┼── 上海 一线
王五 北京 ──┘ ├── 广州 一线
├── 深圳 一线
├── 杭州 新一线 ← 没匹配上,丢弃
└── 成都 新一线 ← 没匹配上,丢弃
结果:8 条(学生数),杭州、成都不出现
GroupJoin(分组联接):
城市表(主表) 学生表
北京 ───→ [张三, 王五, 周八]
上海 ───→ [李四, 孙七]
广州 ───→ [赵六, 吴九]
深圳 ───→ [郑十]
杭州 ───→ [] ← 空组,但保留!
成都 ───→ [] ← 空组,但保留!
结果:6 条(城市数),全部城市都出现
第二部分:分组(GroupBy)
六、GroupBy —— 基本分组
6.1 什么是 GroupBy?
把集合中的元素按某个键分成一组一组。 每个组是一个 IGrouping<TKey, TElement>,它本身也是一个集合。
// 按城市分组
var byCity = students.GroupBy(s => s.City);
foreach (var group in byCity)
{
Console.WriteLine($"--- {group.Key} ({group.Count()}人) ---");
foreach (var s in group)
Console.WriteLine($" {s.Name}: {s.Score}分");
}
输出:
--- 北京 (3人) ---
张三: 92分
王五: 76分
周八: 95分
--- 上海 (2人) ---
李四: 85分
孙七: 88分
--- 广州 (2人) ---
赵六: 58分
吴九: 45分
--- 深圳 (1人) ---
郑十: 72分
6.2 理解 IGrouping
IGrouping<TKey, TElement> 的核心:
var byCity = students.GroupBy(s => s.City);
foreach (var group in byCity)
{
string city = group.Key; // ← 分组键
int count = group.Count(); // ← 组内数量
double avg = group.Average(s => s.Score); // ← 组内聚合(可以继续 LINQ)
int max = group.Max(s => s.Score); // ← 组内最大值
var names = group.Select(s => s.Name); // ← 组内投影
Console.WriteLine($"{city}: {count}人, 均分{avg:F1}, 最高{max}");
}
输出:
北京: 3人, 均分87.7, 最高95
上海: 2人, 均分86.5, 最高88
广州: 2人, 均分51.5, 最高58
深圳: 1人, 均分72.0, 最高72
七、GroupBy 的高级用法
7.1 分组 + 投影一步到位(方法语法)
// GroupBy 有三个参数:
// 1. keySelector(按什么分组)
// 2. elementSelector(每个元素取什么字段放进组)
// 3. resultSelector(每个组生成什么结果)
var stats = students.GroupBy(
s => s.City, // 1. 按城市分组
s => s.Score, // 2. 每组只放 Score
(city, scores) => new // 3. 对每组生成统计结果
{
City = city,
Count = scores.Count(),
Avg = scores.Average(),
Max = scores.Max(),
Min = scores.Min()
}
);
foreach (var s in stats)
Console.WriteLine($"{s.City}: {s.Count}人, 均分{s.Avg:F1}, 最高{s.Max}, 最低{s.Min}");
输出:
北京: 3人, 均分87.7, 最高95, 最低76
上海: 2人, 均分86.5, 最高88, 最低85
广州: 2人, 均分51.5, 最高58, 最低45
深圳: 1人, 均分72.0, 最高72, 最低72
7.2 复合键分组
// 按城市+科目 两个条件分组
var multiGroup = students.GroupBy(s => new { s.City, s.Subject });
foreach (var group in multiGroup)
{
Console.WriteLine($"--- {group.Key.City} - {group.Key.Subject} ({group.Count()}人) ---");
foreach (var s in group)
Console.WriteLine($" {s.Name}: {s.Score}分");
}
输出(部分):
--- 北京 - 数学 (1人) ---
张三: 92分
--- 北京 - 语文 (1人) ---
王五: 76分
--- 北京 - 英语 (1人) ---
周八: 95分
--- 上海 - 数学 (1人) ---
李四: 85分
...
7.3 自定义条件分组
// 按分数段分组
var byLevel = students.GroupBy(s =>
{
if (s.Score >= 90) return "A - 优秀";
if (s.Score >= 80) return "B - 良好";
if (s.Score >= 60) return "C - 及格";
return "D - 不及格";
});
foreach (var group in byLevel)
{
Console.WriteLine($"--- {group.Key} ({group.Count()}人) ---");
foreach (var s in group)
Console.WriteLine($" {s.Name}: {s.Score}分");
}
输出:
--- A - 优秀 (2人) ---
张三: 92分
周八: 95分
--- B - 良好 (2人) ---
李四: 85分
孙七: 88分
--- C - 及格 (2人) ---
王五: 76分
郑十: 72分
--- D - 不及格 (2人) ---
赵六: 58分
吴九: 45分
7.4 分组后按组聚合值排序
// 按城市分组,按平均分降序排列
var ordered = students
.GroupBy(s => s.City)
.Select(g => new
{
City = g.Key,
Count = g.Count(),
Avg = g.Average(s => s.Score)
})
.OrderByDescending(x => x.Avg);
foreach (var item in ordered)
Console.WriteLine($"{item.City}: {item.Count}人, 均分{item.Avg:F1}");
输出:
北京: 3人, 均分87.7
上海: 2人, 均分86.5
深圳: 1人, 均分72.0
广州: 2人, 均分51.5
八、查询语法中的分组(group by)
8.1 基本分组
// 查询语法
var byCity = from s in students
group s by s.City;
// 等价方法语法
var byCity = students.GroupBy(s => s.City);
8.2 group by ... into —— 对分组结果继续处理
这是查询语法的大杀器——分组后继续筛选、排序、投影:
// 分组后,只要人数 ≥ 2 的组,按平均分降序
var result = from s in students
group s by s.City into g // 分组,结果存在 g 里
where g.Count() >= 2 // 只要 2 人及以上的城市
orderby g.Average(s => s.Score) descending // 按均分降序
select new
{
City = g.Key,
Count = g.Count(),
Avg = g.Average(s => s.Score),
Max = g.Max(s => s.Score)
};
foreach (var item in result)
Console.WriteLine($"{item.City}: {item.Count}人, 均分{item.Avg:F1}, 最高{item.Max}");
输出:
北京: 3人, 均分87.7, 最高95
上海: 2人, 均分86.5, 最高88
广州: 2人, 均分51.5, 最高58
深圳只有 1 人,被
where g.Count() >= 2筛掉了。
8.3 group by + into + let 组合
// 分组 → 计算及格率 → 只保留及格率 > 50% 的科目
var result = from s in students
group s by s.Subject into g
let passRate = (double)g.Count(s => s.Score >= 60) / g.Count()
where passRate > 0.5
orderby passRate descending
select new
{
Subject = g.Key,
Count = g.Count(),
PassRate = passRate.ToString("P0"),
Avg = g.Average(s => s.Score).ToString("F1")
};
foreach (var item in result)
Console.WriteLine($"{item.Subject}: {item.Count}人, 均分{item.Avg}, 及格率{item.PassRate}");
输出:
语文: 2人, 均分82.0, 及格率100%
数学: 4人, 均分76.8, 及格率75%
英语及格率 50%,被筛掉了。
8.4 查询语法 vs 方法语法——分组对比
// 同一需求的两种写法
// 查询语法:group by ... into 优雅
var r1 = from s in students
group s by s.City into g
where g.Count() >= 2
select new { City = g.Key, Avg = g.Average(s => s.Score) };
// 方法语法:GroupBy + Select + Where
var r2 = students
.GroupBy(s => s.City)
.Select(g => new { City = g.Key, Avg = g.Average(s => s.Score) })
.Where(x => x.Avg >= 70);
第九、ToLookup —— 立即分组
GroupBy 是延迟执行,ToLookup 是立即执行。
// ToLookup 立即分组,返回 ILookup<TKey, TElement>
ILookup<string, Student> lookup = students.ToLookup(s => s.City);
// 用起来像字典
foreach (var s in lookup["北京"])
Console.WriteLine(s.Name); // 张三, 王五, 周八
// 查不存在的 key 不报错,返回空序列
var empty = lookup["成都"];
Console.WriteLine(empty.Count()); // 0
// 对比 Dictionary:
Dictionary<string, List<Student>> dict;
// dict["成都"] → KeyNotFoundException!
GroupBy vs ToLookup:
| 特性 | GroupBy | ToLookup |
|---|---|---|
| 执行时机 | 延迟执行 | 立即执行 |
| 返回类型 | IEnumerable<IGrouping<K,T>> |
ILookup<K,T> |
| 多次遍历 | 每次都重新分组 | 结果已固化 |
| 不存在的 key | 不支持按 key 访问 | 返回空序列,不报错 |
| 适用场景 | 一次遍历 | 需要反复按 key 查询 |
十、联接 + 分组综合实战
10.1 完整示例:按省份统计学生成绩
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
List<Student> students = /* ... 测试数据 ... */;
List<CityInfo> cities = /* ... 测试数据 ... */;
Console.WriteLine("========== 按省份统计学生成绩 ==========\n");
// 第1步:联接学生表和城市表
// 第2步:按省份分组
// 第3步:统计每组数据
var byProvince = from s in students
join c in cities on s.City equals c.City
group new { s, c } by c.Province into g
orderby g.Average(x => x.s.Score) descending
select new
{
Province = g.Key,
Cities = g.Select(x => x.c.City).Distinct().Count(),
StudentCount = g.Count(),
AvgScore = g.Average(x => x.s.Score),
MaxScore = g.Max(x => x.s.Score),
MinScore = g.Min(x => x.s.Score),
PassRate = (double)g.Count(x => x.s.Score >= 60) / g.Count(),
TopStudent = g.OrderByDescending(x => x.s.Score).First().s.Name
};
Console.WriteLine("{0,-6} {1,-6} {2,-6} {3,-8} {4,-6} {5,-6} {6,-8} {7}",
"省份", "城市数", "人数", "均分", "最高", "最低", "及格率", "最佳学生");
Console.WriteLine(new string('-', 65));
foreach (var item in byProvince)
{
Console.WriteLine("{0,-6} {1,-6} {2,-6} {3,-8:F1} {4,-6} {5,-6} {6,-8:P0} {7}",
item.Province, item.Cities, item.StudentCount,
item.AvgScore, item.MaxScore, item.MinScore,
item.PassRate, item.TopStudent);
}
}
}
输出:
========== 按省份统计学生成绩 ==========
省份 城市数 人数 均分 最高 最低 及格率 最佳学生
-----------------------------------------------------------------
上海 1 2 86.5 88 85 100% 孙七
北京 1 3 87.7 95 76 100% 周八
广东 2 3 58.3 72 45 33% 郑十
10.2 完整示例:多表联接 + 分组报表
// 三表联接 + 分组 + 排序
var fullReport = from s in students
join c in cities on s.City equals c.City
group new { s, c } by s.Subject into g
let passCount = g.Count(x => x.s.Score >= 60)
orderby g.Average(x => x.s.Score) descending
select new
{
科目 = g.Key,
人数 = g.Count(),
均分 = g.Average(x => x.s.Score),
最高 = g.Max(x => x.s.Score),
最低 = g.Min(x => x.s.Score),
及格人数 = passCount,
及格率 = $"{(double)passCount / g.Count():P0}",
城市分布 = string.Join(", ", g.Select(x => x.s.City).Distinct()),
省份 = string.Join(", ", g.Select(x => x.c.Province).Distinct())
};
Console.WriteLine("========== 科目综合报表 ==========\n");
Console.WriteLine("{0,-6} {1,-6} {2,-8} {3,-6} {4,-6} {5,-8} {6,-8} {7}",
"科目", "人数", "均分", "最高", "最低", "及格人数", "及格率", "涉及省份");
Console.WriteLine(new string('-', 80));
foreach (var item in fullReport)
{
Console.WriteLine("{0,-6} {1,-6} {2,-8:F1} {3,-6} {4,-6} {5,-8} {6,-8} {7}",
item.科目, item.人数, item.均分, item.最高, item.最低,
item.及格人数, item.及格率, item.省份);
}
输出:
========== 科目综合报表 ==========
科目 人数 均分 最高 最低 及格人数 及格率 涉及省份
-------------------------------------------------------------------------------
语文 2 82.0 88 76 2 100% 北京, 上海
数学 4 76.8 92 58 3 75% 北京, 上海, 广东
英语 2 70.0 95 45 1 50% 北京, 广东
十一、常见易错点(避坑指南)
坑1:Join 中 equals 不是 ==
// ❌ 错误语法
// from s in students
// join c in cities on s.City == c.City // 编译错误!
// ✅ 正确语法
from s in students
join c in cities on s.City equals c.City
坑2:Join 后需要判断匹配不到的情况
// Join(内连接)只返回匹配的,不需要判空
var joined = from s in students
join c in cities on s.City equals c.City
select new { s.Name, c.Province }; // ✅ 省一定有值
// GroupJoin 要小心空组
var groupJoined = from c in cities
join s in students on c.City equals s.City into g
select new
{
c.City,
// AvgScore = g.Average(s => s.Score) // ❌ 空组会抛异常!
AvgScore = g.Any() ? g.Average(s => s.Score) : (double?)null // ✅
};
坑3:GroupBy 后 Aggregate 对空组的影响
// GroupBy 产生的组至少有一个元素,所以 Average 通常安全
// 但如果用 DefaultIfEmpty 就可能产生空组
var dangerous = students
.Where(s => s.Score > 100) // 没有 >100 的
.GroupBy(s => s.City) // 分组也没内容
.Select(g => g.Average(s => s.Score)); // ❌ 可能抛异常
坑4:IGrouping 没有索引器
var byCity = students.GroupBy(s => s.City);
foreach (var group in byCity)
{
var first = group.First(); // ✅ 可以
// var bad = group[0]; // ❌ IGrouping 没有索引器!
var second = group.ElementAt(1); // ✅ 用 ElementAt
}
坑5:复合键分组后 Key 是匿名类型
var groups = students.GroupBy(s => new { s.City, s.Subject });
foreach (var g in groups)
{
// g.Key 是匿名类型,里面有两个属性
Console.WriteLine($"{g.Key.City} - {g.Key.Subject}"); // ✅
}
坑6:分组联接中的 into 不能和 select 互换位置
// ✅ 正确:join ... into 之后 select
from c in cities
join s in students on c.City equals s.City into g
select new { c.City, Count = g.Count() }
// ❌ 错误:into 后面不能直接接其他 join
// from c in cities
// join s in students on c.City equals s.City into g
// join x in table3 on c.X equals x.X // 这样不行
十二、总结
联接速查
| 操作 | 语法(查询) | 语法(方法) | 说明 |
|---|---|---|---|
| 内连接 | join c in cities on s.City equals c.City |
students.Join(cities, ...) |
只要匹配的 |
| 分组联接 | join s in students on c.City equals s.City into g |
cities.GroupJoin(students, ...) |
左表全保留 |
| 多表连接 | 多个 join ... on ... equals |
多次 .Join() |
三表以上 |
分组速查
| 操作 | 语法(查询) | 语法(方法) | 说明 |
|---|---|---|---|
| 基本分组 | group s by s.City |
.GroupBy(s => s.City) |
按单字段分 |
| 复合键分组 | group s by new { s.City, s.Subject } |
.GroupBy(s => new { ... }) |
多条件分 |
| 自定义分组 | group s by (s.Score >= 60 ? "及格" : "不及格") |
.GroupBy(s => 计算式) |
按计算值分 |
| 分组后继续 | group s by s.City into g where g.Count() >= 2 |
.GroupBy().Select().Where() |
分组再处理 |
| 立即分组 | (无查询语法直接支持) | .ToLookup(s => s.City) |
立即执行 |
记忆口诀
Join 联表配对找,内连只留匹配号
GroupJoin 左全保,空组也要列出来
GroupBy 分组归归类,相同键值聚一堆
into 后面继续筛,查询语法最爽快
复合键组用匿名,分组投影配合用
内连外连分清楚,数据报表一网捞
一句话总结:
Join是按关联键把两张表拼在一起(内连接,只留匹配的);GroupJoin是以左表为主,右表数据装进组里(像 LEFT JOIN,空组也保留);GroupBy是把一张表按某个键拆成一堆一堆的,然后对每组做聚合统计。Join+GroupBy组合是数据报表的黄金搭档。