Skip to content
Go back

C# LINQ 完整指南:从基础操作到 .NET 6-10 新增 API

LINQ(Language Integrated Query)是 C# 3.0 引入的特性,也是 .NET 开发者最常用的工具之一。它让你能用声明式、可组合的 API 查询、转换和聚合数据集合,无论数据来自内存列表、数据库还是自定义数据源。相比嵌套循环和索引变量,LINQ 写出来的代码更清楚地表达”要什么”,而非”怎么做”。

本文覆盖所有主要操作符分类,并对 .NET 6 到 .NET 10 的新增 API 逐一比较,每节都附可直接使用的代码示例。

LINQ 是什么

LINQ 是定义在 IEnumerable<T>IQueryable<T> 上的一组扩展方法。IEnumerable<T> 路径在内存中操作(LINQ to Objects),IQueryable<T> 则构建表达式树,由 provider 在运行时翻译为 SQL 或其他查询语言(Entity Framework Core 是最典型的例子)。

LINQ 有两种语法形式:

// 查询语法 -- 类 SQL 关键字
var cheapItems =
    from p in products
    where p.Price < 50
    orderby p.Name
    select p.Name;

// 方法语法 -- 现代 C# 首选
var cheapItems =
    products
        .Where(p => p.Price < 50)
        .OrderBy(p => p.Name)
        .Select(p => p.Name);

两种形式编译结果相同。方法语法更受欢迎:支持全部操作符(部分操作符没有查询语法等价形式),可组合性更强,IDE 补全体验也更好。

过滤:Where、Any、All、Contains、OfType

过滤是最常见的 LINQ 操作。Where 接受谓词返回所有满足条件的元素;AnyAll 回答存在性和全称性问题,找到答案就短路:

public sealed record Order(int Id, string CustomerId, decimal Total, bool IsShipped);

// Where -- 基本和复合谓词
IEnumerable<Order> largeUnshipped =
    orders.Where(o => !o.IsShipped && o.Total > 500m);

// Any -- 找到第一个匹配就停止
bool hasPending = orders.Any(o => !o.IsShipped);

// All -- 遇到第一个不满足就停止;空集合返回 true
bool allShipped = orders.Any() && orders.All(o => o.IsShipped);

// OfType -- 按运行时类型过滤异质集合
IEnumerable<PremiumOrder> premiumOnly =
    mixedOrders.OfType<PremiumOrder>();

始终用 Any() 替代 Count() > 0——Any() 在第一个元素处短路,Count() 要遍历整个集合。

投影:Select 和 SelectMany

Select 把每个元素变换为新类型;SelectMany 把嵌套序列展平为单一流:

public sealed record Category(string Name, IReadOnlyList<Product> Products);
public sealed record Product(string Sku, string Name, decimal Price);

// Select -- 投影到新类型
IEnumerable<string> skus = products.Select(p => p.Sku);

// Select 带索引(.NET 6+)
IEnumerable<(int Index, string Sku)> indexed =
    products.Select((p, i) => (i, p.Sku));

// .NET 9 -- Index() 取代 Select((item, i) => ...) 写法
IEnumerable<(int Index, Product Item)> withIndex =
    products.Index();

// SelectMany -- 展平所有分类下的商品
IEnumerable<Product> allProducts =
    categories.SelectMany(c => c.Products);

// SelectMany 带结果选择器 -- 保留父级上下文
IEnumerable<(string CategoryName, string Sku)> flat =
    categories.SelectMany(
        c => c.Products,
        (c, p) => (c.Name, p.Sku));

排序:OrderBy、ThenBy、Order(.NET 7)

LINQ 排序是稳定排序——相等元素保持原始相对顺序:

public sealed record Product(string Sku, string Name, decimal Price, int Stock);

// 多键排序
IOrderedEnumerable<Product> sorted =
    products
        .OrderBy(p => p.Price)
        .ThenByDescending(p => p.Stock);

// .NET 7 之前 -- 对自然可比类型排序需要多写一个 key selector
IEnumerable<decimal> sortedPrices = prices.OrderBy(x => x);

// .NET 7 -- Order() 和 OrderDescending() 省去冗余的 key selector
IEnumerable<decimal> sortedPricesNew = prices.Order();
IEnumerable<string> sortedNamesDesc = names.OrderDescending();

Order()OrderDescending() 要求类型实现 IComparable<T>。对复杂类型仍需用 OrderBy(keySelector) 或自定义 IComparer<T>

分组:GroupBy、ToLookup、CountBy / AggregateBy(.NET 9)

public sealed record Sale(string Region, string ProductSku, decimal Amount);

