LINQ:.NET Language-Integrated查询
唐·伯克,安德斯·赫尔斯伯格
2007 年 2 月
适用于:
Visual Studio Code名称“Orcas”
.Net Framework 3.5
总结:添加到 .NET Framework的常规用途查询工具适用于所有信息源,而不仅仅是关系或 XML 数据。 此功能称为 .NET Language-Integrated 查询 (LINQ) 。 ) (32 个打印页
目录
.NET Language-Integrated 查询
使用标准查询运算符入门
支持 LINQ 项目的语言功能
更多标准查询运算符
查询语法
LINQ to SQL:SQL 集成
LINQ to XML:XML 集成
总结
.NET Language-Integrated 查询
二十年后,该行业在面向对象的 (OO) 编程技术的发展中已达到稳定点。 程序员现在将类、对象和方法等功能视为理所当然。 在审视当前和下一代技术时,很明显,编程技术的下一个重大挑战是降低访问和集成未使用 OO 技术本身定义的信息的复杂性。 非 OO 信息的两个最常见来源是关系数据库和 XML。
使用 LINQ 项目,我们没有向编程语言和运行时添加关系或特定于 XML 的功能,而是采用更通用的方法,并将常规用途的查询工具添加到适用于所有信息源(而不仅仅是关系或 XML 数据)的.NET Framework。 此功能称为 .NET Language-Integrated 查询 (LINQ) 。
我们使用 术语语言集成查询 来指示查询是开发人员的主要编程语言的集成功能, (例如 Visual C#、Visual Basic) 。 通过语言集成的查询, 查询表达式 可以从丰富的元数据、编译时语法检查、静态键入和 IntelliSense 中获益,这些功能以前仅适用于命令性代码。 语言集成查询还允许将单个常规用途声明性查询工具应用于所有内存中信息,而不仅仅是来自外部源的信息。
.NET Language-Integrated 查询定义了一组通用 标准查询运算符 ,这些运算符允许在任何 中以直接但声明性的方式表示遍历、筛选和投影操作。基于 NET 的编程语言。 标准查询运算符允许将查询应用于任何基于 IEnumererable<T> 的信息源。 LINQ 允许第三方使用适用于目标域或技术的新特定于域的运算符来扩充标准查询运算符集。 更重要的是,第三方还可以自由地将标准查询运算符替换为其自己的实现,这些实现提供远程评估、查询转换、优化等附加服务。 通过遵循 LINQ 模式的约定,此类实现享有与标准查询运算符相同的语言集成和工具支持。
查询体系结构的扩展性在 LINQ 项目本身中用于提供同时处理 XML 和 SQL 数据的实现。 基于 XML 的查询运算符 (LINQ to XML) 使用高效、易于使用的内存中 XML 工具,以主机编程语言提供 XPath/XQuery 功能。 基于关系数据的查询运算符 (LINQ to SQL) 基于 SQL 的架构定义集成到公共语言运行时 (CLR) 类型系统的基础上。 此集成提供对关系数据的强类型化,同时保留关系模型的表达能力和直接在基础存储中查询评估的性能。
使用标准查询运算符入门
若要查看工作中的语言集成查询,我们将从一个简单的 C# 3.0 程序开始,该程序使用标准查询运算符来处理数组的内容:
using System;
using System.Linq;
using System.Collections.Generic;
class app {
static void Main() {
string[] names = { "Burke", "Connor", "Frank",
"Everett", "Albert", "George",
"Harris", "David" };
IEnumerable<string> query = from s in names
where s.Length == 5
orderby s
select s.ToUpper();
foreach (string item in query)
Console.WriteLine(item);
}
}
如果要编译并运行此程序,则会将此视为输出:
BURKE
DAVID
FRANK
To understand how language-integrated query works, we need to dissect the
first statement of our program.
IEnumerable<string> query = from s in names
where s.Length == 5
orderby s
select s.ToUpper();
局部变量 查询 使用 查询表达式进行初始化。 查询表达式通过应用标准查询运算符或特定于域的运算符中的一个或多个查询运算符,对一个或多个信息源进行操作。 此表达式使用三个标准查询运算符: Where、 OrderBy 和 Select。
Visual Basic 9.0 也支持 LINQ。 下面是在 Visual Basic 9.0 中编写的上述语句:
Dim query As IEnumerable(Of String) = From s in names _
Where s.Length = 5 _
Order By s _
Select s.ToUpper()
此处显示的 C# 和 Visual Basic 语句都使用查询表达式。 与 foreach 语句一样,查询表达式是可手动编写的代码的便捷声明性简写。 上述语句在语义上与 C# 中显示的以下显式语法相同:
IEnumerable<string> query = names
.Where(s => s.Length == 5)
.OrderBy(s => s)
.Select(s => s.ToUpper());
这种形式的查询称为 基于方法的 查询。 Where、OrderBy 和 Select 运算符的参数称为 lambda 表达式,它们是类似于委托的代码片段。 它们允许将标准查询运算符单独定义为方法,并使用点表示法串在一起。 这些方法共同构成了可扩展查询语言的基础。
支持 LINQ 项目的语言功能
LINQ 完全基于常规用途语言功能构建,其中一些功能是 C# 3.0 和 Visual Basic 9.0 的新增功能。 其中每个功能本身都有实用工具,但这些功能共同提供了一种可扩展的方法来定义查询和可查询 API。 在本部分中,我们将探讨这些语言功能,以及它们如何为更直接和声明性的查询样式做出贡献。
Lambda 表达式和表达式树
许多查询运算符允许用户提供执行筛选、投影或键提取的函数。 查询设施建立在 lambda 表达式的概念之上,它为开发人员提供了一种方便的方式来编写函数,这些函数可以作为参数传递以供后续评估。 Lambda 表达式类似于 CLR 委托,必须遵循委托类型定义的方法签名。 为了说明这一点,可以使用 Func 委托类型将上述语句扩展为等效但更明确的形式:
Func<string, bool> filter = s => s.Length == 5;
Func<string, string> extract = s => s;
Func<string, string> project = s => s.ToUpper();
IEnumerable<string> query = names.Where(filter)
.OrderBy(extract)
.Select(project);
Lambda 表达式是 C# 2.0 中匿名方法的自然演变。 例如,我们可以使用如下匿名方法编写前面的示例:
Func<string, bool> filter = delegate (string s) {
return s.Length == 5;
};
Func<string, string> extract = delegate (string s) {
return s;
};
Func<string, string> project = delegate (string s) {
return s.ToUpper();
};
IEnumerable<string> query = names.Where(filter)
.OrderBy(extract)
.Select(project);
通常,开发人员可以随意使用命名方法、匿名方法或带有查询运算符的 lambda 表达式。 Lambda 表达式的优点是提供用于创作的最直接、最精简的语法。 更重要的是,lambda 表达式可以编译为代码或数据,这允许优化器、翻译器和计算器在运行时处理 lambda 表达式。
命名空间 System.Linq.Expressions 定义一个可分辨泛型类型 Expression<T>,它指示给定 lambda 表达式需要 表达式树 ,而不是传统的基于 IL 的方法主体。 表达式树是 lambda 表达式的高效内存中数据表示形式,使表达式的结构透明和显式。
确定编译器是发出可执行 IL 还是表达式树,取决于 lambda 表达式的使用方式。 将 lambda 表达式分配给类型为委托的变量、字段或参数时,编译器会发出与匿名方法相同的 IL。 当 Lambda 表达式分配给某个委托类型 T 的类型为 Expression<T> 的变量、字段或参数时,编译器会改为发出表达式树。
例如,请考虑以下两个变量声明:
Func<int, bool> f = n => n < 5;
Expression<Func<int, bool>> e = n => n < 5;
变量 f 是直接可执行的委托的引用:
bool isSmall = f(2); // isSmall is now true
变量 e 是对不直接可执行的表达式树的引用:
bool isSmall = e(2); // compile error, expressions == data
与委托不同,委托实际上是不透明的代码,我们可以与表达式树进行交互,就像程序中的任何其他数据结构一样。
Expression<Func<int, bool>> filter = n => n < 5;
BinaryExpression body = (BinaryExpression)filter.Body;
ParameterExpression left = (ParameterExpression)body.Left;
ConstantExpression right = (ConstantExpression)body.Right;
Console.WriteLine("{0} {1} {2}",
left.Name, body.NodeType, right.Value);
上面的示例在运行时分解表达式树,并输出以下字符串:
n LessThan 5
这种在运行时将表达式视为数据的能力对于启用利用平台一部分的基本查询抽象的第三方库生态系统至关重要。 LINQ to SQL 数据访问实现利用此功能将表达式树转换为适合在存储中计算的 T-SQL 语句。
扩展方法
Lambda 表达式是查询体系结构的一个重要部分。 扩展方法是 另一种。 扩展方法将动态语言中常用的“鸭子键入”的灵活性与静态类型化语言的性能和编译时验证相结合。 使用扩展方法时,第三方可以使用新方法扩充类型的公共协定,同时仍允许单个类型作者提供自己对这些方法的专用实现。
扩展方法在静态类中定义为静态方法,但在 CLR 元数据中标有 [System.Runtime.CompilerServices.Extension] 属性。 建议语言为扩展方法提供直接语法。 在 C# 中,扩展方法由 此 修饰符指示,该修饰符必须应用于扩展方法的第一个参数。 让我们看一下最简单的查询运算符的定义, 其中:
namespace System.Linq {
using System;
using System.Collections.Generic;
public static class Enumerable {
public static IEnumerable<T> Where<T>(
this IEnumerable<T> source,
Func<T, bool> predicate) {
foreach (T item in source)
if (predicate(item))
yield return item;
}
}
}
扩展方法的第一个参数的类型指示扩展应用于的类型。 在上面的示例中, Where 扩展方法扩展类型 IEnumerable<T>。 由于 Where 是静态方法,因此我们可以像调用任何其他静态方法一样直接调用它:
IEnumerable<string> query = Enumerable.Where(names,
s => s.Length < 6);
但是,扩展方法的独特之处是,也可以使用实例语法调用它们:
IEnumerable<string> query = names.Where(s => s.Length < 6);
扩展方法在编译时根据哪些扩展方法在范围内进行解析。 使用 C# 中的 using 语句或 Visual Basic 中的 Import 语句导入命名空间时,该命名空间中的静态类定义的所有扩展方法都会进入作用域。
标准查询运算符定义为 类型 System.Linq.Enumerable 中的扩展方法。 检查标准查询运算符时,你会注意到,除了少数查询运算符之外,所有这些运算符都是根据 IEnumerable<T> 接口定义的。 这意味着每个 IEnumerable<T> 兼容信息源只需在 C# 中添加以下 using 语句即可获取标准查询运算符:
using System.Linq; // makes query operators visible
想要替换特定类型的标准查询运算符的用户可以:在具有兼容签名的特定类型上定义自己的同名方法,或定义扩展特定类型的新同名扩展方法。 想要完全避免使用标准查询运算符的用户根本无法将 System.Linq 置于范围中,而为 IEnumerable<T> 编写自己的扩展方法。
在分辨率方面,扩展方法的优先级最低,并且仅在目标类型及其基类型上没有合适的匹配项时使用。 这允许用户定义的类型提供其自己的查询运算符,这些运算符优先于标准运算符。 例如,请考虑以下自定义集合:
public class MySequence : IEnumerable<int> {
public IEnumerator<int> GetEnumerator() {
for (int i = 1; i <= 10; i++)
yield return i;
}
IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator();
}
public IEnumerable<int> Where(Func<int, bool> filter) {
for (int i = 1; i <= 10; i++)
if (filter(i))
yield return i;
}
}
给定此类定义后,以下程序将使用 MySequence.Where 实现,而不是扩展方法,因为实例方法优先于扩展方法:
MySequence s = new MySequence();
foreach (int item in s.Where(n => n > 3))
Console.WriteLine(item);
OfType 运算符是少数不扩展基于 IEnumerable<T> 的信息源的标准查询运算符之一。 让我们看一下 OfType 查询运算符:
public static IEnumerable<T> OfType<T>(this IEnumerable source) {
foreach (object item in source)
if (item is T)
yield return (T)item;
}
OfType 不仅接受基于 IEnumerable<T> 的源,还接受针对 .NET Framework版本 1.0 中存在的非参数化 IEnumerable 接口编写的源。 OfType 运算符允许用户将标准查询运算符应用于经典 .NET 集合,如下所示:
// "classic" cannot be used directly with query operators
IEnumerable classic = new OlderCollectionType();
// "modern" can be used directly with query operators
IEnumerable<object> modern = classic.OfType<object>();
在此示例中, 变量 modern
生成的值序列与 经典值相同。 但是,其类型与新式 IEnumerable<T> 代码兼容,包括标准查询运算符。
OfType 运算符对于较新的信息源也很有用,因为它允许基于类型筛选源中的值。 生成新序列时, OfType 仅省略与类型参数不兼容的原始序列的成员。 请考虑从异类数组中提取字符串的简单程序:
object[] vals = { 1, "Hello", true, "World", 9.1 };
IEnumerable<string> justStrings = vals.OfType<string>();
在 foreach 语句中枚举 justStrings 变量时,将得到两个字符串的序列:“Hello”和“World”。
延迟查询评估
观察读者可能已注意到标准 Where 运算符是使用 C# 2.0 中引入的 yield 构造实现的。 对于返回值序列的所有标准运算符,此实现技术很常见。 使用 yield 有一个有趣的好处,即使用 foreach 语句或手动使用基础 GetEnumerator 和 MoveNext 方法循环访问查询之前,不会实际计算查询。 这种延迟计算允许查询保留为基于 IEnumererable<T> 的值,这些值可以多次计算,每次都会产生可能不同的结果。
对于许多应用程序,这正是所需的行为。 对于想要缓存查询计算结果的应用程序,提供了两个运算符 ToList 和 ToArray,用于强制立即计算查询并返回 List<T> 或包含查询计算结果的数组。
若要查看延迟查询评估的工作原理,请考虑此程序,该程序对数组运行简单查询:
// declare a variable containing some strings
string[] names = { "Allen", "Arthur", "Bennett" };
// declare a variable that represents a query
IEnumerable<string> ayes = names.Where(s => s[0] == 'A');
// evaluate the query
foreach (string item in ayes)
Console.WriteLine(item);
// modify the original information source
names[0] = "Bob";
// evaluate the query again, this time no "Allen"
foreach (string item in ayes)
Console.WriteLine(item);
每次循环访问变量 ayes 时,都会计算查询。 若要指示需要结果的缓存副本,只需将 ToList 或 ToArray 运算符追加到查询,如下所示:
// declare a variable containing some strings
string[] names = { "Allen", "Arthur", "Bennett" };
// declare a variable that represents the result
// of an immediate query evaluation
string[] ayes = names.Where(s => s[0] == 'A').ToArray();
// iterate over the cached query results
foreach (string item in ayes)
Console.WriteLine(item);
// modifying the original source has no effect on ayes
names[0] = "Bob";
// iterate over result again, which still contains "Allen"
foreach (string item in ayes)
Console.WriteLine(item);
ToArray 和 ToList 都强制立即查询评估。 对于返回单一实例值的标准查询运算符也是如此, (例如: First、 ElementAt、 Sum、 Average、 All、 Any) 。
IQueryable<T> 接口
对于使用表达式树(如 LINQ to SQL)实现查询功能的数据源,通常需要相同的延迟执行模型。 这些数据源可以从实现 IQueryable<T> 接口中获益,该接口使用表达式树实现 LINQ 模式所需的所有查询运算符。 每个 IQueryable<T> 都以表达式树的形式表示“运行查询所需的代码”。 所有延迟的查询运算符都返回一个新的 IQueryable<T> ,它通过调用该查询运算符的表示形式来扩充该表达式树。 因此,当需要计算查询时(通常是因为枚举了 IQueryable<T> ),数据源可以处理表示整个查询的表达式树一批。 例如,通过多次调用查询运算符获取的复杂 LINQ to SQL 查询可能导致仅将单个 SQL 查询发送到数据库。
数据源实现者通过实现 IQueryable<T>
接口重用此延迟功能的好处是显而易见的。 另一方面,对于编写查询的客户端而言,为远程信息源提供通用类型是一个很大的优势。 它不仅允许他们编写可用于不同数据源的多态查询,而且还为编写跨域的查询提供了可能性。
初始化复合值
Lambda 表达式和扩展方法为我们提供了查询所需的一切,这些查询只是筛选出一系列值的成员。 大多数查询表达式还会对这些成员执行投影,从而有效地将原始序列的成员转换为值和类型可能与原始成员不同的成员。 为了支持编写这些转换,LINQ 依赖于名为 对象初始值设定项 的新构造来创建结构化类型的新实例。 对于本文档的其余部分,我们将假定已定义以下类型:
public class Person {
string name;
int age;
bool canCode;
public string Name {
get { return name; } set { name = value; }
}
public int Age {
get { return age; } set { age = value; }
}
public bool CanCode {
get { return canCode; } set { canCode = value; }
}
}
对象初始值设定项使我们能够根据类型的公共字段和属性轻松构造值。 例如,若要创建 Person 类型的新值,可以编写以下语句:
Person value = new Person {
Name = "Chris Smith", Age = 31, CanCode = false
};
从语义上讲,此语句等效于以下语句序列:
Person value = new Person();
value.Name = "Chris Smith";
value.Age = 31;
value.CanCode = false;
对象初始值设定项是语言集成查询的一项重要功能,因为它们允许在仅允许表达式 ((如 lambda 表达式和表达式树) )中构造新的结构化值。 例如,请考虑以下查询表达式,该表达式为输入序列中的每个值创建新的 Person 值:
IEnumerable<Person> query = names.Select(s => new Person {
Name = s, Age = 21, CanCode = s.Length == 5
});
对象初始化语法对于初始化结构化值的数组也很方便。 例如,请考虑使用单个对象初始值设定项初始化的数组变量:
static Person[] people = {
new Person { Name="Allen Frances", Age=11, CanCode=false },
new Person { Name="Burke Madison", Age=50, CanCode=true },
new Person { Name="Connor Morgan", Age=59, CanCode=false },
new Person { Name="David Charles", Age=33, CanCode=true },
new Person { Name="Everett Frank", Age=16, CanCode=true },
};
结构化值和类型
LINQ 项目支持以数据为中心的编程样式,其中某些类型主要用于在结构化值上提供静态“形状”,而不是同时具有状态和行为的完整对象。 以这一前提为逻辑结论,开发人员通常关心的是值的结构,并且该形状的命名类型需求几乎没有什么用处。 这会导致引入 匿名类型 ,这些类型允许在初始化时“内联”定义新结构。
在 C# 中,匿名类型的语法类似于对象初始化语法,只不过省略了类型的名称。 例如,请考虑以下两个语句:
object v1 = new Person {
Name = "Brian Smith", Age = 31, CanCode = false
};
object v2 = new { // note the omission of type name
Name = "Brian Smith", Age = 31, CanCode = false
};
变量 v1 和 v2 都指向内存中对象,其 CLR 类型具有三个公共属性 Name、 Age 和 CanCode。 变量的不同之处在于 v2 引用 匿名类型的实例。 在 CLR 术语中,匿名类型与任何其他类型没有什么不同。 匿名类型的特殊之处是它们在编程语言中没有有意义的名称。 创建匿名类型的实例的唯一方法是使用上面所示的语法。
为了允许变量引用匿名类型的实例,但仍受益于静态类型,C# 引入了隐式类型化局部变量:var 关键字 (keyword) 可用于代替局部变量声明的类型名称。 例如,请考虑以下合法的 C# 3.0 程序:
var s = "Bob";
var n = 32;
var b = true;
var 关键字 (keyword) 告知编译器从用于初始化变量的表达式的静态类型推断变量的类型。 在此示例中, s、 n 和 b 的类型分别为 string、 int 和 bool。 此程序与以下内容相同:
string s = "Bob";
int n = 32;
bool b = true;
var 关键字 (keyword) 对于类型具有有意义的名称的变量来说是一种便利,但对于引用匿名类型实例的变量来说,这是一种必要条件。
var value = new {
Name = " Brian Smith", Age = 31, CanCode = false
};
在上面的示例中,变量 值 属于匿名类型,其定义等效于以下伪 C#:
internal class ??? {
string _Name;
int _Age;
bool _CanCode;
public string Name {
get { return _Name; } set { _Name = value; }
}
public int Age{
get { return _Age; } set { _Age = value; }
}
public bool CanCode {
get { return _CanCode; } set { _CanCode = value; }
}
public bool Equals(object obj) { ... }
public bool GetHashCode() { ... }
}
不能跨程序集边界共享匿名类型;但是,编译器确保每个程序集中给定的属性名称/类型对序列最多有一个匿名类型。
由于在投影中经常使用匿名类型来选择现有结构化值的一个或多个成员,因此只需在初始化匿名类型时引用另一个值的字段或属性。 这会导致新的匿名类型获取属性,该属性的名称、类型和值都从引用的属性或字段复制。
例如,假设此示例通过组合其他值中的属性来创建一个新的结构化值:
var bob = new Person { Name = "Bob", Age = 51, CanCode = true };
var jane = new { Age = 29, FirstName = "Jane" };
var couple = new {
Husband = new { bob.Name, bob.Age },
Wife = new { Name = jane.FirstName, jane.Age }
};
int ha = couple.Husband.Age; // ha == 51
string wn = couple.Wife.Name; // wn == "Jane"
引用上面所示的字段或属性只是编写以下更明确的格式的便捷语法:
var couple = new {
Husband = new { Name = bob.Name, Age = bob.Age },
Wife = new { Name = jane.FirstName, Age = jane.Age }
};
在这两种情况下,夫妇变量从 bob 和 jane 获取其自己的 Name 和 Age 属性副本。
匿名类型最常用于查询的 select 子句中。 例如,考虑以下查询:
var query = people.Select(p => new {
p.Name, BadCoder = p.Age == 11
});
foreach (var item in query)
Console.WriteLine("{0} is a {1} coder",
item.Name,
item.BadCoder ? "bad" : "good");
在此示例中,我们能够在 Person 类型上创建一个新的投影,该投影与处理代码所需的形状完全匹配,但仍具有静态类型的优势。
更多标准查询运算符
在上述基本查询工具的基础上,许多运算符提供了操作序列和组合查询的有用方法,使用户在标准查询运算符的便捷框架中可以高度控制结果。
排序和分组
一般情况下,对查询的求值会生成一系列值,这些值按基础信息源中固有的某种顺序生成。 为了使开发人员能够显式控制这些值的生成顺序,定义了标准查询运算符来控制顺序。 这些运算符中最基本的是 OrderBy 运算符。
OrderBy 和 OrderByDescending 运算符可应用于任何信息源,并允许用户提供生成用于对结果进行排序的值的键提取函数。 OrderBy 和 OrderByDescending 还接受一个可选的比较函数,该函数可用于对键施加部分顺序。 让我们看一个基本示例:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
// unity sort
var s1 = names.OrderBy(s => s);
var s2 = names.OrderByDescending(s => s);
// sort by length
var s3 = names.OrderBy(s => s.Length);
var s4 = names.OrderByDescending(s => s.Length);
前两个查询表达式生成基于字符串比较对源成员进行排序的新序列。 后两个查询生成新的序列,这些序列基于每个字符串的长度对源成员进行排序。
为了允许多个排序条件, OrderBy 和 OrderByDescending 都返回 OrderedSequence<T> ,而不是泛型 IEnumerable<T>。 两个运算符仅在 OrderedSequence<T> 上定义,即 ThenBy 和 ThenByDescending ,它们应用其他 (从属) 排序条件。 ThenBy/ThenByDescending 本身返回 OrderedSequence<T>,允许应用任意数量的 ThenBy/ThenByDescending 运算符:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
var s1 = names.OrderBy(s => s.Length).ThenBy(s => s);
评估此示例中 s1 引用的查询将生成以下值序列:
"Burke", "David", "Frank",
"Albert", "Connor", "George", "Harris",
"Everett"
除了 OrderBy 系列运算符外,标准查询运算符还包括 Reverse 运算符。 Reverse 只是枚举序列,并按相反顺序生成相同的值。 与 OrderBy 不同, Reverse 在确定顺序时不考虑实际值本身,而是仅依赖于基础源生成值的顺序。
OrderBy 运算符对值序列施加排序顺序。 标准查询运算符还包括 GroupBy 运算符,该运算符基于键提取函数对值序列进行分区。 GroupBy 运算符返回 IGrouping 值的序列,每个遇到的非重复键值对应一个序列。 IGrouping 是一个 IEnumerable,另外还包含用于提取其内容的密钥:
public interface IGrouping<K, T> : IEnumerable<T> {
public K Key { get; }
}
GroupBy 的最简单应用程序如下所示:
string[] names = { "Albert", "Burke", "Connor", "David",
"Everett", "Frank", "George", "Harris"};
// group by length
var groups = names.GroupBy(s => s.Length);
foreach (IGrouping<int, string> group in groups) {
Console.WriteLine("Strings of length {0}", group.Key);
foreach (string value in group)
Console.WriteLine(" {0}", value);
}
运行时,此程序会输出以下内容:
Strings of length 6
Albert
Connor
George
Harris
Strings of length 5
Burke
David
Frank
Strings of length 7
Everett
使用 la Select, GroupBy 可以提供用于填充组成员的投影函数。
string[] names = { "Albert", "Burke", "Connor", "David",
"Everett", "Frank", "George", "Harris"};
// group by length
var groups = names.GroupBy(s => s.Length, s => s[0]);
foreach (IGrouping<int, char> group in groups) {
Console.WriteLine("Strings of length {0}", group.Key);
foreach (char value in group)
Console.WriteLine(" {0}", value);
}
此变体打印以下内容:
Strings of length 6
A
C
G
H
Strings of length 5
B
D
F
Strings of length 7
E
注意 在此示例中,投影类型不需要与源相同。 在本例中,我们创建了一组从字符串到字符的整数分组。
聚合运算符
定义了多个标准查询运算符,用于将一系列值聚合成单个值。 最常见的聚合运算符是 Aggregate,其定义如下:
public static U Aggregate<T, U>(this IEnumerable<T> source,
U seed, Func<U, T, U> func) {
U result = seed;
foreach (T element in source)
result = func(result, element);
return result;
}
使用 Aggregate 运算符可以方便地对一系列值执行计算。 聚合 的工作原理是为基础序列的每个成员调用一次 lambda 表达式。 每次 Aggregate 调用 lambda 表达式时,它都会将序列中的成员和聚合值( (初始值是种子参数)传递给 Aggregate) 。 lambda 表达式的结果将替换以前的聚合值, 而 Aggregate 返回 lambda 表达式的最终结果。
例如,此程序使用 Aggregate 在字符串数组中累积字符总数:
string[] names = { "Albert", "Burke", "Connor", "David",
"Everett", "Frank", "George", "Harris"};
int count = names.Aggregate(0, (c, s) => c + s.Length);
// count == 46
除了常规用途 聚合 运算符外,标准查询运算符还包括一个常规用途 Count 运算符和四个数值聚合运算符, (Min、 Max、 Sum 和 Average) ,可简化这些常见聚合操作。 数值聚合函数可处理数值类型的序列 (例如 int、 double、 decimal) 或任意值的序列,只要提供将序列成员投影为数值类型的函数。
此程序演示了上述 Sum 运算符的两种形式:
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
string[] names = { "Albert", "Burke", "Connor", "David",
"Everett", "Frank", "George", "Harris"};
int total1 = numbers.Sum(); // total1 == 55
int total2 = names.Sum(s => s.Length); // total2 == 46
注意 第二个 Sum 语句等效于使用 Aggregate 的上一个示例。
选择与 SelectMany
Select 运算符要求转换函数为源序列中的每个值生成一个值。 如果转换函数返回的值本身是序列,则由使用者手动遍历子序列。 例如,请考虑使用现有 String.Split 方法将字符串拆分为标记的程序:
string[] text = { "Albert was here",
"Burke slept late",
"Connor is happy" };
var tokens = text.Select(s => s.Split(' '));
foreach (string[] line in tokens)
foreach (string token in line)
Console.Write("{0}.", token);
运行时,此程序输出以下文本:
Albert.was.here.Burke.slept.late.Connor.is.happy.
理想情况下,我们希望查询返回标记的合并序列,而不是向使用者公开中间 字符串[] 。 为此,我们使用 SelectMany 运算符而不是 Select 运算符。 SelectMany 运算符的工作方式与 Select 运算符类似。 不同之处在于转换函数应返回一个序列,然后由 SelectMany 运算符展开。 下面是使用 SelectMany 重写的程序:
string[] text = { "Albert was here",
"Burke slept late",
"Connor is happy" };
var tokens = text.SelectMany(s => s.Split(' '));
foreach (string token in tokens)
Console.Write("{0}.", token);
使用 SelectMany 会导致将每个中间序列扩展为正常计算的一部分。
SelectMany 非常适合用于组合两个信息源:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
var query = names.SelectMany(n =>
people.Where(p => n.Equals(p.Name))
);
在传递给 SelectMany 的 lambda 表达式中,嵌套查询应用于不同的源,但在范围
n
中,参数从外部源传入。 因此 人。其中 为每个 n 调用一次,生成的序列由 SelectMany 平展为最终输出。 结果是 名称数组中 显示其名称的所有人员的序列。
联接运算符
在面向对象的程序中,相互关联的对象通常与易于导航的对象引用链接。 对于外部信息源,这通常不成立,其中数据条目通常没有选择,只能以符号方式相互“指向”,ID 或可以唯一标识指向实体的其他数据。 联接的概念是指将序列的元素与另一个序列中“匹配”的元素一起的操作。
上一个 SelectMany 示例实际上完全如此,将字符串与名称为这些字符串的人员匹配。 但是,出于此特定目的,SelectMany 方法并不十分高效,它将遍历人名的每个元素的所有元素。 通过在一个方法调用中将此方案的所有信息(两个信息源及其匹配的“键”)组合在一起, Join 运算符能够做得更好:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
var query = names.Join(people, n => n, p => p.Name, (n,p) => p);
这有点令人费力,但看看这些部分如何组合在一起:在“外部”数据源名称上调用 Join 方法。 第一个参数是“内部”数据源 ,即 people。 第二个和第三个参数是 lambda 表达式,用于分别从外部源和内部源的元素中提取键。 这些键是 Join 方法用来匹配元素的内容。 在这里,我们希望名称本身与人员的 Name 属性匹配。 然后,最终 lambda 表达式负责生成结果序列的元素:它与每对匹配的元素 n 和 p 一起调用,并用于调整结果。 在这种情况下,我们选择放弃 n 并返回 p。 最终结果是姓名列表中的人员的 Person 元素列表。
更强大的 Join 表亲是 GroupJoin 运算符。 GroupJoin 在结果塑造 lambda 表达式的使用方式上与 Join 不同:它不是与每对外部元素和内部元素一起调用,而是为每个外部元素调用一次,其中包含与该外部元素匹配的所有内部元素的序列。 使该具体化:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
var query = names.GroupJoin(people, n => n, p => p.Name,
(n, matching) =>
new { Name = n, Count = matching.Count() }
);
此调用将生成一系列名称,其中包含与具有该姓名的人数配对的名称。 因此, GroupJoin 运算符允许您基于外部元素的整个“匹配集”结果。
查询语法
C# 中的现有 foreach 语句为通过 .NET Frameworks IEnumerable/IEnumerator 方法迭代提供声明性语法。 foreach 语句是严格可选的,但它已被证明是一种非常方便和流行的语言机制。
在此先例的基础上,查询表达式使用最常见的查询运算符的声明性语法简化查询:Where、Join、GroupJoin、SelectMany、GroupBy、OrderBy、ThenBy、OrderByDescending、ThenByDescending 和 Cast。
让我们先看一下本文开头的简单查询:
IEnumerable<string> query = names
.Where(s => s.Length == 5)
.OrderBy(s => s)
.Select(s => s.ToUpper());
使用查询表达式,可以重写此确切语句,如下所示:
IEnumerable<string> query = from s in names
where s.Length == 5
orderby s
select s.ToUpper();
与 C# 中的 foreach 语句一样,查询表达式更紧凑且更易于阅读,但完全是可选的。 每个可以编写为查询表达式的表达式都有相应的 (尽管使用点表示法) 语法更详细。
首先,让我们看一下查询表达式的基本结构。 C# 中的每个语法查询表达式都以 from 子句开头,以 select 子句或 group 子句结尾。 初始 from 子句可以后跟 零个或多个 from、 let、 where、 join 和 orderby 子句。 每个 from 子句都是一个生成器,用于在序列上引入范围变量;每个 let 子句为表达式的结果提供一个名称;和每个 where 子句都是一个筛选器,用于从结果中排除项。 每个 联接 子句将新的数据源与上述子句的结果相关联。 orderby 子句指定结果的排序:
query-expression ::= from-clause query-body
query-body ::=
query-body-clause* final-query-clause query-continuation?
query-body-clause ::=
(from-clause
| join-clause
| let-clause
| where-clause
| orderby-clause)
from-clause ::=from itemName in srcExpr
join-clause ::=join itemName in srcExpr on keyExpr equals keyExpr
(into itemName)?
let-clause ::=let itemName = selExpr
where-clause ::= where predExpr
orderby-clause ::= orderby (keyExpr (ascending | descending)?)*
final-query-clause ::=
(select-clause | groupby-clause)
select-clause ::= select selExpr
groupby-clause ::= group selExpr by keyExprquery-continuation ::= intoitemName query-body
例如,请考虑以下两个查询表达式:
var query1 = from p in people
where p.Age > 20
orderby p.Age descending, p.Name
select new {
p.Name, Senior = p.Age > 30, p.CanCode
};
var query2 = from p in people
where p.Age > 20
orderby p.Age descending, p.Name
group new {
p.Name, Senior = p.Age > 30, p.CanCode
} by p.CanCode;
编译器将这些查询表达式视为使用以下显式点表示法编写:
var query1 = people.Where(p => p.Age > 20)
.OrderByDescending(p => p.Age)
.ThenBy(p => p.Name)
.Select(p => new {
p.Name,
Senior = p.Age > 30,
p.CanCode
});
var query2 = people.Where(p => p.Age > 20)
.OrderByDescending(p => p.Age)
.ThenBy(p => p.Name)
.GroupBy(p => p.CanCode,
p => new {
p.Name,
Senior = p.Age > 30,
p.CanCode
});
查询表达式将机械转换为具有特定名称的方法调用。 因此,选择的确切查询运算符 实现 取决于要查询的变量的类型和范围内的扩展方法。
到目前为止显示的查询表达式只使用了一个生成器。 使用多个生成器时,会在其前置生成器的上下文中评估每个后续生成器。 例如,请考虑对查询进行以下轻微修改:
var query = from s1 in names
where s1.Length == 5
from s2 in names
where s1 == s2
select s1 + " " + s2;
针对此输入数组运行时:
string[] names = { "Burke", "Connor", "Frank", "Everett",
"Albert", "George", "Harris", "David" };
我们得到以下结果:
Burke Burke
Frank Frank
David David
上述查询表达式将扩展到此点表示法表达式:
var query = names.Where(s1 => s1.Length == 5)
.SelectMany(s1 => names, (s1,s2) => new {s1,s2})
.Where($1 => $1.s1 == $1.s2)
.Select($1 => $1.s1 + " " + $1.s2);
注意 此版本的 SelectMany 采用额外的 lambda 表达式,用于基于外部和内部序列中的元素生成结果。 在此 lambda 表达式中,两个范围变量以匿名类型收集。 编译器发明了一个变量名称 $1 ,以在后续 lambda 表达式中表示该匿名类型。
一种特殊的生成器是 join 子句,它将根据给定键引入另一个源的元素,这些元素与上述子句的元素匹配。 联接子句可能会逐个生成匹配的元素,但如果使用 into 子句指定,则匹配的元素将作为一个组提供:
var query = from n in names
join p in people on n equals p.Name into matching
select new { Name = n, Count = matching.Count() };
毫不奇怪,此查询会直接扩展到我们之前见过的查询:
var query = names.GroupJoin(people, n => n, p => p.Name,
(n, matching) =>
new { Name = n, Count = matching.Count() }
);
在后续查询中将一个查询的结果视为生成器通常很有用。 为了支持此功能,查询表达式使用 关键字 (keyword) 在 select 或 group 子句之后拼接新的查询表达式。 这称为 查询延续。
into 关键字 (keyword) 对于后处理 group
by 子句的结果特别有用。 例如,请考虑以下程序:
var query = from item in names
orderby item
group item by item.Length into lengthGroups
orderby lengthGroups.Key descending
select lengthGroups;
foreach (var group in query) {
Console.WriteLine("Strings of length {0}", group.Key);
foreach (var val in group)
Console.WriteLine(" {0}", val);
}
此程序输出以下内容:
Strings of length 7
Everett
Strings of length 6
Albert
Connor
George
Harris
Strings of length 5
Burke
David
Frank
本部分介绍了 C# 如何实现查询表达式。 其他语言可能选择支持具有显式语法的附加查询运算符,或者根本不使用查询表达式。
请务必注意,查询语法绝不是硬连接到标准查询运算符。 它是一种纯语法功能,适用于通过实现具有适当名称和签名的基础方法来实现 查询模式 的任何内容。 上述标准查询运算符通过使用扩展方法来扩充 IEnumerable<T> 接口。 开发人员可以在所需的任意类型上利用查询语法,只要他们通过直接实现必要的方法或将其添加为扩展方法,确保它符合查询模式。
通过提供两个 支持 LINQ 的 API,即 LINQ to SQL(实现基于 SQL 的数据访问的 LINQ 模式)和允许对 XML 数据进行 LINQ 查询的 LINQ to XML,在 LINQ 项目本身中利用此扩展性。 以下各节将介绍这两者。
LINQ to SQL:SQL 集成
.NET Language-Integrated 查询可用于查询关系数据存储,而无需离开本地编程语言的语法或编译时环境。 此代码名为 LINQ to SQL 的设施利用了 SQL 架构信息与 CLR 元数据的集成。 此集成将 SQL 表和视图定义编译为可通过任何语言访问的 CLR 类型。
LINQ to SQL 定义了两个核心属性 [Table] 和 [Column],它们指示哪些 CLR 类型和属性对应于外部 SQL 数据。 [Table] 属性可以应用于类,并将 CLR 类型与命名的 SQL 表或视图相关联。 [Column] 属性可以应用于任何字段或属性,并将成员与命名的 SQL 列相关联。 这两个属性都参数化,以允许保留特定于 SQL 的元数据。 例如,请考虑以下简单的 SQL 架构定义:
create table People (
Name nvarchar(32) primary key not null,
Age int not null,
CanCode bit not null
)
create table Orders (
OrderID nvarchar(32) primary key not null,
Customer nvarchar(32) not null,
Amount int
)
CLR 等效项如下所示:
[Table(Name="People")]
public class Person {
[Column(DbType="nvarchar(32) not null", Id=true)]
public string Name;
[Column]
public int Age;
[Column]
public bool CanCode;
}
[Table(Name="Orders")]
public class Order {
[Column(DbType="nvarchar(32) not null", Id=true)]
public string OrderID;
[Column(DbType="nvarchar(32) not null")]
public string Customer;
[Column]
public int? Amount;
}
注意此示例:可为 null 列映射到 CLR 中的可为空类型 (可为空类型首次出现在 .NET Framework) 2.0 版中,对于与 CLR 类型没有 1:1 对应关系 (例如 nvarchar、char、text) ,原始 SQL 类型将保留在 CLR 元数据中。
为了针对关系存储发出查询,LINQ 模式的 LINQ to SQL 实现将查询从其表达式树形式转换为 SQL 表达式,并 ADO.NET 适用于远程评估的 DbCommand 对象。 例如,请考虑以下简单查询:
// establish a query context over ADO.NET sql connection
DataContext context = new DataContext(
"Initial Catalog=petdb;Integrated Security=sspi");
// grab variables that represent the remote tables that
// correspond to the Person and Order CLR types
Table<Person> custs = context.GetTable<Person>();
Table<Order> orders = context.GetTable<Order>();
// build the query
var query = from c in custs
from o in orders
where o.Customer == c.Name
select new {
c.Name,
o.OrderID,
o.Amount,
c.Age
};
// execute the query
foreach (var item in query)
Console.WriteLine("{0} {1} {2} {3}",
item.Name, item.OrderID,
item.Amount, item.Age);
DataContext 类型提供一个轻型翻译器,用于将标准查询运算符转换为 SQL。 DataContext 使用现有的 ADO.NET IDbConnection 访问存储区,可以使用已建立 ADO.NET 连接对象或可用于创建连接的连接字符串进行初始化。
GetTable 方法提供可在查询表达式中使用的 IEnumerable 兼容变量来表示远程表或视图。 对 GetTable 的调用不会导致与数据库进行任何交互,而是表示使用查询表达式与远程表或视图交互 的可能性 。 在上面的示例中,在程序循环访问查询表达式之前,查询不会传输到存储区,在这种情况下,使用 C# 中的 foreach 语句。 当程序首次循环访问查询时, DataContext 机制会将表达式树转换为发送到存储的以下 SQL 语句:
SELECT [t0].[Age], [t1].[Amount],
[t0].[Name], [t1].[OrderID]
FROM [Customers] AS [t0], [Orders] AS [t1]
WHERE [t1].[Customer] = [t0].[Name]
请务必注意,通过将查询功能直接构建到本地编程语言中,开发人员无需将关系模型静态烘焙到 CLR 类型即可获得关系模型的全部功能。 也就是说,全面的对象/关系映射也可以利用此核心查询功能,供需要该功能的用户使用。 LINQ to SQL 提供对象关系映射功能,开发人员可以使用这些功能定义和导航对象之间的关系。 可以使用映射将 Orders 称为 Customer 类的属性,这样就不需要显式联接来将两者捆绑在一起。 外部映射文件允许映射与对象模型分离,以实现更丰富的映射功能。
LINQ to XML:XML 集成
.NET Language-Integrated XML (LINQ to XML) 查询允许使用标准查询运算符以及树特定的运算符来查询 XML 数据,这些运算符通过后代、祖先和同级提供类似于 XPath 的导航。 它为 XML 提供了一种高效的内存中表示形式,该表示形式与现有的 System.Xml 读取器/编写器基础结构集成,并且比 W3C DOM 更易于使用。 将 XML 与查询集成的大部分工作有三种类型: XName、 XElement 和 XAttribute。
XName 提供了一种易于使用的方式来处理命名空间限定标识符 (QNames) 用作元素和属性名称。 XName 以透明方式处理标识符的有效原子化,并允许在需要 QName 的地方使用符号或纯字符串。
XML 元素和属性分别使用 XElement 和 XAttribute 表示。 XElement 和 XAttribute 支持常规构造语法,允许开发人员使用自然语法编写 XML 表达式:
var e = new XElement("Person",
new XAttribute("CanCode", true),
new XElement("Name", "Loren David"),
new XElement("Age", 31));
var s = e.ToString();
这对应于以下 XML:
<Person CanCode="true">
<Name>Loren David</Name>
<Age>31</Age>
</Person>
请注意,创建 XML 表达式不需要基于 DOM 的工厂模式,并且 ToString 实现生成了文本 XML。 也可以从现有 XmlReader 或字符串文本构造 XML 元素:
var e2 = XElement.Load(xmlReader);
var e1 = XElement.Parse(
@"<Person CanCode='true'>
<Name>Loren David</Name>
<Age>31</Age>
</Person>");
XElement 还支持使用现有 XmlWriter 类型发出 XML。
XElement 与查询运算符保持一致,允许开发人员针对非 XML 信息编写查询,并通过在 select 子句的正文中构造 XElement 生成 XML 结果:
var query = from p in people
where p.CanCode
select new XElement("Person",
new XAttribute("Age", p.Age),
p.Name);
此查询返回 XElement 序列。 为了允许从此类查询的结果中生成 XElement , XElement 构造函数允许将元素序列作为参数直接传递:
var x = new XElement("People",
from p in people
where p.CanCode
select
new XElement("Person",
new XAttribute("Age", p.Age),
p.Name));
此 XML 表达式生成以下 XML:
<People>
<Person Age="11">Allen Frances</Person>
<Person Age="59">Connor Morgan</Person>
</People>
上述语句直接转换为 Visual Basic。 但是,Visual Basic 9.0 还支持使用 XML 文本,这允许使用直接从 Visual Basic 使用声明性 XML 语法来表示查询表达式。 可以使用 Visual Basic 语句构造上一个示例:
Dim x = _
<People>
<%= From p In people __
Where p.CanCode _
Select <Person Age=<%= p.Age %>>p.Name</Person> _
%>
</People>
到目前为止,这些示例演示了如何使用语言集成查询 来构造 新的 XML 值。 XElement 和 XAttribute 类型还简化了从 XML 结构中提取信息的功能。 XElement 提供访问器方法,允许将查询表达式应用于传统的 XPath 轴。 例如,以下查询仅从上面所示的 XElement 中提取名称:
IEnumerable<string> justNames =
from e in x.Descendants("Person")
select e.Value;
//justNames = ["Allen Frances", "Connor Morgan"]
若要从 XML 中提取结构化值,只需在 select 子句中使用对象初始值设定项表达式:
IEnumerable<Person> persons =
from e in x.Descendants("Person")
select new Person {
Name = e.Value,
Age = (int)e.Attribute("Age")
};
请注意, XAttribute 和 XElement 都支持显式转换,以将文本值提取为基元类型。 若要处理缺失数据,只需强制转换为可以为 null 的类型:
IEnumerable<Person> persons =
from e in x.Descendants("Person")
select new Person {
Name = e.Value,
Age = (int?)e.Attribute("Age") ?? 21
};
在本例中,当缺少 Age 属性时,我们使用默认值 21。
Visual Basic 9.0 为 XElement 的 Elements、Attribute 和 Descendants 访问器方法提供直接语言支持,允许使用更紧凑、更直接的语法(称为 XML 轴属性)访问基于 XML 的数据。 可以使用此功能编写前面的 C# 语句,如下所示:
Dim persons = _
From e In x...<Person> _
Select new Person { _
.Name = e.Value, _
.Age = IIF(e.@Age, 21) _
}
在 Visual Basic 中,x...<Person> 获取名为Person 的 x 的 Descendants 集合中的所有项,而表达式 e.@Age 查找名称为 Age.
The Value 属性的所有 XAttributes 获取集合中的第一个属性,并调用该特性上的 Value 属性。
总结
.NET Language-Integrated查询将查询功能添加到 CLR 及其目标语言。 查询工具基于 lambda 表达式和表达式树构建,允许谓词、投影和键提取表达式用作不透明的可执行代码,或用作适合下游处理或转换的透明内存中数据。 LINQ 项目定义的标准查询运算符适用于任何 基于 IEnumererer 的<T> 的信息源,并与 ADO.NET (LINQ to SQL) 集成, System.Xml (LINQ to XML) ,使关系和 XML 数据能够获得语言集成查询的优势。
简单来说,标准查询运算符
运算符 | 说明 |
---|---|
其中 | 基于谓词函数的限制运算符 |
Select/SelectMany | 基于选择器函数的投影运算符 |
Take/Skip/TakeWhile/SkipWhile | 基于位置或谓词函数的分区运算符 |
Join/GroupJoin | 基于键选择器函数的联接运算符 |
Concat | 串联运算符 |
OrderBy/ThenBy/OrderByDescending/ThenByDescending | 基于可选键选择器和比较器函数按升序或降序排序的排序运算符 |
Reverse | 反转序列顺序的排序运算符 |
GroupBy | 基于可选键选择器和比较器函数的分组运算符 |
Distinct | Set 运算符删除重复项 |
Union/Intersect | 返回集联合或交集的 Set 运算符 |
Except | Set 运算符返回集差异 |
AsEnumerable | 将运算符转换为 IEnumerable<T> |
ToArray/ToList | 将运算符转换为 数组 或 列表<T> |
ToDictionary/ToLookup | 将运算符转换为 字典<K、T> 或 查找<K,T> (基于键选择器函数的多字典) |
OfType/Cast | 基于筛选依据或转换为类型参数的 IEnumerable<T> 的转换运算符 |
SequenceEqual | 用于检查成对元素相等性的相等运算符 |
First/FirstOrDefault/Last/LastOrDefault/Single/SingleOrDefault | 基于可选谓词函数返回初始/最终/仅元素的元素运算符 |
ElementAt/ElementAtOrDefault | 基于位置返回元素的元素运算符 |
DefaultIfEmpty | 元素运算符,将空序列替换为默认值的单一实例序列 |
范围 | 生成运算符返回范围中的数字 |
Repeat | 返回给定值的多个匹配项的生成运算符 |
空 | 返回空序列的生成运算符 |
任意/所有 | 谓词函数存在或普遍满足的限定符检查 |
包含 | 限定符检查给定元素是否存在 |
Count/LongCount | 基于可选谓词函数对元素进行计数的聚合运算符 |
总和/最小值/最大值/平均值 | 基于可选选择器函数的聚合运算符 |
聚合 | 基于累加函数和可选种子累积多个值的聚合运算符 |