C-12-口袋参考-四-

83 阅读48分钟

C#12 口袋参考(四)

原文:zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92

译者:飞龙

协议:CC BY-NC-SA 4.0

第二十八章:可空值类型

引用类型可以用空引用表示不存在的值。然而,值类型通常不能表示 null 值。例如:

string s = null;   // OK - reference type
int i = null;      // Compile error - int cannot be null

要在值类型中表示 null,必须使用称为可空类型的特殊构造。可空类型用值类型后跟?符号表示:

int? i = null;                     // OK - nullable type
Console.WriteLine (i == null);     // True

Nullable 结构体

T?翻译成System.Nullable<T>Nullable<T>是一个轻量级的不可变结构,只有两个字段,用于表示ValueHasValueSystem.Nullable<T>的本质非常简单:

public struct Nullable<T> where T : struct
{
  public T Value {get;}
  public bool HasValue {get;}
  public T GetValueOrDefault();
  public T GetValueOrDefault (T defaultValue);
  ...
}

以下代码:

int? i = null;
Console.WriteLine (i == null);              // True

翻译为:

Nullable<int> i = new Nullable<int>();
Console.WriteLine (! i.HasValue);           // True

HasValue为 false 时尝试检索Value会引发InvalidOperationExceptionGetValueOrDefault()HasValue为 true 时返回Value;否则返回new T()或指定的自定义默认值。

T?的默认值是null

可空类型转换

TT?的转换是隐式的,而从T?T的转换是显式的。例如:

int? x = 5;        // implicit
int y = (int)x;    // explicit

显式转换直接等同于调用可空对象的Value属性。因此,如果HasValue为 false,则会引发InvalidOperationException

对可空值类型进行装箱/拆箱

T?被装箱时,堆上的装箱值包含T,而不是T?。这种优化是可能的,因为装箱值是一个可以表示 null 的引用类型。

C#也允许使用as运算符对可空类型进行拆箱。如果转换失败,则结果将为null

object o = "string";
int? x = o as int?;
Console.WriteLine (x.HasValue);   // False

运算符提升

Nullable<T>结构体并未定义诸如<>或甚至==之类的运算符。尽管如此,以下代码编译并正确执行:

int? x = 5;
int? y = 10;
bool b = x < y;      // true

这是因为编译器从基础值类型借用或“提升”了小于运算符。从语义上讲,它将前面的比较表达式转换为:

bool b = (x.HasValue && y.HasValue)
          ? (x.Value < y.Value)
          : false;

换句话说,如果xy都有值,则通过int的小于运算符比较;否则返回false

运算符提升意味着您可以隐式地在T?上使用T的运算符。您可以为T?定义运算符,以提供特定的空值行为,但在绝大多数情况下,最好依赖编译器自动为您应用系统化的可空逻辑。

编译器根据运算符的类别在空值逻辑上执行不同的操作。

相等运算符 (==, !=)

提升的相等运算符处理 null 值就像引用类型一样。这意味着两个 null 值是相等的:

Console.WriteLine (       null ==        null);  // True
Console.WriteLine ((bool?)null == (bool?)null);  // True

更进一步:

  • 如果恰好一个操作数为 null,则操作数不相等。

  • 如果两个操作数均非 null,则比较它们的Value

关系运算符 (<、<=、>=、>)

关系运算符基于不能比较 null 操作数的原则。这意味着将 null 值与 null 或非 null 值比较会返回false

bool b = x < y;    // Translation:

bool b = (x == null || y == null)
  ? false 
  : (x.Value < y.Value);

// b is false (assuming x is 5 and y is null)

所有其他运算符(+、−、*、/、%、&、|、^、<<、>>、+、++、--、!、~)

当任一操作数为空时,这些运算符返回空。这种模式对于 SQL 用户来说应该很熟悉:

int? c = x + y;   // Translation:

int? c = (x == null || y == null)
         ? null 
         : (int?) (x.Value + y.Value);

// c is null (assuming x is 5 and y is null)

例外情况是当&|运算符应用于bool?时,我们稍后会讨论。

混合可空和非可空类型

您可以混合和匹配可空和非可空类型(这是因为存在从TT?的隐式转换):

int? a = null;
int b = 2;
int? c = a + b;   // c is null - equivalent to a + (int?)b

bool?&|运算符

当提供bool?类型的操作数时,&|运算符将null视为未知值。因此,null | true为 true,因为:

  • 如果未知值为 false,则结果将为 true。

  • 如果未知值为 true,则结果将为 true。

类似地,null & false为 false。这种行为对 SQL 用户来说应该很熟悉。以下示例列举了其他组合:

bool? n = null, f = false, t = true;
Console.WriteLine (n | n);    // *(null)*
Console.WriteLine (n | f);    // *(null)*
Console.WriteLine (n | t);    // True
Console.WriteLine (n & n);    // *(null)*
Console.WriteLine (n & f);    // False
Console.WriteLine (n & t);    // *(null)*

可空类型和空操作符

可空类型特别适用于??运算符(参见“空合并运算符”)。例如:

int? x = null;
int y = x ?? 5;        // y is 5

int? a = null, b = null, c = 123;
Console.WriteLine (a ?? b ?? c);  // 123

在可空值类型上使用??等同于使用GetValueOrDefault来调用显式默认值,唯一的区别是如果变量不为空,则默认值的表达式不会被评估。

可空类型也与空条件运算符配合得很好(参见“空条件运算符”)。在以下示例中,length评估为空:

System.Text.StringBuilder sb = null;
int? length = sb?.ToString().Length;

我们可以将此与空合并运算符结合使用,以评估为零而不是空:

int length = sb?.ToString().Length ?? 0;

第二十九章:可空引用类型

