编写 C# LINQ 查询以查询数据

介绍性的语言集成查询 (LINQ) 文档中的大多数查询是使用 LINQ 声明性查询语法编写的。 C# 编译器将查询语法转换为方法调用。 这些方法调用实现标准查询运算符,并具有 WhereSelectGroupByJoinMaxAverage等名称。 可以使用方法语法(而不查询语法)来直接调用它们。

查询语法和方法语法在语义上是相同的,但是查询语法通常更简单且更易于阅读。 某些查询必须表示为方法调用。 例如,必须使用方法调用表示检索与指定条件匹配的元素数的查询。 还必须对检索源序列中具有最大值的元素的查询使用方法调用。 System.Linq 命名空间中的标准查询运算符的参考文档通常使用方法语法。 你应该熟悉如何在查询和查询表达式本身中使用方法语法。

标准查询运算符扩展方法

下面的示例演示一个简单查询表达式以及编写为基于方法的查询的语义上等效的查询。

int[] numbers = [ 5, 10, 8, 3, 6, 12 ];

//Query syntax:
IEnumerable<int> numQuery1 =
    from num in numbers
    where num % 2 == 0
    orderby num
    select num;

//Method syntax:
IEnumerable<int> numQuery2 = numbers
    .Where(num => num % 2 == 0)
    .OrderBy(n => n);

foreach (int i in numQuery1)
{
    Console.Write(i + " ");
}
Console.WriteLine(System.Environment.NewLine);
foreach (int i in numQuery2)
{
    Console.Write(i + " ");
}

这两个示例的输出是相同的。 查询变量的类型在两种形式中相同:IEnumerable<T>

在表达式右侧,请注意,where 子句现在表示为 numbers 对象上的实例方法,它具有类型 IEnumerable<int>。 如果熟悉泛型 IEnumerable<T> 接口,则会知道它没有 Where 方法。 但是,如果在 Visual Studio IDE 中调用 IntelliSense 完成列表,则不仅会看到 Where 方法,还会看到许多其他方法(如 SelectSelectManyJoinOrderby)。 这些方法实现标准查询运算符。

显示 Intellisense 中的所有标准查询运算符的屏幕截图。

虽然看起来好像 IEnumerable<T> 包括其他方法,但它没有。 标准查询运算符作为扩展方法来实现。 扩展方法可“扩展”现有类型;它们可以如同类型上的实例方法一样进行调用。 标准查询运算符扩展了 IEnumerable<T>,因此可以写入 numbers.Where(...)。 在调用扩展之前,使用 using 指令将扩展引入范围。

有关扩展方法的详细信息,请参阅扩展方法。 有关标准查询运算符的详细信息,请参阅标准查询运算符概述 (C#)。 某些 LINQ 提供程序(如 实体框架和 LINQ to XML),会实现自己的标准查询运算符,并为 IEnumerable<T> 之外的其他类型实现扩展方法。

Lambda 表达式

在前面的示例中,条件表达式(num % 2 == 0)作为内联参数传递给 Enumerable.Where 方法:Where(num => num % 2 == 0). 此内联表达式是 lambda 表达式。 编写代码是一种方便的方法,否则必须以更繁琐的形式编写代码。 运算符左侧的 num 是输入变量,它与查询表达式中的 num 对应。 编译器可以推断出 num 的类型,因为它知道 numbers 是泛型 IEnumerable<T> 类型。 Lambda 的主体与查询语法中或任何其他 C# 表达式或语句中的表达式完全相同。 它可以包含方法调用和其他复杂逻辑。 返回值为表达式结果。 某些查询只能用方法语法表示,其中一些查询需要 lambda 表达式。 Lambda 表达式是 LINQ 工具箱中的一个强大且灵活的工具。

查询的可组合性

在前面的代码示例中,Enumerable.OrderBy 方法是在调用 Where时使用点运算符调用的。 Where 生成筛选序列,然后 OrderbyWhere 所生成的序列进行排序。 由于查询返回 IEnumerable,因此可通过将方法调用链接在一起在方法语法中撰写查询。 使用查询语法编写查询时,编译器会执行此组合。 因为查询变量不存储查询的结果,所以可以随时修改它或将它用作新查询的基础(即使在执行它之后)。

以下示例使用前面列出的每个方法演示了一些基本的 LINQ 查询。

注意

这些查询对内存中集合进行操作;但是,语法与 LINQ to Entities 和 LINQ to XML 中使用的语法相同。

示例 - 查询语法

使用查询语法编写大多数查询来创建查询表达式。 下面的示例演示三个查询表达式。 第一个查询表达式演示如何通过应用包含 where 子句的条件来筛选或限制结果。 它返回源序列中值大于 7 或小于 3 的所有元素。 第二个表达式演示如何对返回的结果进行排序。 第三个表达式演示如何根据某个键对结果进行分组。 此查询基于单词的第一个字母返回两个组。

List<int> numbers = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0 ];