// GroupBy -- 延迟执行,枚举时缓冲各组数据
IEnumerable<IGrouping<string, Sale>> byRegion =
    sales.GroupBy(s => s.Region);

foreach (var group in byRegion)
    Console.WriteLine($"{group.Key}: {group.Sum(s => s.Amount):C}");

// ToLookup -- 立即执行,支持按键随机访问
ILookup<string, Sale> lookup = sales.ToLookup(s => s.Region);
IEnumerable<Sale> westSales = lookup["West"];

// .NET 9 -- CountBy:按键计数,无需具体化分组
IEnumerable<KeyValuePair<string, int>> regionCounts =
    sales.CountBy(s => s.Region);

// .NET 9 -- AggregateBy:按键累积,无需 GroupBy + 聚合
IEnumerable<KeyValuePair<string, decimal>> regionTotals =
    sales.AggregateBy(
        keySelector: s => s.Region,
        seedSelector: _ => 0m,
        func: (acc, s) => acc + s.Amount);

CountByAggregateBy 不具体化分组子序列,对大数据集只需要汇总值时节省大量内存。

联接:Join、GroupJoin、LeftJoin / RightJoin(.NET 10)、Zip

public sealed record Customer(int Id, string Name);
public sealed record Order(int Id, int CustomerId, decimal Total);

// 内联接
var customerOrders =
    customers.Join(
        orders,
        c => c.Id,
        o => o.CustomerId,
        (c, o) => new { c.Name, o.Total });

// GroupJoin -- 左联接 + 分组;所有客户都包含在内
var customersWithTotals =
    customers.GroupJoin(
        orders,
        c => c.Id,
        o => o.CustomerId,
        (c, orderGroup) => new
        {
            c.Name,
            Total = orderGroup.Sum(o => o.Total)
        });

// .NET 10 之前 -- 左联接需要 GroupJoin + SelectMany + DefaultIfEmpty
var leftJoinOld =
    customers
        .GroupJoin(orders, c => c.Id, o => o.CustomerId,
            (c, og) => new { c, og })
        .SelectMany(
            x => x.og.DefaultIfEmpty(),
            (x, o) => new { x.c.Name, OrderTotal = o?.Total ?? 0 });

// .NET 10 -- LeftJoin 作为一级操作符
var leftJoined =
    customers.LeftJoin(
        orders,
        c => c.Id,
        o => o.CustomerId,
        (c, o) => new { c.Name, OrderTotal = o?.Total ?? 0 });

// .NET 10 -- RightJoin
var rightJoined =
    orders.RightJoin(
        customers,
        o => o.CustomerId,
        c => c.Id,
        (o, c) => new { c.Name, OrderTotal = o?.Total ?? 0 });

LeftJoinRightJoin 是开发者手写了十几年的 GroupJoin + SelectMany + DefaultIfEmpty 模式的直接替代品。

集合运算:Distinct、DistinctBy、Union、Intersect、Except

// Distinct -- 使用默认相等比较
IEnumerable<string> uniqueSkus = skus.Distinct();

// .NET 6 之前 -- DistinctBy 需要 GroupBy 变通
IEnumerable<Product> uniqueByNameOld =
    products
        .GroupBy(p => p.Name)
        .Select(g => g.First());

// .NET 6 -- DistinctBy:按投影键去重
IEnumerable<Product> uniqueByName =
    products.DistinctBy(p => p.Name);

// Union、Intersect、Except -- 序列上的集合运算
IEnumerable<string> allSkus    = catalogSkus.Union(warehouseSkus);
IEnumerable<string> inBoth     = catalogSkus.Intersect(warehouseSkus);
IEnumerable<string> orphans    = warehouseSkus.Except(catalogSkus);

// .NET 6 -- ExceptBy、IntersectBy、UnionBy:按键做集合运算
IEnumerable<Product> newArrivals =
    incomingProducts.ExceptBy(
        existingProducts.Select(p => p.Sku),
        p => p.Sku);

聚合:Count、Sum、Min、Max、Average、Aggregate、MinBy/MaxBy

// 基本聚合
int orderCount      = orders.Count();
decimal totalRevenue = orders.Sum(o => o.Total);
decimal avgOrder    = orders.Average(o => o.Total);

// .NET 6 之前 -- MinBy/MaxBy 需要手写 Aggregate
Order mostExpensiveOld = orders.Aggregate((a, b) => a.Total > b.Total ? a : b);

// .NET 6 -- MinBy 和 MaxBy:返回整个元素而非仅键值
Order mostExpensive = orders.MaxBy(o => o.Total)!;
Order cheapest      = orders.MinBy(o => o.Total)!;