尽管可空值类型将空性引入值类型,可空引用类型(来自 C# 8)则相反。启用时,它们将(一定程度的)非空性引入引用类型,旨在帮助避免NullReference​Excep⁠tion

可空引用类型通过编译器纯粹强制执行安全级别,在检测到有可能生成NullReference​Excep⁠tion的代码时生成警告。

要启用可空引用类型,您必须将Nullable元素添加到您的*.csproj*项目文件中(如果要为整个项目启用它):

<Nullable>enable</Nullable>

或者在代码中的适当位置使用以下指令来使其生效:

#nullable enable   // enables NRT from this point on
#nullable disable  // disables NRT from this point on
#nullable restore  // resets NRT to project setting

启用后,编译器将非空性设置为默认值:如果要使引用类型接受空值而不生成警告,必须添加?后缀以指示可空引用类型。在以下示例中,s1是非可空的,而s2是可空的:

#nullable enable    // Enable nullable reference types

string s1 = null;   // Generates a compiler warning!
string? s2 = null;  // OK: s2 is *nullable reference type*
注意

因为可空引用类型是编译时构造,所以在stringstring?之间没有运行时差异。相比之下,可空值类型在类型系统中引入了具体内容,即Null​a⁠ble<T>结构。

以下也会生成警告,因为x未初始化:

class Foo { string x; }

如果通过字段初始化器或构造函数中的代码初始化x,警告将消失。

如果编译器认为可能会发生NullReferenceException,则在对可空引用类型进行解引用时也会向您发出警告。在以下示例中,访问字符串的 Length 属性会生成警告:

void Foo (string? s) => Console.Write (s.Length);

要消除警告,可以使用空值忽略运算符 (!):

void Foo (string? s) => Console.Write (s!.Length);

在这个示例中使用的空值忽略运算符是危险的,因为我们最初试图避免的NullReferen⁠ce​Exception可能会被抛出。我们可以按如下方式修复它:

void Foo (string? s)
{
  if (s != null) Console.Write (s.Length);
}

现在请注意,我们不再需要空值忽略运算符。这是因为编译器执行静态分析,并且在简单情况下足够聪明以推断出解引用是安全的,没有NullReferenceException的可能。

编译器检测和警告的能力并不是绝对可靠的,而且在覆盖范围方面也存在限制。例如,它无法知道数组的元素是否已被填充,因此以下情况不会生成警告:

var strings = new string[10];
Console.WriteLine (strings[0].Length);

第三十章:扩展方法

扩展方法 允许在不改变原始类型定义的情况下为现有类型添加新方法。扩展方法是静态类的静态方法,其中第一个参数应用了 this 修饰符。第一个参数的类型将是扩展的类型。例如:

public static class StringHelper
{
  public static bool IsCapitalized (this string s)
  {
    if (string.IsNullOrEmpty (s)) return false;
    return char.IsUpper (s[0]);
  }
}

IsCapitalized 扩展方法可以被调用,就像它是一个字符串上的实例方法一样,如下所示:

Console.Write ("Perth".IsCapitalized());

扩展方法调用在编译时会被转换回普通的静态方法调用:

Console.Write (StringHelper.IsCapitalized ("Perth"));

接口也可以被扩展:

public static T First<T> (this IEnumerable<T> sequence)
{
  foreach (T element in sequence)
    return element;
  throw new InvalidOperationException ("No elements!");
}
...
Console.WriteLine ("Seattle".First());   // S

扩展方法链

扩展方法与实例方法一样,提供了一种整洁的方法来链式调用函数。考虑以下两个函数:

public static class StringHelper
{
  public static string Pluralize (this string s) {...}
  public static string Capitalize (this string s) {...}
}

xy 是等价的,都会评估为"Sausages",但 x 使用扩展方法,而 y 使用静态方法:

string x = "sausage".Pluralize().Capitalize();

string y = StringHelper.Capitalize
           (StringHelper.Pluralize ("sausage"));

歧义和解决方案

任何兼容的实例方法总是优先于扩展方法—即使扩展方法的参数更具体匹配类型。

如果两个扩展方法具有相同的签名,则必须将扩展方法调用为普通静态方法,以消除调用方法的歧义。然而,如果一个扩展方法具有更具体的参数,则更具体的方法优先。

第三十一章:匿名类型

匿名类型 是一个简单的类,即时创建用于存储一组值。要创建匿名类型,您使用 new 关键字,后跟对象初始化程序,指定类型将包含的属性和值。例如:

var dude = new { Name = "Bob", Age = 1 };

编译器通过编写一个私有嵌套类型,为 Name(类型为 string)和 Age(类型为 int)生成只读属性来解决这个问题。必须使用 var 关键字引用匿名类型,因为类型名称是由编译器生成的。

匿名类型的属性名称可以从表达式中推断出,该表达式本身是一个标识符;例如:

int Age = 1;
var dude = new { Name = "Bob", Age };

这相当于:

var dude = new { Name = "Bob", Age = Age };

你可以像下面这样创建匿名类型的数组:

var dudes = new[]
{
  new { Name = "Bob", Age = 30 },
  new { Name = "Mary", Age = 40 }
};

匿名类型主要用于编写 LINQ 查询时使用。

匿名类型是不可变的,因此实例在创建后无法修改。然而,从 C# 10 开始,你可以使用 with 关键字创建具有变化的副本,就像使用记录一样。请参见“非破坏性变异”中的示例。

第三十二章:元组

与匿名类型类似,元组(C# 7+)提供了一种简单的方法来存储一组值。元组是为了允许方法返回多个值而引入到 C# 中的,而无需使用 out 参数(这是匿名类型无法做到的)。然而,此后,记录已被引入,提供了一种简洁的类型化方法,我们将在接下来的章节中描述。

创建元组字面量的最简单方法是在括号中列出所需的值。这将创建一个具有未命名元素的元组:

var bob = ("Bob", 23);
Console.WriteLine (bob.Item1);   // Bob
Console.WriteLine (bob.Item2);   // 23

与匿名类型不同,var 是可选的,并且你可以明确指定元组类型

(string,int) bob  = ("Bob", 23);

这意味着你可以从方法中有用地返回一个元组:

(string,int) person = GetPerson();
Console.WriteLine (person.Item1);    // Bob
Console.WriteLine (person.Item2);    // 23

(string,int) GetPerson() => ("Bob", 23);

元组与泛型兼容,因此以下类型都是合法的:

Task<(string,int)>
Dictionary<(string,int),Uri>
IEnumerable<(int ID, string Name)>   // See below...

元组是值类型,其元素是可变(读写)的。这意味着创建元组后,你可以修改Item1Item2等元素。

命名元组元素

当创建元组字面量时,你可以选择为元素指定有意义的名称:

var tuple = (Name:"Bob", Age:23);
Console.WriteLine (tuple.Name);     // Bob
Console.WriteLine (tuple.Age);      // 23

当指定元组类型时,你也可以这样做:

static (string Name, int Age) GetPerson() => ("Bob",23);

元素名称从属性或字段名称推断出:

var now = DateTime.Now;
var tuple = (now.Day, now.Month, now.Year);
Console.WriteLine (tuple.Day);               // OK
注意

元组是语法糖,用于使用名为 ValueTuple<T1>ValueTuple<T1,T2> 的一组泛型结构体,这些结构体具有名为 Item1Item2 等的字段。因此 (string,int)ValueTuple<string,int> 的别名。这意味着“命名元素”仅存在于源代码中——以及编译器的想象中——并在运行时大多数时候消失。

解构元组

元组隐式支持解构模式(参见“解构器”),因此你可以轻松地将元组解构为单独的变量。考虑以下示例:

var bob = ("Bob", 23);
string name = bob.Item1;
int age = bob.Item2;

使用元组的解构器,你可以将代码简化为这样:

var bob = ("Bob", 23);
(string name, int age) = bob;   // Deconstruct bob into
 // name and age.
Console.WriteLine (name);
Console.WriteLine (age);

解构语法与声明具有命名元素的元组的语法令人困惑地相似!以下突出了它们的区别:

(string name, int age)      = bob;  // Deconstructing
(string name, int age) bob2 = bob;  // Declaring tuple

第三十三章:记录

记录(从 C# 9 开始)是一种特殊的类或结构体,设计用于与不可变(只读)数据良好配合。其最有用的特性是允许非破坏性变异,即要“修改”不可变对象,你创建一个新对象,并复制数据同时合并修改。

记录也非常有用,用于创建仅组合或保存数据的类型。在简单情况下,它们消除了样板代码,同时遵循结构相等语义(如果它们的数据相同,则两个对象相同),这通常是不可变类型所需的。

记录纯粹是 C#的编译时构造。在运行时,CLR 将它们视为类或结构体(由编译器添加了一堆额外的“合成”成员)。

定义记录

记录定义类似于类或结构体定义,可以包含相同类型的成员,包括字段、属性、方法等。记录可以实现接口,(基于类的)记录可以子类化其他(基于类的)记录。

默认情况下,记录的基础类型是一个类:

record Point { }          // Point is a class

从 C# 10 开始,记录的基础类型也可以是结构体:

record struct Point { }   // Point is a struct

record class也是合法的,并具有与record相同的含义。)

一个简单的记录可能只包含一堆仅初始化的属性,也许还有一个构造函数:

record Point
{
  public Point (double x, double y) => (X, Y) = (x, y);

  public double X { get; init; }
  public double Y { get; init; }    
}

在编译时,C#将记录定义转换为类(或结构体)并执行以下额外步骤:

  • 它编写了一个受保护的复制构造函数(和一个隐藏的Clone方法),以促进非破坏性变异。

  • 它重写/重载了与相等性相关的函数,以实现结构相等性。

  • 它重写了ToString()方法(扩展了记录的公共属性,就像匿名类型一样)。

前述记录声明会展开为类似于这样的内容:

class Point
{  
  public Point (double x, double y) => (X, Y) = (x, y);

  public double X { get; init; }
  public double Y { get; init; }    

 protected Point (Point original) // “Copy constructor”
 {
 this.X = original.X; this.Y = original.Y
 }

  // This method has a strange compiler-generated name:
 public virtual Point <Clone>$() => new Point (this);

 // Additional code to override Equals, ==, !=,
 // GetHashCode, ToString()...
}

参数列表

记录定义可以通过使用参数列表来简化:

record Point (double X, double Y)
{
  ...
}

参数可以包括inparams修饰符,但不能包括outref。如果指定了参数列表,则编译器会执行以下额外步骤:

  • 它为每个参数编写了一个仅初始化的属性(或者在记录结构体的情况下为可写属性)。

  • 它编写了一个主构造函数来填充属性。

  • 它编写了一个解构器。

这意味着我们可以简单地如下声明我们的Point记录:

record Point (double X, double Y);

编译器最终生成(几乎)与我们在前述展开中列出的内容完全一致。一个小的区别是主构造函数中的参数名最终会成为XY,而不是xy

  public Point (double X, double Y)
  {
    this.X = X; this.Y = Y;
  }
注意

另外,由于是主构造函数,参数XY会神奇地在记录中的任何字段或属性初始化器中可用。我们稍后在“主构造函数”中讨论这个细微之处。

定义参数列表时的另一个区别是,编译器还会生成一个解构器:

  public void Deconstruct (out double X, out double Y)
  {
    X = this.X; Y = this.Y;
  }

具有参数列表的记录可以使用以下语法进行子类化:

record Point3D (double X, double Y, double Z) 
  : Point (X, Y);

然后,编译器会像这样发出一个主构造函数:

class Point3D : Point
{
  public double Z { get; init; }

  public Point3D (double X, double Y, double Z)
    : base (X, Y)
    => this.Z = Z;
}
注意

当你需要一个简单地将一堆值(在函数式编程中称为乘积类型)简单地组合在一起的类时,参数列表提供了一个不错的快捷方式,并且在原型设计中也可能非常有用。但是,当你需要向init访问器添加逻辑(例如参数验证)时,它们并不那么有用。

非破坏性变异

编译器对所有记录执行的最重要步骤是编写一个复制构造函数(以及一个隐藏的Clone方法)。这通过with关键字实现了非破坏性的变异:

Point p1 = new Point (3, 3);
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2);       // Point { X = 3, Y = 4 }

record Point (double X, double Y);

在本示例中,p2p1的副本,但其Y属性设置为 4。当属性更多时,这带来的好处更大。

非破坏性变异分为两个阶段:

  1. 首先,复制构造函数克隆记录。默认情况下,它复制记录的每个底层字段,创建一个忠实的副本,同时绕过(访问器的)任何逻辑。所有字段都包括在内(公共和私有的,以及支持自动属性的隐藏字段)。

  2. 然后,更新成员初始化器列表中的每个属性(这次使用init访问器)。

编译器将以下内容翻译为:

Test t2 = t1 with { A = 10, C = 30 };

转换为以下等效的功能:

Test t2 = new Test(t1);  // Clone t1
t2.A = 10;               // Update property A
t2.C = 30;               // Update property C

(如果您明确编写代码,由于AC是只读属性,则相同的代码不会编译。此外,复制构造函数是受保护的;C#通过调用一个写入到名为<Clone>$的记录中的公共隐藏方法来解决此问题。)

如果需要,您可以定义自己的复制构造函数。C#然后将使用您的定义而不是自己编写一个:

protected Point (Point original)
{
  this.X = original.X; this.Y = original.Y;
}

当子类化另一个记录时,复制构造函数负责仅复制其自己的字段。要复制基本记录的字段,请委托给基类:

protected Point (Point original) : base (original)
{
  ...
}

主构造函数

当您使用参数列表定义记录时,编译器会自动生成属性声明,以及主构造函数(和解构函数)。这在简单情况下效果很好,在更复杂的情况下,您可以省略参数列表并手动编写属性声明和构造函数。C#还提供了一个中间选项,即在写一些或所有属性声明的同时定义参数列表:

record Student(int ID, string Surname, string FirstName)
{
 public int ID { get; } = ID;
}

在这种情况下,我们“接管”了ID属性的定义,将其定义为只读(而不是只读初始化),防止它参与非破坏性变异。如果您从不需要非破坏性地变异特定属性,则将其设置为只读属性使您能够在记录中缓存计算数据,而无需编写刷新机制。

注意,我们需要包含属性初始化器(粗体):

  public int ID { get; } = ID;

当您“接管”属性声明时,您将负责初始化其值;主构造函数不再自动执行此操作。(这与在类或结构上定义主构造函数时的行为完全匹配。)请注意,粗体中的ID指的是主构造函数参数,而不是ID属性。

与类和结构的主构造函数语义一致,主构造函数参数(在本例中为IDSurnameFirstName)对所有字段和属性初始化器都是自动可见的。

您还可以接管属性定义并使用显式访问器:

int _id = ID;
public int ID { get => _id; init => _id = value; }

再次,粗体中的ID指的是主构造函数参数,而不是属性。(没有歧义的原因是从初始化程序访问属性是非法的。)

我们必须使用 ID 初始化 _id 属性,这使得这种“接管”变得不太有用,因为主构造函数中的任何逻辑(如验证)都将被绕过。

记录和相等性比较

正如对结构体、匿名类型和元组一样,记录提供了开箱即用的结构相等性,这意味着如果它们的字段(和自动属性)相等,则两个记录是相等的:

var p1 = new Point (1, 2);
var p2 = new Point (1, 2);
Console.WriteLine (p1.Equals (p2));   // True

record Point (double X, double Y);

相等运算符 也适用于记录(如同适用于元组一样):

Console.WriteLine (p1 == p2);         // True

与类和结构体不同,如果要自定义相等性行为,您不需要(也不能)重写 object.Equals 方法。相反,您需要定义一个公共的 Equals 方法,具有以下签名:

record Point (double X, double Y)
{
  public virtual bool Equals (Point other) =>
  other != null && X == other.X && Y == other.Y;
}

Equals 方法必须是 virtual(而不是 override),并且它必须是 强类型 的,以便接受实际的记录类型(在这种情况下是 Point,而不是 object)。一旦您正确地设置了签名,编译器将自动插入您的方法。

与任何类型一样,如果您接管了相等比较,您也应该重写 GetHashCode()。记录的一个好处是,您不需要重载 !===,也不需要实现 IEquatable<T>:这一切都由编译器为您完成。我们在《C# 12 简明概述》的第六章“相等比较”中详细讨论了这个主题。

第三十四章:模

早些时候,我们演示了如何使用 is 运算符测试引用转换是否成功,然后使用其转换后的值:

if (obj is string s)
  Console.WriteLine (s.Length);

这使用了一种称为 类型模式 的模式。is 运算符还支持其他在最近版本的 C# 中引入的模式。模式在以下上下文中受支持:

  • is 运算符之后(*variable* is *pattern*

  • switch 语句中

  • switch 表达式中

我们已经在“在类型上进行切换”和“is 运算符”中介绍了类型模式。在本节中,我们将介绍在最近版本的 C# 中引入的更高级的模式。

一些更专业的模式旨在在 switch 语句/表达式中使用。在这里,它们减少了对 when 子句的需求,并让您在以前无法使用 switch 的地方使用它们。

变量模式

var 模式类型模式 的一种变体,您可以用 var 关键字替换类型名称。转换总是成功的,因此它的目的仅仅是让您重用随后的变量:

bool IsJanetOrJohn (string name) => 
  name.ToUpper() is var upper && 
  (upper == "JANET" || upper == "JOHN");

这相当于:

bool IsJanetOrJohn (string name)
{
  string upper = name.ToUpper();
  return upper == "JANET" || upper == "JOHN";
}

常量模式

常量模式 允许您直接匹配到一个常量,对于与 object 类型一起工作时非常有用:

void Foo (object obj) 
{
  if (obj is 3) ...
}

这个加粗的表达式相当于以下内容:

obj is int && (int)obj == 3

正如我们将很快看到的那样,常量模式在使用 模式组合器 时会更加有用。

关系模式

自 C# 9 开始,您可以在模式中使用 <><=>= 运算符:

if (x is > 100) Console.Write ("x is greater than 100");

switch 中这变得非常有用:

string GetWeightCategory (decimal bmi) => bmi switch
{
  < 18.5m => "underweight",
  < 25m => "normal",
  < 30m => "overweight",
  _ => "obese"
};

模式组合器

自 C# 9 开始,您可以使用 andornot 关键字来组合模式:

bool IsJanetOrJohn (string name)
  => name.ToUpper() is "JANET" or "JOHN";

bool IsVowel (char c) 
  => c is 'a' or 'e' or 'i' or 'o' or 'u';

bool Between1And9 (int n) => n is >= 1 and <= 9;

bool IsLetter (char c) => c is >= 'a' and <= 'z'
                            or >= 'A' and <= 'Z';

&&||运算符一样,andor具有更高的优先级。您可以使用括号覆盖这一点。一个很好的技巧是将not组合器与类型模式结合起来,以测试对象是否为(不是)某种类型:

if (obj is not string) ...

这看起来比以下方式更好:

if (!(obj is string)) ...

元组和位置模式

元组模式(C# 8 中引入)匹配元组:

var p = (2, 3);
Console.WriteLine (p is (2, 3));   // True

元组模式可以被视为位置模式(C# 8+)的特殊情况,它匹配任何公开Deconstruct方法的类型(参见“解构器”)。在以下示例中,我们利用Point记录的编译器生成的解构器:

var p = new Point (2, 2);
Console.WriteLine (p is (2, 2));  // True

record Point (int X, int Y);

您可以在匹配时解构,使用以下语法:

Console.WriteLine (p is (var x, var y) && x == y);

这是一个将类型模式与位置模式结合的 switch 表达式:

string Print (object obj) => obj switch 
{
  Point (0, 0)                      => "Empty point",
  Point (var x, var y) when x == y  => "Diagonal"
  ...
};

属性模式

属性模式(C# 8+)匹配对象的一个或多个属性值:

if (obj is string { Length:4 }) ...

然而,这与以下方式并没有太大区别:

if (obj is string s && s.Length == 4) ...

对于 switch 语句和表达式,属性模式更有用。考虑System.Uri类,它表示一个 URI。它具有的属性包括SchemeHostPortIsLoopback。在编写防火墙时,我们可以通过使用使用属性模式的 switch 表达式来决定是否允许或阻止 URI:

bool ShouldAllow (Uri uri) => uri switch
{
  { Scheme: "http",  Port: 80  } => true,
  { Scheme: "https", Port: 443 } => true,
  { Scheme: "ftp",   Port: 21  } => true,
  { IsLoopback: true           } => true,
  _ => false
};

您可以嵌套属性,使以下子句合法:

  { Scheme: { Length: 4 }, Port: 80 } => true,

从 C# 10 开始,可以简化为:

  { Scheme.Length: 4, Port: 80 } => true,

您可以在属性模式中使用其他模式,包括关系模式:

  { Host: { Length: < 1000 }, Port: > 0 } => true,

你可以在子句末尾引入一个变量,然后在when子句中使用该变量:

  { Scheme: "http", Port: 80 } httpUri 
      when httpUri.Host.Length < 1000 => true,

您还可以在属性级别引入变量:

  { Scheme: "http", Port: 80, Host: var host }
      when host.Length < 1000 => true,

然而,在这种情况下,以下方式更短更简单:

  { Scheme: "http", Port: 80, Host: { Length: < 1000 } }

列表模式

列表模式(从 C# 11 开始)适用于任何可计数的集合类型(具有CountLength属性)和可索引的集合类型(具有intSystem.Index类型的索引器)。

列表模式匹配方括号中的一系列元素:

int[] numbers = { 0, 1, 2, 3, 4 };
Console.Write (numbers is [0, 1, 2, 3, 4]);   // True

下划线匹配任何值的单个元素:

Console.Write (numbers is [0, 1, _, _, 4]);   // True

var 模式也适用于匹配单个元素:

Console.Write (numbers is [0, 1, var x, 3, 4] && x > 1);

两个点表示一个切片。切片匹配零个或多个元素:

Console.Write (numbers is [0, .., 4]);    // True

对于支持索引和范围的数组和其他类型(参见“索引和范围”),您可以在切片后跟一个var模式:

Console.Write (numbers is [0, .. var mid, 4]
               && mid.Contains (2));           // True

列表模式最多可以包含一个切片。

第三十五章:LINQ

LINQ,即 Language Integrated Query,允许您在本地对象集合和远程数据源上编写结构化类型安全的查询。LINQ 允许您查询任何实现IEnumerable<>的集合,无论是数组、列表、XML DOM 还是远程数据源(如 SQL Server 中的表)。LINQ 提供了编译时类型检查和动态查询组合的好处。

注意

一个尝试 LINQ 的好方法是下载 LINQPad。LINQPad 允许您在 LINQ 中交互式查询本地集合和 SQL 数据库,无需任何设置,并预装有大量示例。

LINQ 基础知识

LINQ 中的基本数据单元是 序列元素。序列是实现通用 IEnumerable 接口的任何对象,而元素是序列中的每个项。在以下示例中,names 是一个序列,TomDickHarry 是元素:

string[] names = { "Tom", "Dick", "Harry" };

我们称这样的序列为本地序列,因为它代表内存中的本地对象集合。

查询操作符 是一种转换序列的方法。典型的查询操作符接受一个输入序列并发出一个转换后的输出序列。在 System.LinqEnumerable 类中,有大约 40 个查询操作符,都是作为静态扩展方法实现的。这些称为标准查询操作符

注意

LINQ 还支持可以动态从远程数据源(如 SQL Server)输入的序列。这些序列还实现了 IQueryable<> 接口,并通过 Queryable 类中的一组匹配的标准查询操作符进行支持。

简单查询

查询 是使用一个或多个查询操作符转换序列的表达式。最简单的查询包括一个输入序列和一个操作符。例如,我们可以在一个简单数组上应用 Where 操作符,以提取长度至少为四个字符的名称,如下所示:

string[] names = { "Tom", "Dick", "Harry" };

IEnumerable<string> filteredNames =
 System.Linq.Enumerable.Where (
 names, n => n.Length >= 4);

foreach (string n in filteredNames)
  Console.Write (n + "|");            // Dick|Harry|

因为标准查询操作符是作为扩展方法实现的,所以我们可以直接在 names 上调用 Where,就像它是一个实例方法一样:

IEnumerable<string> filteredNames =
  names.Where (n => n.Length >= 4);

(要使其编译通过,您必须使用 using 指令导入 System.Linq 命名空间。)System.Linq.Enumerable 中的 Where 方法具有以下签名:

static IEnumerable<TSource> Where<TSource> (
  this IEnumerable<TSource> source,
  Func<TSource,bool> predicate)

source输入序列predicate 是在每个输入元素上调用的委托。Where 方法包含所有委托返回 true输出序列中的元素。在内部,它是使用迭代器实现的——这是它的源代码:

foreach (TSource element in source)
  if (predicate (element))
    yield return element;

投影

另一个基本的查询操作符是 Select 方法。这使用给定的 lambda 表达式转换(投影)输入序列中的每个元素:

string[] names = { "Tom", "Dick", "Harry" };

IEnumerable<string> upperNames =
  names.Select (n => n.ToUpper());

foreach (string n in upperNames)
  Console.Write (n + "|");       // TOM|DICK|HARRY|

查询可以投影为匿名类型:

var query = names.Select (n => new { 
                                     Name = n,
                                     Length = n.Length
                                   });
foreach (var row in query)
  Console.WriteLine (row);

这是结果:

{ Name = Tom, Length = 3 }
{ Name = Dick, Length = 4 }
{ Name = Harry, Length = 5 }

Take 和 Skip

在 LINQ 中,输入序列中元素的原始顺序很重要。某些查询操作符依赖于此行为,如 TakeSkipReverseTake 操作符输出前 x 个元素,丢弃其余部分:

int[] numbers  = { 10, 9, 8, 7, 6 };
IEnumerable<int> firstThree = numbers.Take (3);
// firstThree is { 10, 9, 8 }

Skip 操作符忽略前 x 个元素,并输出其余部分:

IEnumerable<int> lastTwo = numbers.Skip (3);

从 .NET 6 开始,还有 TakeLastSkipLast 方法,分别取或跳过最后 n 个元素。此外,Take 方法已重载以接受 Range 变量。此重载可以包含所有四种方法的功能;例如,Take(5..) 等同于 Skip(5)Take(..⁵) 等同于 SkipLast(5)

元素操作符

并非所有查询操作符都返回序列。元素 操作符从输入序列中提取一个元素;例如 FirstLastSingleElementAt

int[] numbers    = { 10, 9, 8, 7, 6 };
int firstNumber  = numbers.First();                // 10
int lastNumber   = numbers.Last();                 // 6
int secondNumber = numbers.ElementAt (2);          // 8
int firstOddNum  = numbers.First (n => n%2 == 1);  // 9

如果没有元素存在,所有这些运算符都会抛出异常。要避免异常,请使用 FirstOrDefault, LastOrDefault, SingleOrDefaultElementAtOrDefault —— 当未找到元素时,它们返回 null(或值类型的 default 值)。

SingleSingleOrDefault 方法与 FirstFirstOrDefault 方法相同,除了如果有多个匹配项则抛出异常。在查询数据库表的主键时,这种行为非常有用。

从 .NET 6 开始,还有 MinByMaxBy 方法,根据键选择器返回具有最低或最高值的元素:

string[] names = { "Tom", "Dick", "Harry" };
Console.Write (names.MaxBy (n => n.Length));  // Harry

聚合运算符

聚合运算符返回一个标量值,通常是数值类型。最常用的聚合运算符是 Count, Min, MaxAverage

int[] numbers = { 10, 9, 8, 7, 6 };
int count     = numbers.Count();             // 5
int min       = numbers.Min();               // 6
int max       = numbers.Max();               // 10
double avg    = numbers.Average();           // 8

Count 接受一个可选的谓词,指示是否包括给定的元素。以下计算所有偶数的数量:

int evenNums = numbers.Count (n => n % 2 == 0);   // 3

Min, MaxAverage 运算符接受一个可选参数,用于在聚合之前转换每个元素:

int maxRemainderAfterDivBy5 = numbers.Max
                              (n => n % 5);       // 4

以下计算 numbers 的均方根:

double rms = Math.Sqrt (numbers.Average (n => n * n));

量词运算符

量词运算符返回一个 bool 值。量词运算符包括 Contains, Any, AllSequenceEquals(比较两个序列):

int[] numbers = { 10, 9, 8, 7, 6 };

bool hasTheNumberNine = numbers.Contains (9);    // true
bool hasMoreThanZeroElements = numbers.Any();    // true
bool hasOddNum = numbers.Any (n => n % 2 == 1);  // true
bool allOddNums = numbers.All (n => n % 2 == 1); // false

集合运算符

集合运算符接受两个相同类型的输入序列。Concat 将一个序列附加到另一个序列;Union 也是如此,但删除重复项:

int[] seq1 = { 1, 2, 3 }, seq2 = { 3, 4, 5 };

IEnumerable<int>
  concat = seq1.Concat (seq2),   // { 1, 2, 3, 3, 4, 5 }
  union  = seq1.Union  (seq2),   // { 1, 2, 3, 4, 5 }

此类别中的另外两个运算符是 IntersectExcept

IEnumerable<int>
  commonality = seq1.Intersect (seq2),    //  { 3 }
  difference1 = seq1.Except    (seq2),    //  { 1, 2 }
  difference2 = seq2.Except    (seq1);    //  { 4, 5 }

从 .NET 6 开始,还有使用键选择器的集合运算符 (UnionBy, ExceptBy, IntersectBy)。键选择器用于确定元素是否被视为重复项:

string[] seq1 = { "A", "b", "C" };
string[] seq2 = { "a", "B", "c" };
var union = seq1.UnionBy (seq2, x => x.ToUpper());
// union is { "A", "b", "C" }

延迟执行

许多查询运算符的重要特性是它们在构造时不执行,而是在枚举时执行(换句话说,在其枚举器上调用 MoveNext 时)。考虑以下查询:

var numbers = new List<int> { 1 };

IEnumerable<int> query = numbers.Select (n => n * 10); 
numbers.Add (2);    // Sneak in an extra element

foreach (int n in query)
  Console.Write (n + "|");          // 10|20|

我们在构造查询之后偷偷加入列表中的额外数字,因为直到 foreach 语句运行时才进行任何过滤或排序。这被称为延迟或惰性评估。延迟执行将查询的构造与执行分离,允许您在多个步骤中构造查询,还可以在不将所有行检索到客户端的情况下查询数据库。所有标准查询运算符都提供延迟执行,以下是例外:

  • 返回单个元素或标量值的运算符(元素运算符、聚合运算符和量词运算符)

  • 转换运算符 ToArray, ToList, ToDictionary, ToLookupToHashSet

转换运算符非常方便,部分原因在于它们避免了惰性求值。这在某些情况下很有用,可以在特定时间点“冻结”或缓存结果,避免重新执行计算密集或远程获取的查询,比如 Entity Framework 表。(惰性求值的副作用是,如果稍后重新枚举它,查询会被重新评估。)

以下示例说明了 ToList 运算符:

var numbers = new List<int>() { 1, 2 };

List<int> timesTen = numbers
  .Select (n => n * 10) 
 .ToList();    // Executes immediately into a List<int>

numbers.Clear();
Console.WriteLine (timesTen.Count);      // Still 2
警告

子查询提供了另一层间接引用。子查询中的所有内容都受延迟执行的影响,包括聚合和转换方法,因为子查询本身只有在需要时才会懒惰执行。假设 names 是一个字符串数组,子查询如下所示:

names.Where (
  n => n.Length ==
 names.Min (n2 => n2.Length))

标准查询运算符

我们可以将标准查询运算符(如在 System.Linq.Enumerable 类中实现)分为 12 个类别,如 Table 1 所总结的。

表 1. 查询运算符类别

类别描述延迟执行?
过滤返回满足给定条件的元素子集
投影使用 lambda 函数转换每个元素,可选地展开子序列
连接使用时间有效的查找策略将一个集合的元素与另一个集合的元素进行网格化
排序返回序列的重新排序
分组将序列分组为子序列
集合接受两个相同类型的序列,并返回它们的共同性、和或差异
元素从序列中选择单个元素
聚合对序列执行计算,返回一个标量值(通常是一个数值)
量化对序列执行计算,返回 truefalse
转换:导入将非泛型序列转换为(可查询的)泛型序列
转换:导出将序列转换为数组、列表、字典或查找,强制立即评估
生成制造一个简单的序列

表格 2 到 13 总结了每个查询运算符。在 C# 中,加粗显示的运算符具有特殊的支持(参见 “查询表达式”)。

表 2. 过滤运算符

方法描述
**Where**返回满足给定条件的元素子集
Take返回前 x 个元素,并丢弃其余元素
Skip忽略前 x 个元素,并返回其余元素
TakeLast返回最后 x 个元素,并丢弃其余元素
SkipLast忽略最后 x 个元素,并返回其余元素
TakeWhile发射输入序列中的元素,直到给定的谓词为真
SkipWhile忽略输入序列中的元素,直到给定的谓词为真,然后发射其余元素
Distinct, DistinctBy返回排除重复项的集合

表 3. 投影运算符

MethodDescription
**Select**使用给定的 Lambda 表达式转换每个输入元素
**SelectMany**转换每个输入元素,然后展平和连接生成的子序列

表 4. 连接运算符

MethodDescription
**Join**应用查找策略匹配来自两个集合的元素,并生成一个平坦的结果集
**GroupJoin**类似于上面的操作,但生成一个分层的结果集
Zip依次枚举两个序列,并返回一个应用函数到每一对元素上的序列

表 5. 排序运算符

MethodDescription
**OrderBy**, **ThenBy**返回按升序排列的元素
**OrderByDescending**, **ThenByDescending**返回按降序排列的元素
Reverse返回逆序排列的元素

表 6. 分组运算符

MethodDescription
**GroupBy**将序列分组为子序列
Chunk将序列分成给定大小的块

表 7. 集合运算符

MethodDescription
Concat连接两个序列
Union, UnionBy连接两个序列并移除重复元素
Intersect, IntersectBy返回两个序列中共有的元素
Except, ExceptBy返回只存在于第一个序列中而不在第二个序列中的元素

表 8. 元素运算符

MethodDescription
First, FirstOrDefault返回序列中的第一个元素,或满足给定条件的第一个元素
Last, LastOrDefault返回序列中的最后一个元素,或满足给定条件的最后一个元素
Single, SingleOrDefault等效于 First/FirstOrDefault,但如果有多个匹配则抛出异常
MinBy, MaxBy返回具有最小或最大值的元素,由键选择器确定
ElementAt, ElementAtOrDefault返回指定位置的元素
DefaultIfEmpty如果序列为空则返回一个包含单个值的序列,该值为 null 或 default(TSource)

表 9. 聚合运算符

MethodDescription
Count, LongCount返回输入序列中的元素总数,或满足给定条件的元素数量
Min, Max返回序列中的最小或最大元素
Sum, Average计算序列中元素的数值总和或平均值
Aggregate执行自定义聚合操作

表 10. 量词运算符

MethodDescription
Contains如果输入序列包含给定元素则返回 true
Any如果任何元素满足给定条件则返回 true
All如果所有元素都满足给定条件则返回 true
SequenceEqual如果第二个序列与输入序列具有相同的元素,则返回true

Table 11. 转换操作符(导入)

方法描述
OfTypeIEnumerable转换为IEnumerable<T>,丢弃类型不正确的元素
**Cast**IEnumerable转换为IEnumerable<T>,如果有任何类型不正确的元素则抛出异常

Table 12. 转换操作符(导出)

方法描述
ToArrayIEnumerable<T>转换为T[]
ToListIEnumerable<T>转换为List<T>
ToDictionaryIEnumerable<T>转换为Dictionary<TKey,TValue>
ToHashSetIEnumerable<T>转换为HashSet<T>
ToLookupIEnumerable<T>转换为ILookup<TKey,TElement>
AsEnumerable向下转换为IEnumerable<T>
AsQueryable强制转换或转换为IQueryable<T>

Table 13. 生成操作符

方法描述
Empty创建一个空序列
Repeat创建一个重复元素序列
Range创建一个整数序列

链式查询操作符

要构建更复杂的查询,您可以将查询操作符链接在一起。例如,以下查询提取所有包含字母a的字符串,按长度排序,然后将结果转换为大写:

string[] names = { "Tom","Dick","Harry","Mary","Jay" };

IEnumerable<string> query = names
  .Where   (n => n.Contains ("a"))
  .OrderBy (n => n.Length)
  .Select  (n => n.ToUpper());

foreach (string name in query)
  Console.Write (name + "|");

// RESULT: JAY|MARY|HARRY|

WhereOrderBySelect 都是标准查询操作符,对应于Enumerable类中的扩展方法。Where操作符发出输入序列的筛选版本,OrderBy发出其输入序列的排序版本,Select发出使用给定 Lambda 表达式进行变换或投影的序列(在本例中为n.ToUpper())。数据通过操作符链从左到右流动,因此首先进行过滤,然后排序,然后投影。最终结果类似于生产线上的传送带,如图 6 所示。

链式查询操作符

图 6. 链式查询操作符

操作符始终遵守延迟执行,因此直到实际枚举查询时才进行过滤、排序或投影。

查询表达式

到目前为止,我们已经通过调用Enumerable类中的扩展方法编写了查询。在本书中,我们将其描述为流畅语法。C#还提供了用于编写查询的特殊语言支持,称为查询表达式。以下是前述查询表达为查询表达式的示例:

IEnumerable<string> query =
  from n in names
  where n.Contains ("a")
  orderby n.Length
  select n.ToUpper();

查询表达式始终以from子句开始,并以selectgroup子句结束。from子句声明一个范围变量(在本例中为n),您可以将其视为遍历输入集合,类似于foreach。图 7 说明了完整的语法结构。

查询表达式语法

图 7. 查询表达式语法

如果您熟悉 SQL,LINQ 的查询表达式语法——从 from 子句开始,select 子句在最后——可能看起来很奇怪。实际上,查询表达式语法更为逻辑,因为子句 按执行顺序 出现。这使得 Visual Studio 在您键入时可以使用 IntelliSense 提示,并简化了子查询的作用域规则。

编译器通过将查询表达式转换为流畅语法来处理查询表达式。它以一种相当机械化的方式进行此操作,就像它将 foreach 语句转换为对 GetEnumeratorMoveNext 的调用一样:

IEnumerable<string> query = names
  .Where   (n => n.Contains ("a"))
  .OrderBy (n => n.Length)
  .Select  (n => n.ToUpper());

然后,WhereOrderBySelect 运算符将使用与在流畅语法中编写查询时相同的规则解析。在这种情况下,它们绑定到 Enumerable 类中的扩展方法(假设已导入了 System.Linq 命名空间),因为 names 实现了 IEnumerable<string>。然而,编译器在转换查询语法时并不专门偏爱 Enumerable 类。您可以将编译器视为在语句中机械地注入 WhereOrderBySelect 这些词,然后编译它,就像您自己键入方法名一样。这样可以灵活地解析它们——例如,Entity Framework 查询中的运算符则绑定到 Queryable 类中的扩展方法。

查询表达式与流畅查询

查询表达式和流畅查询各有其优点。

查询表达式仅支持查询运算符的一个小子集,即:

Where, Select, SelectMany
OrderBy, ThenBy, OrderByDescending, ThenByDescending
GroupBy, Join, GroupJoin

对于使用其他运算符的查询,您必须要么完全使用流畅语法编写,要么构建混合语法查询;例如:

string[] names = { "Tom","Dick","Harry","Mary","Jay" };

IEnumerable<string> query =
  from   n in names
  where  n.Length == names.Min (n2 => n2.Length)
  select n;

此查询返回与最短长度匹配的名称(“Tom” 和 “Jay”)。 子查询(粗体) 计算每个名称的最小长度,并计算为 3。我们需要使用流畅语法进行子查询,因为查询表达式语法中不支持 Min 运算符。但是,我们仍然可以在外部查询中使用查询语法。

查询语法的主要优势在于它可以极大地简化涉及以下内容的查询:

  • let 关键字用于在范围变量旁引入一个新变量。

  • 多个生成器(SelectMany)后跟外部范围变量引用

  • 等效于 JoinGroupJoin,然后是外部范围变量引用

let 关键字

let 关键字在范围变量旁引入一个新变量。例如,假设您想列出所有长度(去除元音后)大于两个字符的名称:

string[] names = { "Tom","Dick","Harry","Mary","Jay" };

IEnumerable<string> query =
  from n in names
 let vowelless = Regex.Replace (n, "[aeiou]", "")
  where vowelless.Length > 2
  orderby vowelless
  select n + " - " + vowelless;

枚举此查询的输出为:

Dick - Dck
Harry - Hrry
Mary - Mry

let 子句对每个元素执行计算,而不会丢失原始元素。在我们的查询中,后续子句(whereorderbyselect)可以访问 nvowelless。一个查询可以包括多个 let 子句,并且它们可以与额外的 wherejoin 子句交替使用。

编译器通过投影到一个包含原始和转换元素的临时匿名类型来转换let关键字:

IEnumerable<string> query = names
 .Select (n => new  
   {
     n = n, 
     vowelless = Regex.Replace (n, "[aeiou]", "")
   }
 )
 .Where (temp0 => (temp0.vowelless.Length > 2))
 .OrderBy (temp0 => temp0.vowelless)
 .Select (temp0 => ((temp0.n + " - ") + temp0.vowelless))

查询继续

如果您希望在selectgroup子句添加子句,必须使用into关键字来“继续”查询。例如:

from c in "The quick brown tiger".Split()
select c.ToUpper() into upper
where upper.StartsWith ("T")
select upper

// RESULT: "THE", "TIGER"

into子句后,前一个范围变量已经超出范围。

编译器简单地将带有into关键字的查询转换为更长的操作链:

"The quick brown tiger".Split()
  .Select (c => c.ToUpper())
  .Where (upper => upper.StartsWith ("T"))

(它省略了最后的Select(upper=>upper),因为它是多余的。)

多个生成器

查询可以包括多个生成器(from子句)。例如:

int[] numbers = { 1, 2, 3 };
string[] letters = { "a", "b" };

IEnumerable<string> query = from n in numbers
 from l in letters
                            select n.ToString() + l;

结果是一个交叉乘积,类似于嵌套的foreach循环:

"1a", "1b", "2a", "2b", "3a", "3b"

当查询中有多个from子句时,编译器会发出对SelectMany的调用:

IEnumerable<string> query = numbers.SelectMany (
 n => letters,
 (n, l) => (n.ToString() + l));

SelectMany执行嵌套循环。它枚举源集合(numbers)中的每个元素,并使用第一个 lambda 表达式(letters)转换每个元素。这生成一系列子序列,然后它枚举这些子序列。最终的输出元素由第二个 lambda 表达式(n.ToString()+l)确定。

如果随后应用了where子句,您可以过滤交叉乘积并投影出类似连接的结果:

string[] players = { "Tom", "Jay", "Mary" };

IEnumerable<string> query =
  from name1 in players
  from name2 in players
 where name1.CompareTo (name2) < 0
  orderby name1, name2
  select name1 + " vs " + name2;

RESULT: { "Jay vs Mary", "Jay vs Tom", "Mary vs Tom" }

将此查询转换为流畅语法更为复杂,需要一个临时匿名投影。自动执行此转换是查询表达式的关键优势之一。

第二个生成器中的表达式允许使用第一个范围变量:

string[] fullNames =
  { "Anne Williams", "John Fred Smith", "Sue Green" };

IEnumerable<string> query =
  from fullName in fullNames
  from name in fullName.Split()
  select name + " came from " + fullName;

Anne came from Anne Williams
Williams came from Anne Williams
John came from John Fred Smith

这有效,因为表达式fullName.Split生成一个序列(字符串数组)。

多个生成器在数据库查询中被广泛使用,以展开父子关系并执行手动连接。

连接

LINQ 提供了三个连接运算符,主要是JoinGroupJoin,它们执行基于键的查找连接。JoinGroupJoin仅支持多个生成器/SelectMany的部分功能,但在本地查询中更高效,因为它们使用基于哈希表的查找策略,而不是执行嵌套循环。(在 Entity Framework 查询中,连接运算符与多个生成器相比没有优势。)

JoinGroupJoin仅支持等连接(即连接条件必须使用等号操作符)。有两种方法:JoinGroupJoinJoin生成扁平的结果集,而GroupJoin生成分层结果集。

以下是用于平面连接的查询表达式语法:

from *outer-var* in *outer-sequence*
join *inner-var* in *inner-sequence* 
  on *outer-key-expr* equals *inner-key-expr*

例如,考虑以下集合:

var customers = new[]
{
  new { ID = 1, Name = "Tom" },
  new { ID = 2, Name = "Dick" },
  new { ID = 3, Name = "Harry" }
};
var purchases = new[]
{
  new { CustomerID = 1, Product = "House" },
  new { CustomerID = 2, Product = "Boat" },
  new { CustomerID = 2, Product = "Car" },
  new { CustomerID = 3, Product = "Holiday" }
};

我们可以执行如下的连接操作:

IEnumerable<string> query =
 from c in customers
 join p in purchases on c.ID equals p.CustomerID
  select c.Name + " bought a " + p.Product;

编译器将此转换为:

customers.Join (                // outer collection
  purchases,                    // inner collection
  c => c.ID,                    // outer key selector
  p => p.CustomerID,            // inner key selector
  (c, p) =>                     // result selector
     c.Name + " bought a " + p.Product 
);

这是结果:

Tom bought a House
Dick bought a Boat
Dick bought a Car
Harry bought a Holiday

对于本地序列,JoinGroupJoin在处理大型集合时比SelectMany更有效,因为它们首先将内部序列预加载到基于键的哈希表查找中。但是,通过数据库查询,您同样可以以以下方式同样高效地实现相同的结果:

from c in customers
from p in purchases
where c.ID == p.CustomerID
select c.Name + " bought a " + p.Product;

GroupJoin

GroupJoinJoin执行相同的工作,但不是产生一个平坦的结果,而是产生一个按每个外部元素分组的分层结果。

GroupJoin的查询表达式语法与Join相同,但后面跟着into关键字。以下是一个基本示例,使用我们在前一节设置的customerspurchases集合:

var query =
  from c in customers
  join p in purchases on c.ID equals p.CustomerID
 into custPurchases
  select custPurchases;   // custPurchases is a sequence
注意

into子句只有在直接跟在join子句之后时才会转换为GroupJoin。在selectgroup子句之后,它意味着查询继续into关键字的这两种用法非常不同,尽管它们有一个共同的特征:它们都引入了一个新的查询变量。

结果是一系列序列IEnumerable<IEnumerable<T>>,您可以如下枚举它们:

foreach (var purchaseSequence in query)
  foreach (var purchase in purchaseSequence)
    Console.WriteLine (purchase.Product);

然而,这并不是很有用,因为outerSeq没有引用外部的顾客。更常见的是,在投影中引用外部的范围变量:

from c in customers
join p in purchases on c.ID equals p.CustomerID
into custPurchases
select new { CustName = c.Name, custPurchases };

您可以通过投影到包含子查询的匿名类型来获得相同的结果(但对于本地查询来说效率较低):

from c in customers
select new
{
  CustName = c.Name,
  custPurchases = 
    purchases.Where (p => c.ID == p.CustomerID)
}

Zip

Zip是最简单的连接运算符。它以步骤方式枚举两个序列(像拉链一样),根据每个元素对应应用函数,从而得到一个基于以下函数的序列:

int[] numbers = { 3, 5, 7 };
string[] words = { "three", "five", "seven", "ignored" };
IEnumerable<string> zip = 
  numbers.Zip (words, (n, w) => n + "=" + w);

产生一个包含以下元素的序列:

3=three
5=five
7=seven

忽略任一输入序列中的额外元素。在查询数据库时不支持Zip

排序

orderby关键字对序列进行排序。您可以指定任意数量的表达式来进行排序:

string[] names = { "Tom","Dick","Harry","Mary","Jay" };

IEnumerable<string> query = from n in names
                            orderby n.Length, n
                            select n;

首先按长度排序,然后按名称排序,得到以下结果:

Jay, Tom, Dick, Mary, Harry

编译器将第一个orderby表达式转换为对OrderBy的调用,将后续表达式转换为对ThenBy的调用:

IEnumerable<string> query = names
  .OrderBy (n => n.Length)
  .ThenBy (n => n)

ThenBy操作符细化而不是替换先前的排序。

您可以在任何orderby表达式之后包含descending关键字:

orderby n.Length descending, n

这转换为以下内容:

.OrderByDescending (n => n.Length).ThenBy (n => n)
注意

排序操作符返回一个扩展类型的IEnumerable<T>称为IOrderedEnumerable<T>。此接口定义了ThenBy操作符所需的额外功能。

分组

GroupBy将一个平坦的输入序列组织成的序列。例如,以下根据它们的长度将一个名称序列分组:

string[] names = { "Tom","Dick","Harry","Mary","Jay" };

var query = from name in names
            group name by name.Length;

编译器将此查询转换为以下内容:

IEnumerable<IGrouping<int,string>> query = 
  names.GroupBy (name => name.Length);

下面是枚举结果的方法:

foreach (IGrouping<int,string> grouping in query)
{
  Console.Write ("\r\n Length=" + grouping.Key + ":");
  foreach (string name in grouping)
    Console.Write (" " + name);
}

 Length=3: Tom Jay
 Length=4: Dick Mary
 Length=5: Harry

Enumerable.GroupBy通过将输入元素读取到一个临时字典的列表中来工作,以便所有具有相同键的元素最终在同一个子列表中。然后它发出一个分组的序列。分组是具有Key属性的序列:

public interface IGrouping <TKey,TElement>
  : IEnumerable<TElement>, IEnumerable
{
  // Key applies to the subsequence as a whole
  TKey Key { get; }    
}

默认情况下,每个分组中的元素是未转换的输入元素,除非您指定了一个elementSelector参数。以下示例将每个输入元素投影为大写:

from name in names
group name.ToUpper() by name.Length

翻译为:

names.GroupBy (
  name => name.Length, 
  name => name.ToUpper() )

子集合不按键的顺序发出。GroupBy不进行排序(事实上,它保留原始顺序)。要排序,必须添加一个OrderBy运算符(这意味着首先添加一个into子句,因为通常group by结束一个查询):

from name in names
group name.ToUpper() by name.Length into grouping
orderby grouping.Key
select grouping

查询延续经常在group by查询中使用。下一个查询过滤掉那些正好有两个匹配项的组:

from name in names
group name.ToUpper() by name.Length into grouping
where grouping.Count() == 2
select grouping
注意

group by后的where等同于 SQL 中的HAVING。它适用于每个子序列或分组作为整体而不是单个元素。

OfTypeCast

OfTypeCast接受一个非泛型的IEnumerable集合,并发出一个泛型的IEnumerable<T>序列,随后您可以查询该序列:

var classicList = new System.Collections.ArrayList();
classicList.AddRange ( new int[] { 3, 4, 5 } );
IEnumerable<int> sequence1 = classicList.Cast<int>();

这很有用,因为它允许您查询在 C# 2.0 之前编写的集合(当时引入了IEnumerable<T>),比如System.Windows.Forms中的ControlCollection

CastOfType在遇到不兼容类型的输入元素时行为不同:Cast抛出异常,而OfType忽略不兼容的元素。

元素兼容性规则遵循 C#的is运算符的规则。这是Cast的内部实现:

public static IEnumerable<TSource> Cast <TSource>
             (IEnumerable source)
{
  foreach (object element in source)
    yield return (TSource)element;
}

C#支持查询表达式中的Cast运算符——只需在from关键字后立即插入元素类型:

from int x in classicList ...

这翻译成如下内容:

from x in classicList.Cast <int>() ...

第三十六章:动态绑定

动态绑定延迟了绑定—解析类型、成员和运算符的过程—从编译时到运行时。动态绑定在编译时知道某个函数、成员或运算符存在,但编译器不知道时非常有用。这通常发生在与动态语言(如 IronPython)和 COM 的互操作以及您可能使用反射的情况下。

动态类型通过使用上下文关键字dynamic声明:

dynamic d = GetSomeObject();
d.Quack();

动态类型指示编译器放松。我们期望d的运行时类型具有Quack方法。我们只是不能在静态上下文中证明它。因为d是动态的,编译器将Quack绑定到d直到运行时。理解这意味着需要区分静态绑定动态绑定

静态绑定与动态绑定

典型的绑定示例是在编译表达式时将名称映射到特定函数。要编译以下表达式,编译器需要找到名为Quack的方法的实现:

d.Quack();

假设d的静态类型是Duck

Duck d = ...
d.Quack();

在最简单的情况下,编译器通过查找Duck上名为Quack的无参方法来进行绑定。如果失败,编译器会扩展其搜索到带有可选参数的方法、Duck的基类方法以及以Duck作为第一个参数的扩展方法。如果找不到匹配项,你将会得到一个编译错误。不管绑定了什么方法,最终的结果是绑定是由编译器完成的,并且绑定完全依赖于静态知道操作数的类型(在这种情况下是d)。这就是静态绑定

现在让我们将d的静态类型改为object

object d = ...
d.Quack();

调用Quack会导致编译错误,因为尽管存储在d中的值可能包含名为Quack的方法,但编译器无法知道这一点,因为它仅仅知道变量的类型,而在这种情况下是object。但现在让我们将d的静态类型改为dynamic

dynamic d = ...
d.Quack();

dynamic类型就像object—它同样不描述类型。不同之处在于它允许你以编译时不知道的方式使用它。动态对象基于其运行时类型而不是编译时类型在运行时进行绑定。当编译器看到动态绑定表达式(通常是包含类型为dynamic的任何值的表达式),它只是打包表达式以便稍后在运行时进行绑定。

在运行时,如果动态对象实现了IDynamicMeta​Ob⁠jectProvider,那么这个接口将被用来执行绑定。如果没有,绑定几乎与编译器知道动态对象的运行时类型时的方式相同。这两种选择被称为自定义绑定语言绑定

自定义绑定

自定义绑定发生在一个动态对象实现了IDynamicMetaObjectProvider(IDMOP)时。虽然你可以在你用 C#编写的类型上实现 IDMOP,并且这样做很有用,但更常见的情况是你从在.NET 上实现的动态语言(如 IronPython 或 IronRuby)中获得了一个 IDMOP 对象。这些语言的对象隐式地实现了 IDMOP,作为直接控制在它们上执行的操作意义的一种手段。这里有一个简单的例子:

dynamic d = new Duck();
d.Quack();       // Quack was called
d.Waddle();      // Waddle was called

public class Duck : DynamicObject   // in System.Dynamic
{
  public override bool TryInvokeMember (
    InvokeMemberBinder binder, object[] args,
    out object result)
  {
    Console.WriteLine (binder.Name + " was called");
    result = null;
    return true;
  }
}

Duck类实际上没有Quack方法。相反,它使用自定义绑定来拦截和解释所有方法调用。我们在C# 12 in a Nutshell中详细讨论了自定义绑定器。

语言绑定

语言绑定发生在一个动态对象没有实现IDynamicMetaObjectProvider时。语言绑定在你处理.NET 类型系统中的不完美设计类型或固有限制时非常有用。例如,内置的数值类型不完美,因为它们没有共同的接口。我们已经看到方法可以动态绑定;对于运算符也是如此:

int x = 3, y = 4;
Console.WriteLine (Mean (x, y));

dynamic Mean (dynamic x, dynamic y) => (x+y) / 2;

这带来的好处很明显——您不需要为每种数字类型重复编写代码。但是,您失去了静态类型安全性,冒着运行时异常的风险,而不是编译时错误。

注意

动态绑定规避了静态类型安全性,但没有规避运行时类型安全性。与反射不同,您不能通过动态绑定规避成员可访问性规则。

设计上,语言运行时绑定的行为尽可能类似于静态绑定,如果动态对象的运行时类型在编译时已知,则程序行为与硬编码 Mean 以适配 int 类型的行为相同。静态绑定和动态绑定之间在一致性方面最显著的例外是扩展方法,我们在“不可调用函数”中讨论了此问题。

注意

动态绑定也会带来性能损失。但由于 DLR 的缓存机制,对相同动态表达式的重复调用进行了优化,允许您在循环中高效地调用动态表达式。这种优化使得在当今硬件上进行简单动态表达式的典型开销降低到少于 100 ns。

RuntimeBinderException

如果成员绑定失败,将抛出 RuntimeBinderException。您可以将其视为运行时的编译时错误:

dynamic d = 5;
d.Hello();       // throws RuntimeBinderException

抛出异常是因为 int 类型没有 Hello 方法。

动态的运行时表示

dynamicobject 类型之间存在深刻的等价性。运行时将以下表达式视为 true

typeof (dynamic) == typeof (object)

这个原则适用于构造类型和数组类型:

typeof (List<dynamic>) == typeof (List<object>)
typeof (dynamic[]) == typeof (object[])

类似于对象引用,动态引用可以指向任何类型的对象(除指针类型外):

dynamic x = "hello";
Console.WriteLine (x.GetType().Name);  // String

x = 123;  // No error (despite same variable)
Console.WriteLine (x.GetType().Name);  // Int32

结构上,对象引用和动态引用没有区别。动态引用简单地允许对其指向的对象进行动态操作。您可以从 object 转换为 dynamic,以在 object 上执行任何动态操作。

object o = new System.Text.StringBuilder();
dynamic d = o;
d.Append ("hello");
Console.WriteLine (o);   // hello

动态转换

dynamic 类型与所有其他类型都有隐式转换。为了成功转换,动态对象的运行时类型必须隐式可转换为目标静态类型。

以下示例抛出 RuntimeBinderException,因为 int 不能隐式转换为 short

int i = 7;
dynamic d = i;
long l = d;       // OK - implicit conversion works
short j = d;      // throws RuntimeBinderException

vardynamic 的区别很深。var编译器 推断类型。

vardynamic 类型在外表上相似,但其内在差异深远:

  • var 表示,“让 编译器 推断类型。”

  • dynamic 表示,“让 运行时 确定类型。”

举个例子:

dynamic x = "hello";  // Static type is dynamic
var y = "hello";      // Static type is string
int i = x;            // Runtime error
int j = y;            // Compile-time error

动态表达式

字段、属性、方法、事件、构造函数、索引器、操作符和转换都可以动态调用。

禁止使用 void 返回类型来消耗动态表达式的结果,就像使用静态类型表达式一样。不同之处在于错误发生在运行时。

通常涉及动态操作数的表达式本身也是动态的,因为缺少类型信息的影响是级联的:

dynamic x = 2;
var y = x * 3;       // Static type of y is dynamic

这个规则有几个明显的例外。首先,将动态表达式强制转换为静态类型会产生静态表达式。其次,构造函数调用始终产生静态表达式——即使使用动态参数调用时也是如此。

此外,还有一些边缘情况,在这些情况下,包含动态参数的表达式是静态的,包括将索引传递给数组和委托创建表达式。

动态成员重载解析

使用dynamic的经典用例涉及动态接收器。这意味着动态对象是动态函数调用的接收器:

dynamic x = ...;
x.Foo (123);          // x is the receiver

然而,动态绑定不限于接收器:方法参数也适用于动态绑定。使用动态参数调用函数的效果是将重载解析从编译时推迟到运行时:

static void Foo (int x)    => Console.WriteLine ("int");
static void Foo (string x) => Console.WriteLine ("str");

static void Main()
{
  dynamic x = 5;
  dynamic y = "watermelon";

  Foo (x);    // int
  Foo (y);    // str
}

运行时重载解析也称为多分派,在实现访问者等设计模式中非常有用。

如果没有涉及动态接收器,编译器可以静态地执行基本检查,以查看动态调用是否会成功:它检查是否存在一个名称和参数数目正确的函数。如果找不到候选项,则会得到编译时错误。

如果一个函数被调用时使用了动态和静态参数的混合方式,最终的方法选择将反映动态和静态绑定决策的混合:

static void X(object x, object y) =>Console.Write("oo");
static void X(object x, string y) =>Console.Write("os");
static void X(string x, object y) =>Console.Write("so");
static void X(string x, string y) =>Console.Write("ss");

static void Main()
{
  object o = "hello";
  dynamic d = "goodbye";
  X (o, d);               // os
}

X(o,d)的调用是动态绑定的,因为它的一个参数ddynamic类型。但因为o是静态已知的,尽管绑定是动态发生的,它将利用静态类型。在这种情况下,重载解析将由于o的静态类型和d的运行时类型而选择X的第二个实现。换句话说,编译器“尽可能静态”。

不可调用的函数

有些函数无法动态调用。以下情况下不能调用:

  • 扩展方法(通过扩展方法语法)

  • 通过接口的任何成员(通过接口)

  • 子类隐藏的基类成员

这是因为动态绑定需要两个信息:要调用的函数的名称,以及要在其上调用函数的对象。然而,在这三种不可调用的情况中,涉及到一个额外类型,这种类型只有在编译时才知道。并且没有办法动态指定这些额外的类型。

当调用扩展方法时,这个额外的类型是一个扩展类,根据源代码中的using指令(编译后消失)隐式选择。当通过接口调用成员时,可以通过隐式或显式转换来传递这个额外类型。(对于显式实现来说,实际上无法在不进行接口转换的情况下调用成员。)当调用隐藏基类成员时,必须通过转换或base关键字来指定额外的类型——并且该额外类型在运行时丢失。