// The query variables can also be implicitly typed by using var

// Query #1.
IEnumerable<int> filteringQuery =
    from num in numbers
    where num is < 3 or > 7
    select num;

// Query #2.
IEnumerable<int> orderingQuery =
    from num in numbers
    where num is < 3 or > 7
    orderby num ascending
    select num;

// Query #3.
string[] groupingQuery = ["carrots", "cabbage", "broccoli", "beans", "barley"];
IEnumerable<IGrouping<char, string>> queryFoodGroups =
    from item in groupingQuery
    group item by item[0];

查询的类型为 IEnumerable<T>。 可以使用 var 编写所有这些查询,如下面的示例所示:

var query = from num in numbers...

在前面的每个示例中,在 foreach 语句或其他语句中循环访问查询变量之前,查询不会实际执行。

示例 - 方法语法

某些查询操作必须表示为方法调用。 最常见的此类方法是可返回单一数值的方法,例如 SumMaxMinAverage 等。 这些方法必须始终在任何查询中最后调用,因为它们返回单个值,不能用作其他查询操作的源。 下面的示例演示查询表达式中的方法调用:

List<int> numbers1 = [ 5, 4, 1, 3, 9, 8, 6, 7, 2, 0 ];
List<int> numbers2 = [ 15, 14, 11, 13, 19, 18, 16, 17, 12, 10 ];

// Query #4.
double average = numbers1.Average();

// Query #5.
IEnumerable<int> concatenationQuery = numbers1.Concat(numbers2);

如果方法具有 System.ActionSystem.Func<TResult> 参数,则这些参数以 lambda 表达式的形式提供,如下面的示例所示:

// Query #6.
IEnumerable<int> largeNumbersQuery = numbers2.Where(c => c > 15);

在前面的查询中,只有查询 #4 立即执行,因为它返回单个值,而不是泛型 IEnumerable<T> 集合。 方法本身使用 foreach 或类似的代码来计算其值。

上面的每个查询可以通过 var 使用隐式类型化进行编写,如下面的示例所示:

// var is used for convenience in these queries
double average = numbers1.Average();
var concatenationQuery = numbers1.Concat(numbers2);
var largeNumbersQuery = numbers2.Where(c => c > 15);

示例 - 混合查询和方法语法

此示例演示如何对查询子句的结果使用方法语法。 只需将查询表达式括在括号中,然后应用点运算符并调用方法。 在下面的示例中,查询 #7 返回对值介于 3 与 7 之间的数字进行的计数。

// Query #7.

// Using a query expression with method syntax
var numCount1 = (
    from num in numbers1
    where num is > 3 and < 7
    select num
).Count();

// Better: Create a new variable to store
// the method call result
IEnumerable<int> numbersQuery =
    from num in numbers1
    where num is > 3 and < 7
    select num;

var numCount2 = numbersQuery.Count();

由于查询 #7 返回单个值而不是集合,因此查询立即执行。

前面的查询可以通过 var 使用隐式类型化进行编写,如下所示:

var numCount = (from num in numbers...

它可以采用方法语法进行编写,如下所示:

var numCount = numbers.Count(n => n is > 3 and < 7);

它可以使用显式类型化进行编写,如下所示:

int numCount = numbers.Count(n => n is > 3 and < 7);

在运行时动态指定谓词筛选器

在某些情况下,在运行时之前你不知道必须将多少个谓词应用于 where 子句中的源元素。 动态指定多个谓词筛选器的方法之一是使用 Contains 方法,如以下示例中所示。 查询将根据执行查询时的 id 值返回不同的结果。

int[] ids = [ 111, 114, 112 ];

var queryNames = from student in students
                 where ids.Contains(student.ID)
                 select new
                 {
                     student.LastName,
                     student.ID
                 };

foreach (var name in queryNames)
{
    Console.WriteLine($"{name.LastName}: {name.ID}");
}

/* Output:
    Garcia: 114
    O'Donnell: 112
    Omelchenko: 111
 */

// Change the ids.
ids = [ 122, 117, 120, 115 ];

// The query will now return different results
foreach (var name in queryNames)
{
    Console.WriteLine($"{name.LastName}: {name.ID}");
}

/* Output:
    Adams: 120
    Feng: 117
    Garcia: 115
    Tucker: 122
 */

注意

此示例使用以下数据源和数据:

record City(string Name, long Population);
record Country(string Name, double Area, long Population, List<City> Cities);
record Product(string Name, string Category);
static readonly City[] cities = [
    new City("Tokyo", 37_833_000),
    new City("Delhi", 30_290_000),
    new City("Shanghai", 27_110_000),
    new City("São Paulo", 22_043_000),
    new City("Mumbai", 20_412_000),
    new City("Beijing", 20_384_000),
    new City("Cairo", 18_772_000),
    new City("Dhaka", 17_598_000),
    new City("Osaka", 19_281_000),
    new City("New York-Newark", 18_604_000),
    new City("Karachi", 16_094_000),
    new City("Chongqing", 15_872_000),
    new City("Istanbul", 15_029_000),
    new City("Buenos Aires", 15_024_000),
    new City("Kolkata", 14_850_000),
    new City("Lagos", 14_368_000),
    new City("Kinshasa", 14_342_000),
    new City("Manila", 13_923_000),
    new City("Rio de Janeiro", 13_374_000),
    new City("Tianjin", 13_215_000)
];

static readonly Country[] countries = [
    new Country ("Vatican City", 0.44, 526, [new City("Vatican City", 826)]),
    new Country ("Monaco", 2.02, 38_000, [new City("Monte Carlo", 38_000)]),
    new Country ("Nauru", 21, 10_900, [new City("Yaren", 1_100)]),
    new Country ("Tuvalu", 26, 11_600, [new City("Funafuti", 6_200)]),
    new Country ("San Marino", 61, 33_900, [new City("San Marino", 4_500)]),
    new Country ("Liechtenstein", 160, 38_000, [new City("Vaduz", 5_200)]),
    new Country ("Marshall Islands", 181, 58_000, [new City("Majuro", 28_000)]),
    new Country ("Saint Kitts & Nevis", 261, 53_000, [new City("Basseterre", 13_000)])
];

可以使用控制流语句(如 if... elseswitch)在预确定的替代查询之间进行选择。 在下面的示例中,studentQuery 使用其他 where 子句,如果 oddYear 的运行时值为 truefalse

void FilterByYearType(bool oddYear)
{
    IEnumerable<Student> studentQuery = oddYear
        ? (from student in students
           where student.Year is GradeLevel.FirstYear or GradeLevel.ThirdYear
           select student)
        : (from student in students
           where student.Year is GradeLevel.SecondYear or GradeLevel.FourthYear
           select student);
    var descr = oddYear ? "odd" : "even";
    Console.WriteLine($"The following students are at an {descr} year level:");
    foreach (Student name in studentQuery)
    {
        Console.WriteLine($"{name.LastName}: {name.ID}");
    }
}

FilterByYearType(true);

/* Output:
    The following students are at an odd year level:
    Fakhouri: 116
    Feng: 117
    Garcia: 115
    Mortensen: 113
    Tucker: 119
    Tucker: 122
 */

FilterByYearType(false);

/* Output:
    The following students are at an even year level:
    Adams: 120
    Garcia: 114
    Garcia: 118
    O'Donnell: 112
    Omelchenko: 111
    Zabokritski: 121
 */

在查询表达式中处理 null 值

此示例显示如何在源集合中处理可能的 null 值。 IEnumerable<T> 等对象集合可包含值为 null 的元素。 如果源集合为 null 或包含值为 null 的元素,并且查询不处理 null 值,则在执行查询时将引发 NullReferenceException

以下示例使用这些类型和静态数据数组:

record Product(string Name, int CategoryID);
record Category(string Name, int ID);
static Category?[] categories =
[
    new ("brass", 1),
    null,
    new ("winds", 2),
    default,
    new ("percussion", 3)
];

static Product?[] products =
[
    new Product("Trumpet", 1),
    new Product("Trombone", 1),
    new Product("French Horn", 1),
    null,
    new Product("Clarinet", 2),
    new Product("Flute", 2),
    null,
    new Product("Cymbal", 3),
    new Product("Drum", 3)
];

可采用防御方式进行编码,以避免空引用异常,如以下示例所示:

var query1 = from c in categories
             where c != null
             join p in products on c.ID equals p?.CategoryID
             select new
             {
                 Category = c.Name,
                 Name = p.Name
             };

在前面的示例中,where 子句筛选出类别序列中的所有 null 元素。 此方法独立于 join 子句中的 null 检查。 在此示例中,带有 null 的条件表达式有效,因为 Products.CategoryID 的类型为 int?,这是 Nullable<int> 的速记形式。

在 join 子句中,如果只有一个比较键是可以为 null 的值类型,则可以在查询表达式中将另一个比较键转换为可以为 null 的值类型。 在以下示例中,假定 EmployeeID 是包含 int? 类型的值列:

var query =
    from o in db.Orders
    join e in db.Employees
        on o.EmployeeID equals (int?)e.EmployeeID
    select new { o.OrderID, e.FirstName };

每个示例中都使用 equals 查询关键字。 还可使用模式匹配,其中包括 is nullis not null 的模式。 不建议在 LINQ 查询中使用这些模式,因为查询提供程序可能无法正确解读新的 C# 语法。 查询提供程序是一个库,用于将 C# 查询表达式转换为本机数据格式,例如 Entity Framework Core。 查询提供程序实现 System.Linq.IQueryProvider 接口,以创建实现 System.Linq.IQueryable<T> 接口的数据源。

在查询表达式中处理异常

在查询表达式的上下文中可以调用任何方法。 请勿在查询表达式中调用任何会产生副作用(如修改数据源内容或引发异常)的方法。 此示例演示在查询表达式中调用方法时如何避免引发异常,而不违反有关异常处理的常规 .NET 指南。 这些指南阐明,当你理解在给定上下文中为何会引发异常时,捕获到该特定异常是可以接受的。 有关详细信息,请参阅异常的最佳做法

最后的示例演示了在执行查询期间必须引发异常时,该如何处理这种情况。

以下示例演示如何将异常处理代码移到查询表达式外。 只有当方法不取决于查询的任何本地变量时,才可以执行重构。 在查询表达式之外处理异常更容易。

// A data source that is very likely to throw an exception!
IEnumerable<int> GetData() => throw new InvalidOperationException();

// DO THIS with a datasource that might
// throw an exception.
IEnumerable<int>? dataSource = null;
try
{
    dataSource = GetData();
}
catch (InvalidOperationException)
{
    Console.WriteLine("Invalid operation");
}

if (dataSource is not null)
{
    // If we get here, it is safe to proceed.
    var query = from i in dataSource
                select i * i;

    foreach (var i in query)
    {
        Console.WriteLine(i.ToString());
    }
}

在上述示例的 catch (InvalidOperationException) 块中,请以适合你的应用程序的方式处理(或不处理)异常。

在某些情况下,针对由查询内部引发的异常的最佳措施可能是立即停止执行查询。 下面的示例演示如何处理可能在查询正文内部引发的异常。 假定 SomeMethodThatMightThrow 可能导致要求停止执行查询的异常。

try 块封装 foreach 循环,且不对自身进行查询。 foreach 循环是实际执行查询时的点。 执行查询时,会引发运行时异常。 因此,必须在 foreach 循环中处理这些异常。

// Not very useful as a general purpose method.
string SomeMethodThatMightThrow(string s) =>
    s[4] == 'C' ?
        throw new InvalidOperationException() :
        $"""C:\newFolder\{s}""";

// Data source.
string[] files = ["fileA.txt", "fileB.txt", "fileC.txt"];

// Demonstration query that throws.
var exceptionDemoQuery = from file in files
                         let n = SomeMethodThatMightThrow(file)
                         select n;

try
{
    foreach (var item in exceptionDemoQuery)
    {
        Console.WriteLine($"Processing {item}");
    }
}
catch (InvalidOperationException e)
{
    Console.WriteLine(e.Message);
}

/* Output:
    Processing C:\newFolder\fileA.txt
    Processing C:\newFolder\fileB.txt
    Operation is not valid due to the current state of the object.
 */

请记得捕获预期引发的任何异常,并且/或者在 finally 块中执行任何必要的清理。

另请参阅