首页 / C# 入门教程 / LINQ to Objects 进阶

C# 入门教程

LINQ to Objects 进阶

本教程共 100 篇 · 第 72 篇 · 更新于 2026-07-31 · 约 12 分钟阅读

C#C# 入门教程LINQJoinZip集合

72. LINQ to Objects 进阶

本节目标:学完你能用 Join 关联两个集合、用 GroupJoin 做一对多分组、用 Zip 配对两个序列,并用 SelectMany 把嵌套集合摊平。

前一章是”一条序列自己算”。这章讲”多条序列之间怎么玩”——关联、配对、摊平。这些算子在内存里的集合上就叫 LINQ to Objects,是处理结构化数据的基本功。

为什么需要”多序列之间”的操作

现实数据很少待在一个扁平列表里。订单和商品是分开的两张表,城市和居民是分开的两个集合。你需要一种方式把”相关的几份数据”拼到一处。LINQ to Objects 提供的 Join、GroupJoin、Zip、SelectMany 就是干这个的——它们让内存里的集合也能像数据库表一样关联。

打个比方:Join 像把两叠名片按”同一家公司”对齐配对;SelectMany 像把每个文件夹里的文件全部倒进一个大筐;Zip 像拉链,把两条链子一一咬合。理解这几个比喻,本掌的方法就不抽象了。

Join:按键关联两个集合

Join 类似 SQL 的 inner join:给左右两个集合各一个键,把键相等的元素配成一对。语法是 Join(右集合, 左键, 右键, 结果选择器)

namespace CSharpDemo;

record City(int Id, string Name);
record Person(int Id, string Name, int CityId);

List<City> cities =
[
    new City(1, "北京"),
    new City(2, "上海"),
];
List<Person> people =
[
    new Person(1, "小明", 1),
    new Person(2, "小红", 2),
    new Person(3, "小刚", 1),
];

var query = people.Join(
    cities,
    p => p.CityId,        // 左键:人的城市编号
    c => c.Id,            // 右键:城市的编号
    (p, c) => $"{p.Name} 来自 {c.Name}");  // 结果

foreach (var line in query)
    Console.WriteLine(line);
Note

Join 只保留”两边都匹配上”的组合。如果某人 CityId 在 cities 里找不到,他就不会出现在结果里——这就是 inner join 的特性。

Tip

Join 的键比较用的是默认的相等比较器。如果键是你自定义的类型,记得让它正确实现 EqualsGetHashCode,否则”相等的键”可能认不出来。用 record 定义类型时这点天生就满足。

GroupJoin:一对多的分组关联

有时候左边一个元素要带右边”一整组”匹配项,比如一个城市带它的所有居民。用 GroupJoin,结果选择器拿到的是”该城市对应的所有人”的集合。

namespace CSharpDemo;

record City(int Id, string Name);
record Person(int Id, string Name, int CityId);

List<City> cities =
[
    new City(1, "北京"),
    new City(2, "上海"),
];
List<Person> people =
[
    new Person(1, "小明", 1),
    new Person(2, "小红", 2),
    new Person(3, "小刚", 1),
];

var query = cities.GroupJoin(
    people,
    c => c.Id,
    p => p.CityId,
    (c, group) => new
    {
        City = c.Name,
        Residents = group.Select(p => p.Name).ToList()
    });

foreach (var item in query)
    Console.WriteLine($"{item.City}{string.Join("、", item.Residents)}");
Tip

Join 和 GroupJoin 的区别只在结果选择器:Join 拿到”一个匹配对象”,GroupJoin 拿到”一组匹配对象”。把 GroupJoin 再 SelectMany 一下,结果就等价于 Join。

Zip:把两个序列按位置配对(.NET LINQ 方法)

Zip 把两个序列按下标一一配对,合成一个元组序列。它是 .NET 的 LINQ 方法(自 .NET Framework 3.5 起就存在),并不是 C# 语言版本特性,用起来很直观。