// Aggregate -- 通用折叠/归约
string skuList = products
    .Select(p => p.Sku)
    .Aggregate((a, b) => $"{a}, {b}");

// .NET 6 -- TryGetNonEnumeratedCount:快速计数无需遍历
if (orders.TryGetNonEnumeratedCount(out int count))
    Console.WriteLine($"Fast count: {count} orders");

MinByMaxBy 在需要最小/最大值对应的整个对象时特别有用,不再只能拿到键值本身。

元素访问:First、Last、Single、ElementAt、Chunk

// First/Last -- 空集合时抛异常;用 *OrDefault 变体安全访问
Order latestOrder  = orders.OrderByDescending(o => o.PlacedAt).First();
Order? maybeFirst  = orders.FirstOrDefault(o => o.CustomerId == "C001");

// Single -- 期望恰好一个元素(0 或 2+ 个时抛异常)
Order exactOrder = orders.Single(o => o.Id == 42);

// ElementAt -- 按索引随机访问
Order thirdOrder = orders.ElementAt(2);

// .NET 6 之前 -- 分批需要手写 Skip/Take 循环
var batches = new List<Order[]>();
for (int i = 0; i < orders.Count; i += 100)
    batches.Add(orders.Skip(i).Take(100).ToArray());

// .NET 6 -- Chunk:把序列切成固定大小的批次
foreach (Order[] batch in orders.Chunk(100))
    await ProcessBatchAsync(batch);

Chunk 是 .NET 6 最立竿见影的新 API 之一:批量发送邮件、批量数据库写入、限流 API 调用、并行处理都能直接用。

延迟执行与何时具体化

IEnumerable<T> 上的大多数 LINQ 操作符是惰性的——定义查询时不做任何工作,迭代时才执行:

// 这一行什么都不做
IEnumerable<Product> query = products.Where(p => p.Price > 100);

// 过滤在这里发生 -- 每次迭代都会跑一遍
foreach (var p in query) Console.WriteLine(p.Name);

// 再次迭代时,过滤再跑一遍
int count = query.Count(); // 第二次完整遍历

// 需要稳定快照或多次迭代时,立即具体化
List<Product> snapshot = products.Where(p => p.Price > 100).ToList();

以下情况应调用 ToList()ToArray()

.NET 6 到 .NET 10 新增 API 一览

API版本说明
DistinctBy(keySelector).NET 6按投影键去重
MinBy(keySelector).NET 6返回最小投影键对应的完整元素
MaxBy(keySelector).NET 6返回最大投影键对应的完整元素
ExceptBy / IntersectBy / UnionBy.NET 6按键选择器做集合运算
Chunk(size).NET 6切分为固定大小批次
TryGetNonEnumeratedCount.NET 6快速计数无需枚举
Order().NET 7对自然可比类型排序(无需键选择器)
OrderDescending().NET 7降序排序(无需键选择器)
CountBy(keySelector).NET 9按键计数,不具体化分组
AggregateBy(key, seed, func).NET 9按键累积,不需要 GroupBy
Index().NET 9枚举时携带索引(取代 Select((x, i) => ...) 写法)
LeftJoin(...).NET 10无需 GroupJoin 样板的左外联接
RightJoin(...).NET 10无需 GroupJoin 样板的右外联接

常见问题

LINQ 支持数据库查询吗?
支持。当数据源实现 IQueryable<T>(如 Entity Framework Core 提供的),LINQ 表达式树会在执行时翻译为 SQL。但部分操作符无法翻译,会在运行时抛异常或强制客户端求值。开发阶段务必检查生成的 SQL。

LINQ 是线程安全的吗?
在独立序列上并发运行 LINQ to Objects 操作是安全的,但 LINQ 不会让底层集合变成线程安全的。如果一个线程在修改 List<T> 的同时另一个线程在用 LINQ 迭代它,行为是未定义的。

GroupByCountBy(.NET 9)有什么性能差别?
GroupBy 为每个分组分配一个 IGrouping<K, T> 对象,并把所有匹配元素缓冲到内存中。CountBy 只跟踪每个键的计数,从不具体化分组元素。对大型集合只需要频率统计时,CountBy 显著比 GroupBy(...).Select(g => (g.Key, g.Count())) 节省内存。

参考


Tags


Previous

Microsoft Agent Framework 持久化工作流详解:从控制台到 Azure Functions

Next

C# Regex.Replace 与 Regex.Split 全解析:MatchEvaluator、EnumerateSplits 与替换语法