namespace CSharpDemo;

string[] names = ["小明", "小红", "小刚"];
int[] scores = [95, 82, 90];

// Zip 是 .NET 的 LINQ 方法(结果选择器重载自 .NET 3.5 起),这里用 lambda 组合结果
var pairs = names.Zip(scores, (name, score) => $"{name} 考了 {score} 分");
foreach (var line in pairs)
    Console.WriteLine(line);
Note

Zip 以较短的序列为准,多出来的元素会被丢弃。.NET 6 起还支持三个序列的 Zip,可并行配对三组值。

Warning

Zip 是按”位置(下标)“配对,不是按键。它不关心两边值有没有逻辑关联——第 i 个和第 i 个强行成对。如果你要的是”键相等才配对”,请用 Join 而不是 Zip,否则会出现张冠李戴的静默错误。

SelectMany:把嵌套集合摊平

Select 是每个元素投影成一个新值;SelectMany 是每个元素投影成”一个序列”,再把所有这些序列摊平(flatten)成一个长序列。典型场景:每个订单有多件商品,想拿到全部商品。

namespace CSharpDemo;

record Order(int Id, List<Product> Items);
record Product(int Id, string Name);

List<Order> orders =
[
    new Order(1, [new Product(1, "笔记本"), new Product(2, "键盘")]),
    new Order(2, [new Product(3, "鼠标")]),
];

// 摊平:拿到所有订单里的所有商品
IEnumerable<Product> allProducts = orders.SelectMany(o => o.Items);
foreach (var p in allProducts)
    Console.WriteLine(p.Name);
Tip

新手容易把 SelectMany 和 Select 搞混。记住:Select 是”一对一”映射,SelectMany 是”一对多再压平”。想要一层扁平列表就用它。

链式组合:把算子串成一条流水线

真实需求往往是复合的。下面把 Where、OrderBy、Select、Take 串起来:取分数最高的前三名。

namespace CSharpDemo;

record Student(int Id, string Name, int Score);

List<Student> students =
[
    new Student(1, "小红", 95),
    new Student(2, "小刚", 82),
    new Student(3, "小美", 58),
    new Student(4, "小强", 99),
];

var top3 = students
    .Where(s => s.Score >= 60)
    .OrderByDescending(s => s.Score)
    .Take(3)
    .Select(s => $"{s.Name}:{s.Score}");

foreach (var line in top3)
    Console.WriteLine(line);
Note

链式写法的可读性很高,但 LINQ 是延迟执行的:上面这段代码直到 foreach 遍历 top3 时才真正跑。想提前算出来,可在末尾加 .ToList()

Join 还是 GroupJoin:怎么选

需求用哪个
左一个元素配右一个元素Join
左一个元素带右一整组GroupJoin
既要分组又想摊平GroupJoin 后再 SelectMany

其实 Join 可以看作 GroupJoin + SelectMany 的简写。理解这点,你就不会在两者间反复纠结了。

Zip 也能配三个序列(.NET 6 起)

.NET 6 起 Zip 支持三个序列,一次配出三组值,写法依旧直观:

namespace CSharpDemo;

string[] names = ["小明", "小红"];
int[] scores = [95, 82];
string[] levels = ["A", "B"];

var rows = names.Zip(scores, levels,
    (n, s, l) => $"{n}{s} 分({l} 级)");
foreach (var row in rows)
    Console.WriteLine(row);

SelectMany 与匿名类型

SelectMany 的”内层投影”可以是任意序列,包括匿名类型集合;摊平后照样能继续链式操作,比如接着算总额。

namespace CSharpDemo;

record Order(int Id, List<Product> Items);
record Product(int Id, string Name, decimal Price);

List<Order> orders =
[
    new Order(1, [new Product(1, "笔记本", 9.99m), new Product(2, "键盘", 199m)]),
    new Order(2, [new Product(3, "鼠标", 49m)]),
];

decimal total = orders
    .SelectMany(o => o.Items)
    .Sum(p => p.Price);
Console.WriteLine($"所有订单总额:{total}");

GroupBy 与 GroupJoin 别搞混

两者都带”分组”二字,但方向相反,容易看错:

  • GroupBy:把”一条序列”按某个键拆成多组(自己分自己)。
  • GroupJoin:把”左序列”每个元素,关联”右序列”里所有匹配项成组(跨两个序列)。

简记:GroupBy 是”自分组”,GroupJoin 是”跨表分组关联”。

延迟执行的双刃剑

LINQ to Objects 大多延迟执行(deferred execution):定义查询不跑,遍历才跑。这带来两个后果:

  1. 同一个查询变量多次遍历,会重新计算一遍——如果计算很贵,注意缓存。
  2. 查询定义后、遍历前,如果源集合被改了,遍历看到的是”改之后的样子”。
namespace CSharpDemo;

List<int> nums = [1, 2, 3];
var q = nums.Where(n => n > 1);
nums.Add(4);                 // 定义之后改源集合
foreach (var n in q)
    Console.WriteLine(n);    // 输出 2, 3, 4 —— 4 也被算进去了
Warning

如果希望查询”定格”在某个时刻的结果,记得在末尾加 .ToList() / .ToArray() 固化。否则后续对源集合的改动会”悄悄”反映到查询结果里,制造难以定位的 bug。

性能注意:别反复枚举

LINQ to Objects 每次遍历都会重新计算。若把同一个查询多次 foreach、或既 Count 又 Sum 同一个序列,等于把数据跑好几遍。确定要复用结果时,末尾加 .ToList() 固化一次。

Tip

对超大数据集,链式 LINQ 的”每次遍历都重算”会拖慢性能。这时 .ToList() 缓存结果,或考虑 PLINQ(第 85 章)。

与其它技术对比

  • 对比循环手写:Join 比手写双重 foreach 找匹配更短、意图更清,底层也做了哈希优化。
  • 对比数据库 LINQ(EF Core):本章是”to Objects”,数据已在内存;EF Core 的 LINQ 会翻译成 SQL 在数据库执行,语义相似但时机不同。
  • 对比查询语法:本章用的”方法语法”。第 67 章的 from ... in ... 查询语法也能表达 Join,但方法语法在链式组合时更灵活。

常见疑问解答

问:Join 能不能按”多个键”关联? 能。把键做成一个匿名类型或 record,比如 p => new { p.CityId, p.Type },左右键结构一致即可比较。record 与匿名类型的相等比较是现成支持的。

问:SelectMany 一定要摊平成相同类型吗? 不一定。内层序列和最终结果类型可以不同,只要投影返回的类型一致即可。它真正的规则是:每个元素投影成一个 IEnumerable<T>,再把所有 IEnumerable<T> 拼成一条 IEnumerable<T>

问:Zip 两个序列长度不等会报错吗? 不会,按较短的截断,多余部分直接丢弃,不会抛异常。所以如果两个序列你期望等长,最好自己先校验长度,别依赖它报错来发现问题。

最佳实践速记

  • 按键关联用 Join / GroupJoin,按下标配对用 Zip,别混用。
  • GroupJoin 拿到一组,Join 拿到一个,按需选。
  • 嵌套集合摊平首选 SelectMany。
  • 查询要复用或”定格”就 .ToList() 固化,避免重复枚举与延迟副作用。
  • 大数据集警惕反复遍历,必要时缓存或上 PLINQ。

小结

Join 做相等键的内连接,GroupJoin 做一对多分组关联,Zip 按下标配对两个序列(.NET BCL 方法,非 C# 语言特性),SelectMany 把嵌套集合摊平。它们都能和 Where、OrderBy、Select 自由串成一条链。下一章我们跳出 LINQ,进入程序的”安全带”——异常处理。