C-9-和--NET5-高级教程-六-

105 阅读44分钟

C#9 和 .NET5 高级教程(六)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

十、集合和泛型

使用。NET 核心平台将需要处理在内存中维护和操作一组数据点的问题。这些数据点可以来自任何位置,包括关系数据库、本地文本文件、XML 文档、web 服务调用,或者用户提供的输入。

当。NET 平台首次发布时,程序员经常使用System.Collections名称空间的类来存储应用中使用的数据并与之交互。英寸在. NET 2.0 中,C# 编程语言被增强以支持一个被称为泛型的特性;随着这一变化,在基类库中引入了一个新的名称空间:System.Collections.Generic

本章将向您概述在中找到的各种集合(泛型和非泛型)命名空间和类型。NET 核心基本类库。正如您将看到的,泛型容器通常比非泛型容器更受青睐,因为它们通常提供更好的类型安全性和性能优势。在您学习了如何创建和操作框架中的泛型项之后,本章的剩余部分将研究如何构建您自己的泛型方法和泛型类型。当你这样做的时候,你将了解到约束(以及相应的 C# where关键字)的作用,它允许你构建非常类型安全的类。

集合类的动机

可以用来保存应用数据的最原始的容器无疑是数组。正如你在第四章中看到的,C# 数组允许你定义一组固定上限的相同类型的项(包括一个System.Object的数组,它本质上代表一个任何类型数据的数组)。还记得第四章中的所有 C# 数组变量都从System.Array类中收集了大量的功能。快速回顾一下,考虑下面的代码,它创建了一个文本数据数组,并以各种方式操作其内容:

// Make an array of string data.
string[] strArray = {"First", "Second", "Third" };

// Show number of items in array using Length property.
Console.WriteLine("This array has {0} items.", strArray.Length);
Console.WriteLine();

// Display contents using enumerator.
foreach (string s in strArray)
{
  Console.WriteLine("Array Entry: {0}", s);
}
Console.WriteLine();

// Reverse the array and print again.
Array.Reverse(strArray);
foreach (string s in strArray)
{
  Console.WriteLine("Array Entry: {0}", s);
}

Console.ReadLine();

虽然基本数组对于管理少量固定大小的数据很有用,但在很多其他情况下,您需要更灵活的数据结构,例如动态增长和收缩的容器,或者可以容纳仅满足特定标准的对象(例如,仅从特定基类派生的对象或仅实现特定接口的对象)的容器。当你使用一个简单的数组时,请记住它们是以“固定大小”创建的如果你做一个三项的数组,你只能得到三项;因此,下面的代码将导致一个运行时异常(确切地说是一个IndexOutOfRangeException):

// Make an array of string data.
string[] strArray = { "First", "Second", "Third" };

// Try to add a new item at the end?? Runtime error!
strArray[3] = "new item?";
...

Note

实际上可以使用通用的Resize()<T>方法来改变数组的大小。但是,这将导致数据复制到一个新的数组对象中,并且可能是低效的。

为了帮助克服简单数组的局限性。NET 核心基类库附带了许多包含集合类的名称空间。与简单的 C# 数组不同,当您插入或移除项时,集合类会动态调整自身大小。此外,许多集合类提供了更高的类型安全性,并经过高度优化,以内存高效的方式处理所包含的数据。当你阅读这一章时,你会很快注意到一个集合类可以属于两大类中的一类。

  • 非泛型集合(主要在System.Collections名称空间中)

  • 通用集合(主要在System.Collections.Generic名称空间中)

非泛型集合通常被设计为操作System.Object类型,因此是松散类型的容器(然而,一些非泛型集合确实只操作特定类型的数据,例如string对象)。相比之下,泛型集合更加类型安全,因为您必须在创建时指定它们包含的“类型的类型”。正如你将看到的,任何通用项目的指示符号是用尖括号标记的“类型参数”(例如,List<T>)。在本章的稍后部分,你将会研究泛型的细节(包括它们提供的许多好处)。现在,让我们研究一下System.CollectionsSystem.Collections.Specialized名称空间中的一些关键的非泛型集合类型。

系统。集合命名空间

当。NET 平台首次发布时,程序员经常使用在System.Collections名称空间中找到的非泛型集合类,它包含一组用于管理和组织大量内存数据的类。表 10-1 记录了这个名称空间的一些更常用的集合类和它们实现的核心接口。

表 10-1。

System.Collections的有用类型

|

系统。集合类

|

生命的意义

|

主要实现的接口

| | --- | --- | --- | | ArrayList | 表示按顺序列出的动态调整大小的对象集合 | IListICollectionIEnumerableICloneable | | BitArray | 管理以布尔值表示的位值的紧凑数组,其中 true 表示该位为开(1),false 表示该位为关(0) | ICollectionIEnumerableICloneable | | Hashtable | 表示基于键的哈希代码组织的键值对的集合 | IDictionaryICollectionIEnumerableICloneable | | Queue | 表示对象的标准先进先出(FIFO)集合 | ICollectionIEnumerableICloneable | | SortedList | 表示按键排序并可按键和索引访问的键-值对的集合 | IDictionaryICollectionIEnumerableICloneable | | Stack | 一种后进先出(LIFO)堆栈,提供推入和弹出(以及窥视)功能 | ICollectionIEnumerableICloneable |

这些集合类实现的接口提供了对其整体功能的深入了解。表 10-2 记录了这些关键接口的总体性质,其中一些你在第八章中直接使用过。

表 10-2。

System.Collections类支持的关键接口

|

系统。收集界面

|

生命的意义

| | --- | --- | | ICollection | 定义所有非泛型集合类型的一般特征(例如,大小、枚举和线程安全) | | ICloneable | 允许实现对象将自身的副本返回给调用方 | | IDictionary | 允许非泛型集合对象使用键值对来表示其内容 | | IEnumerable | 返回一个实现IEnumerator接口的对象(见下一个表项) | | IEnumerator | 启用集合项目的foreach样式迭代 | | IList | 提供在对象的顺序列表中添加、移除和索引项的行为 |

一个说明性的例子:使用数组列表

根据您的经验,您可能有一些使用(或实现)这些经典数据结构(如堆栈、队列和列表)的第一手经验。如果不是这样,当你在本章稍后检查它们的通用对应物时,我将提供一些关于它们的差异的进一步细节。在此之前,下面是使用ArrayList对象的示例代码:

// You must import System.Collections to access the ArrayList.
using System.Collections;
ArrayList strArray = new ArrayList();
strArray.AddRange(new string[] { "First", "Second", "Third" });

// Show number of items in ArrayList.
System.Console.WriteLine("This collection has {0} items.", strArray.Count);
System.Console.WriteLine();

// Add a new item and display current count.
strArray.Add("Fourth!");
System.Console.WriteLine("This collection has {0} items.", strArray.Count);

// Display contents.
foreach (string s in strArray)
{
  System.Console.WriteLine("Entry: {0}", s);
}
System.Console.WriteLine();

请注意,您可以动态地添加(或删除)项目,容器会相应地自动调整大小。

如您所料,ArrayList类除了Count属性、AddRange()Add()方法之外,还有许多有用的成员,所以请务必参考。NET 核心文档以了解全部详细信息。另外,System.Collections ( StackQueue)的其他等级。)中也有完整的记录。NET 核心帮助系统。

然而,重要的是要指出,你的大多数。NET 核心项目最有可能而不是利用System.Collections名称空间中的集合类!可以肯定的是,如今使用在System.Collections.Generic名称空间中找到的通用对应类要普遍得多。鉴于这一点,我不会对System.Collections中剩余的非泛型类进行评论(或提供代码示例)。

系统概述。集合。专用命名空间

System.Collections不是唯一的。包含非泛型集合类的. NET Core 命名空间。System.Collections.Specialized名称空间定义了许多(原谅冗余)专门化的集合类型。表 10-3 记录了这个特殊的以集合为中心的名称空间中一些更有用的类型,所有这些类型都是非泛型的。

表 10-3。

System.Collections.Specialized的有用类别

|

系统。集合。专用类型

|

生命的意义

| | --- | --- | | HybridDictionary | 这个类通过在集合很小时使用一个ListDictionary来实现IDictionary,然后在集合变大时切换到一个Hashtable。 | | ListDictionary | 当您需要管理少量(十个左右)会随时间变化的项目时,这个类非常有用。这个类使用一个单链表来维护它的数据。 | | StringCollection | 这个类提供了管理大型字符串数据集合的最佳方式。 | | BitVector32 | 这个类提供了一个简单的结构,在 32 位内存中存储布尔值和小整数。 |

除了这些具体的类类型之外,这个命名空间还包含许多附加的接口和抽象基类,您可以将它们用作创建自定义集合类的起点。虽然这些“专门化”类型可能正是您的项目在某些情况下所需要的,但是我不会在这里对它们的用法进行评论。同样,在许多情况下,您可能会发现,System.Collections.Generic名称空间提供了具有类似功能和额外好处的类。

Note

中有两个额外的以集合为中心的名称空间(System.Collections.ObjectModelSystem.Collections.Concurrent)。NET 核心基本类库。在熟悉了泛型的主题之后,你将在本章的后面检查前一个名称空间。System.Collections.Concurrent提供了非常适合多线程环境的集合类(见第十五章关于多线程的信息)。

非一般性收藏的问题

虽然许多成功人士。NET 和。NET 核心应用已经使用这些非泛型集合类(和接口)构建了多年,历史表明使用这些类型会导致许多问题。

第一个问题是使用System.CollectionsSystem.Collections.Specialized类会导致一些性能很差的代码,尤其是当你操作数字数据(例如值类型)的时候。正如您马上会看到的,当您在任何非泛型集合类中存储结构以对System.Object进行操作时,CoreCLR 必须执行大量内存转移操作,这会降低运行时执行速度。

第二个问题是,大多数非泛型集合类都不是类型安全的,因为(再次)它们是为在System.Object上操作而开发的,因此它们可以包含任何内容。如果开发人员需要创建一个高度类型安全的集合(例如,一个可以保存只实现某个接口的对象的容器),唯一真正的选择是手工创建一个新的集合类。这样做不需要太多的劳动,但是有点乏味。

在研究如何在程序中使用泛型之前,您会发现更仔细地研究一下非泛型集合类的问题是有帮助的;这将帮助你更好地理解泛型首先要解决的问题。如果您想继续操作,请创建一个名为 IssuesWithNonGenericCollections 的新控制台应用项目。接下来,确保将SystemSystem.Collections名称空间导入到Program.cs文件的顶部,并清除剩余的代码。

using System;
using System.Collections;

性能的问题

你可能还记得第四章。NET 核心平台支持两大类数据:值类型和引用类型。鉴于此。NET Core 定义了两个主要的类型类别,您可能偶尔需要将一个类别的变量表示为另一个类别的变量。为此,C# 提供了一个简单的机制,称为装箱,将数据存储在引用变量的值类型中。假设您已经在一个名为SimpleBoxUnboxOperation的方法中创建了一个类型为int的局部变量。如果在应用的过程中,您要将这个值类型表示为一个引用类型,那么您应该装箱这个值,如下所示:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;
}

装箱可以被正式定义为将值类型显式分配给一个System.Object变量的过程。当您装箱一个值时,CoreCLR 在堆上分配一个新对象,并将值类型的值(在本例中为25)复制到该实例中。返回给您的是对新分配的基于堆的对象的引用。

通过拆箱也可以进行相反的操作。取消装箱是将对象引用中保存的值转换回堆栈上相应值类型的过程。从语法上来说,拆箱操作看起来像普通的造型操作。然而,语义却大相径庭。CoreCLR 首先验证接收数据类型是否等同于 boxed 类型,如果是,它将值复制回一个基于堆栈的局部变量。例如,假设boxedInt的底层类型确实是一个int,下面的拆箱操作会成功:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;

  // Unbox the reference back into a corresponding int.
  int unboxedInt = (int)boxedInt;
}

当 C# 编译器遇到装箱/拆箱语法时,它会发出包含box / unbox操作码的 CIL 代码。如果您使用ildasm.exe检查您编译的程序集,您会发现如下内容:

.method assembly hidebysig static
    void  '<<Main>$>g__SimpleBoxUnboxOperation|0_0'() cil managed
{
  .maxstack  1
  .locals init (int32 V_0, object V_1, int32 V_2)
    IL_0000:  nop
    IL_0001:  ldc.i4.s   25
    IL_0003:  stloc.0
    IL_0004:  ldloc.0
    IL_0005:  box        [System.Runtime]System.Int32
    IL_000a:  stloc.1
    IL_000b:  ldloc.1
    IL_000c:  unbox.any  [System.Runtime]System.Int32
    IL_0011:  stloc.2
    IL_0012:  ret
  } // end of method '<Program>$'::'<<Main>$>g__SimpleBoxUnboxOperation|0_0'

请记住,与执行典型的强制转换不同,您必须取消装箱成适当的数据类型。如果您试图将一段数据拆箱为不正确的数据类型,将会抛出一个InvalidCastException异常。为了绝对安全,你应该用try / catch逻辑包装每个拆箱操作;然而,对于每个拆箱操作来说,这将是相当劳动密集型的。考虑下面的代码更新,它将抛出一个错误,因为您试图将装箱的int解装箱成一个long:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;

  // Unbox in the wrong data type to trigger
  // runtime exception.
  try
  {
    long unboxedLong = (long)boxedInt;
  }
  catch (InvalidCastException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

乍一看,装箱/取消装箱似乎是一个平淡无奇的语言特性,其学术性大于实用性。毕竟,您很少需要在本地object变量中存储本地值类型,如下所示。然而,事实证明装箱/拆箱过程非常有用,因为它允许您假设一切都可以被视为一个System.Object,而 CoreCLR 则代表您处理与内存相关的细节。

让我们看看这些技术的实际应用。我们将研究System.Collections.ArrayList类,并使用它保存一批数值(堆栈分配的)数据。下面列出了ArrayList类的相关成员。注意,它们的原型是对System.Object数据进行操作。现在考虑一下Add()Insert()Remove()方法,以及类索引器。

public class ArrayList : IList, ICloneable
{
...
  public virtual int Add(object? value);
  public virtual void Insert(int index, object? value);
  public virtual void Remove(object? obj);
  public virtual object? this[int index] {get; set; }
}

ArrayList已经被构建为在object上操作,这些表示在堆上分配的数据,因此下面的代码编译和执行时没有抛出错误可能看起来很奇怪:

static void WorkWithArrayList()
{
  // Value types are automatically boxed when
  // passed to a method requesting an object.
  ArrayList myInts = new ArrayList();
  myInts.Add(10);
  myInts.Add(20);
  myInts.Add(35);
}

尽管您将数字数据直接传递给需要一个object的方法,但运行时会自动为您将基于堆栈的数据装箱。稍后,如果您想使用类型索引器从ArrayList中检索一个项目,您必须使用一个转换操作将堆分配的对象拆箱为堆栈分配的整数。记住,ArrayList的索引器返回的是System.Object s,而不是System.Int32 s

static void WorkWithArrayList()
{
  // Value types are automatically boxed when
  // passed to a member requesting an object.
  ArrayList myInts = new ArrayList();
  myInts.Add(10);
  myInts.Add(20);
  myInts.Add(35);

  // Unboxing occurs when an object is converted back to
  // stack-based data.
  int i = (int)myInts[0];

  // Now it is reboxed, as WriteLine() requires object types!
  Console.WriteLine("Value of your int: {0}", i);
}

同样,请注意,堆栈分配的System.Int32在调用ArrayList.Add()之前被装箱,因此它可以在所需的System.Object中传递。还要注意的是,一旦通过转换操作从ArrayList中检索到System.Object,它就被解装箱回一个System.Int32,只有当它被传递给Console.WriteLine()方法时才被再次装箱*,因为这个方法是对System.Object变量进行操作的。*

*从程序员的角度来看,装箱和拆箱很方便,但是这种简化的堆栈/堆内存传输方法带来了性能问题(在执行速度和代码大小方面)和缺乏类型安全性。要理解性能问题,请考虑对一个简单整数进行装箱和拆箱时必须执行的以下步骤:

  1. 必须在托管堆上分配一个新对象。

  2. 基于堆栈的数据的值必须被传送到该存储器位置。

  3. 取消装箱时,存储在基于堆的对象上的值必须传输回堆栈。

  4. 堆上现在未使用的对象将(最终)被垃圾回收。

尽管这个特殊的WorkWithArrayList()方法不会导致性能方面的主要瓶颈,但是如果一个ArrayList包含了成千上万的整数,并且程序在一定程度上定期地对这些整数进行操作,那么您肯定会感觉到这种影响。在理想情况下,您可以在一个容器中操作基于堆栈的数据,而不会有任何性能问题。理想情况下,如果您不必费心使用try / catch范围从这个容器中提取数据就好了(这正是泛型让您实现的)。

类型安全的问题

在讨论拆箱操作时,我提到了类型安全的问题。回想一下,您必须将数据取消装箱为装箱前声明的相同数据类型。然而,在一个无泛型的世界里,你必须记住类型安全的另一个方面:事实上大多数的System.Collections类通常可以包含任何东西,因为它们的成员被原型化为在System.Object上操作。例如,这个方法构建了一个由不相关数据的随机比特组成的ArrayList:

static void ArrayListOfRandomObjects()
{
  // The ArrayList can hold anything at all.
  ArrayList allMyObjects = new ArrayList();
  allMyObjects.Add(true);
  allMyObjects.Add(new OperatingSystem(PlatformID.MacOSX, new Version(10, 0)));
  allMyObjects.Add(66);
  allMyObjects.Add(3.14);
}

在某些情况下,你会需要一个非常灵活的容器,几乎可以容纳任何东西(如此处所示)。然而,大多数时候你想要一个类型安全的容器,它只能在特定类型的数据点上操作。例如,您可能需要一个只能容纳数据库连接、位图或与IPointy兼容的对象的容器。

在泛型出现之前,解决类型安全问题的唯一方法是手动创建一个自定义(强类型)集合类。假设您想要创建一个自定义集合,它只能包含类型为Person的对象。

namespace IssuesWithNonGenericCollections
{
  public class Person
  {
    public int Age {get; set;}
    public string FirstName {get; set;}
    public string LastName {get; set;}

    public Person(){}
    public Person(string firstName, string lastName, int age)
    {
      Age = age;
      FirstName = firstName;
      LastName = lastName;
    }

    public override string ToString()
    {
      return $"Name: {FirstName} {LastName}, Age: {Age}";
    }
  }
}

要构建一个只能容纳Person对象的集合,可以在名为PersonCollection的类中定义一个System.Collections.ArrayList成员变量,并将所有成员配置为操作强类型Person对象,而不是操作System.Object类型。下面是一个简单的例子(产品级定制集合可以支持许多额外的成员,并且可能从System.CollectionsSystem.Collections.Specialized名称空间扩展一个抽象基类):

using System.Collections;
namespace IssuesWithNonGenericCollections
{
  public class PersonCollection : IEnumerable
  {
    private ArrayList arPeople = new ArrayList();

    // Cast for caller.
    public Person GetPerson(int pos) => (Person)arPeople[pos];

    // Insert only Person objects.
    public void AddPerson(Person p)
    {
      arPeople.Add(p);
    }

    public void ClearPeople()
    {
      arPeople.Clear();
    }

    public int Count => arPeople.Count;

    // Foreach enumeration support.
    IEnumerator IEnumerable.GetEnumerator() => arPeople.GetEnumerator();
  }
}

注意,PersonCollection类实现了IEnumerable接口,该接口允许对每个包含的项目进行类似于foreach的迭代。还要注意,您的GetPerson()AddPerson()方法已经被原型化,只对Person对象进行操作,而不是位图、字符串、数据库连接或其他项目。有了这些定义的类型,现在就可以确保类型安全,因为 C# 编译器将能够确定任何插入不兼容数据类型的尝试。将Program.cs中的using语句更新为以下内容,并将UserPersonCollection()方法添加到当前代码的末尾:

using System;
using System.Collections;
using IssuesWithNonGenericCollections;
//Top level statements in Program.cs
static void UsePersonCollection()
{
  Console.WriteLine("***** Custom Person Collection *****\n");
  PersonCollection myPeople = new PersonCollection();
  myPeople.AddPerson(new Person("Homer", "Simpson", 40));
  myPeople.AddPerson(new Person("Marge", "Simpson", 38));
  myPeople.AddPerson(new Person("Lisa", "Simpson", 9));
  myPeople.AddPerson(new Person("Bart", "Simpson", 7));
  myPeople.AddPerson(new Person("Maggie", "Simpson", 2));

  // This would be a compile-time error!
  // myPeople.AddPerson(new Car());

  foreach (Person p in myPeople)
  {
    Console.WriteLine(p);
  }
}

虽然自定义集合确实可以确保类型安全,但是这种方法使您必须为要包含的每个唯一数据类型创建一个(几乎相同的)自定义集合。因此,如果您需要一个只能在从Car基类派生的类上操作的定制集合,您需要构建一个高度相似的集合类。

using System.Collections;
public class CarCollection : IEnumerable
{
  private ArrayList arCars = new ArrayList();

  // Cast for caller.
  public Car GetCar(int pos) => (Car) arCars[pos];

  // Insert only Car objects.
  public void AddCar(Car c)
  {
    arCars.Add(c);
  }

  public void ClearCars()
  {
    arCars.Clear();
  }

  public int Count => arCars.Count;

  // Foreach enumeration support.
  IEnumerator IEnumerable.GetEnumerator() => arCars.GetEnumerator();
}

然而,自定义集合类并不能解决装箱/拆箱惩罚的问题。即使您要创建一个名为IntCollection的定制集合,并设计为只对System.Int32项进行操作,您也必须分配某种类型的对象来保存数据(例如,System.ArrayArrayList)。

using System.Collections;
public class IntCollection : IEnumerable
{
  private ArrayList arInts = new ArrayList();

  // Get an int (performs unboxing!).
  public int GetInt(int pos) => (int)arInts[pos];

  // Insert an int (performs boxing)!
  public void AddInt(int i)
  {
    arInts.Add(i);
  }

  public void ClearInts()
  {
    arInts.Clear();
  }

  public int Count => arInts.Count;

  IEnumerator IEnumerable.GetEnumerator() => arInts.GetEnumerator();
}

无论您选择哪种类型来保存整数,您都无法使用非泛型容器来摆脱装箱的困境。

通用集合初探

当您使用泛型集合类时,您纠正了所有以前的问题,包括装箱/取消装箱惩罚和缺乏类型安全性。此外,构建定制(通用)集合类的需求变得非常少。您可以使用一个通用集合类并指定类型的类型,而不必构建可以包含人、车和整数的唯一类。将下面的using语句添加到Program.cs类的顶部:

using System.Collections.Generic;

考虑下面的方法(添加到Program.cs的底部),它使用泛型List<T>类(在System.Collections.Generic名称空间中)以强类型的方式包含各种类型的数据(此时不要担心泛型语法的细节):

static void UseGenericList()
{
  Console.WriteLine("***** Fun with Generics *****\n");

  // This List<> can hold only Person objects.
  List<Person> morePeople = new List<Person>();
  morePeople.Add(new Person ("Frank", "Black", 50));
  Console.WriteLine(morePeople[0]);

  // This List<> can hold only integers.
  List<int> moreInts = new List<int>();
  moreInts.Add(10);
  moreInts.Add(2);
  int sum = moreInts[0] + moreInts[1];

  // Compile-time error! Can't add Person object
  // to a list of ints!
  // moreInts.Add(new Person());
}

第一个List<T>对象只能包含Person对象。因此,当从容器中提取项时,不需要执行强制转换,这使得这种方法更加类型安全。第二个List<T>只能包含整数,全部分配在堆栈上;换句话说,不存在您在非泛型ArrayList中发现的隐藏装箱或取消装箱。下面是泛型容器相对于非泛型容器的优势列表:

  • 泛型提供了更好的性能,因为它们在存储值类型时不会导致装箱或取消装箱的损失。

  • 泛型是类型安全的,因为它们只能包含您指定的类型。

  • 泛型极大地减少了构建自定义集合类型的需要,因为您在创建泛型容器时指定了“类型的类型”。

泛型类型参数的作用

中可以找到泛型类、接口、结构和委托。NET 核心基础类库,这些可能是任何。NET 核心命名空间。还要注意,泛型的用途远不止定义一个集合类。当然,出于各种原因,你会在本书的剩余部分看到许多不同的泛型。

Note

只有类、结构、接口和委托可以通用地编写;枚举类型不能。

当您看到列在。NET 核心文档或 Visual Studio 对象浏览器,您会注意到一对尖括号,中间夹着一个字母或其他标记。图 10-1 显示了 Visual Studio 对象浏览器显示了位于System.Collections.Generic名称空间内的许多通用项,包括突出显示的List<T>类。

img/340876_10_En_10_Fig1_HTML.jpg

图 10-1。

支持类型参数的一般项

正式来说,你把这些令牌叫做类型参数;然而,用更加用户友好的术语来说,你可以简单地称它们为占位符。你可以把符号<T>读作“of T”。因此,你可以把IEnumerable<T>读作“T 的IEnumerable”或者换一种说法,“T 型的IEnumerable

Note

类型参数(占位符)的名称无关紧要,这取决于创建泛型项的开发人员。但是,通常 T 用于表示类型, TKeyK 用于键, TValueV 用于值。

当创建泛型对象、实现泛型接口或调用泛型成员时,由您来决定是否向类型参数提供值。在这一章和正文的其余部分,你会看到许多例子。然而,为了做好准备,让我们看看与泛型类型和成员交互的基础知识。

为泛型类/结构指定类型参数

创建泛型类或结构的实例时,在声明变量和调用构造函数时指定类型参数。正如您在前面的代码示例中看到的,UseGenericList()定义了两个List<T>对象。

// This List<> can hold only Person objects.
List<Person> morePeople = new List<Person>();
// This List<> can hold only integers.
List<int> moreInts = new List<int>();

您可以将前面代码片段中的第一行理解为“a List<> of T,其中T属于类型Person或者,更简单地说,你可以把它理解为“一个人对象的列表”指定了泛型项的类型参数之后,就不能再更改了(记住,泛型都是关于类型安全的)。当您为泛型类或结构指定类型参数时,所有出现的占位符现在都将替换为您提供的值。

如果您要使用 Visual Studio 对象浏览器查看泛型List<T>类的完整声明,您将会看到占位符T贯穿于List<T>类型的定义中。以下是部分清单:

// A partial listing of the List<T> class.
namespace System.Collections.Generic
{
  public class List<T> : IList<T>, IList, IReadOnlyList<T>
  {
...
    public void Add(T item);
    public void AddRange(IEnumerable<T> collection);
    public ReadOnlyCollection<T> AsReadOnly();
    public int BinarySearch(T item);
    public bool Contains(T item);
    public void CopyTo(T[] array);
    public int FindIndex(System.Predicate<T> match);
    public T FindLast(System.Predicate<T> match);
    public bool Remove(T item);
    public int RemoveAll(System.Predicate<T> match);
    public T[] ToArray();
    public bool TrueForAll(System.Predicate<T> match);
    public T this[int index] { get; set; }
  }
}

当你创建一个指定Person对象的List<T>时,就好像List<T>类型被定义如下:

namespace System.Collections.Generic
{
  public class List<Person>
    : IList<Person>, IList, IReadOnlyList<Person>
  {
...
    public void Add(Person item);
    public void AddRange(IEnumerable<Person> collection);
    public ReadOnlyCollection<Person> AsReadOnly();
    public int BinarySearch(Person item);
    public bool Contains(Person item);
    public void CopyTo(Person[] array);
    public int FindIndex(System.Predicate<Person> match);
    public Person FindLast(System.Predicate<Person> match);
    public bool Remove(Person item);
    public int RemoveAll(System.Predicate<Person> match);
    public Person[] ToArray();
    public bool TrueForAll(System.Predicate<Person> match);
    public Person this[int index] { get; set; }
  }
}

当然,当您创建一个通用的List<T>变量时,编译器并不会真的创建一个List<T>类的新实现。相反,它将只处理您实际调用的泛型类型的成员。

为泛型成员指定类型参数

非泛型类或结构可以支持泛型属性。在这些情况下,您还需要在调用方法时指定占位符值。例如,System.Array支持几种通用方法。具体来说,非泛型静态Sort()方法现在有了一个名为Sort<T>()的泛型对应方法。考虑下面的代码片段,其中T的类型是int:

int[] myInts = { 10, 4, 2, 33, 93 };

// Specify the placeholder to the generic
// Sort<>() method.
Array.Sort<int>(myInts);

foreach (int i in myInts)
{
  Console.WriteLine(i);
}

为泛型接口指定类型参数

当您构建需要支持各种框架行为(例如,克隆、排序和枚举)的类或结构时,通常会实现泛型接口。在第八章中,你学习了一些非通用接口,比如IComparableIEnumerableIEnumeratorIComparer。回想一下,非泛型IComparable接口是这样定义的:

public interface IComparable
{
  int CompareTo(object obj);
}

在第八章中,你也在你的Car类中实现了这个接口来支持标准数组中的排序。然而,代码需要几次运行时检查和转换操作,因为参数是一个通用的System.Object

public class Car : IComparable
{
...
  // IComparable implementation.
  int IComparable.CompareTo(object obj)
  {
    if (obj is Car temp)
    {
      return this.CarID.CompareTo(temp.CarID);
    }
    throw new ArgumentException("Parameter is not a Car!");
  }
}

现在假设您使用这个接口的通用对应物。

public interface IComparable<T>
{
  int CompareTo(T obj);
}

在这种情况下,您的实现代码将被大大清理。

public class Car : IComparable<Car>
{
...
  // IComparable<T> implementation.
  int IComparable<Car>.CompareTo(Car obj)
  {
    if (this.CarID > obj.CarID)
    {
      return 1;
    }
    if (this.CarID < obj.CarID)
    {
      return -1;
    }
    return 0;
  }
}

这里,你不需要检查传入的参数是否是一个Car,因为它只能只有是一个Car!如果有人传入不兼容的数据类型,您会得到一个编译时错误。现在您已经更好地掌握了如何与通用项交互,以及类型参数(也称为占位符)的角色,您已经准备好检查System.Collections.Generic名称空间的类和接口了。

系统。Collections .泛型命名空间

当您正在构建一个. NET 核心应用,并且需要一种方法来管理内存中的数据时,System.Collections.Generic类最有可能满足您的需求。在这一章的开始,我简要地提到了一些由非泛型集合类实现的核心非泛型接口。毫不奇怪,System.Collections.Generic名称空间为它们中的许多定义了通用替换。

事实上,您可以找到许多扩展其非泛型对应物的泛型接口。这可能看起来很奇怪;然而,通过这样做,实现类也将支持在它们的非泛型兄弟中找到的遗留功能。比如IEnumerable<T>扩展IEnumerable。表 10-4 记录了你在使用通用集合类时会遇到的核心通用接口。

表 10-4。

System.Collections.Generic类支持的关键接口

|

系统。集合.通用接口

|

生命的意义

| | --- | --- | | ICollection<T> | 定义所有泛型集合类型的一般特征(例如,大小、枚举和线程安全)。 | | IComparer<T> | 定义一种与对象进行比较的方式。 | | IDictionary<TKey, TValue> | 允许泛型集合对象使用键值对来表示其内容。 | | IEnumerable<T>/IAsyncEnumerable<T> | 返回给定对象的IEnumerator<T>接口。IAsyncEnumerable(C # 8.0 中的新功能)包含在第十五章中。 | | IEnumerator<T> | 对一般集合启用foreach样式的迭代。 | | IList<T> | 提供在对象的顺序列表中添加、移除和索引项的行为。 | | ISet<T> | 为集合的抽象提供基本接口。 |

名称空间还定义了几个实现这些关键接口的类。表 10-5 描述了这个名称空间的一些常用类,它们实现的接口,以及它们的基本功能。

表 10-5。

System.Collections.Generic的类别

|

通用类

|

支持的关键接口

|

生命的意义

| | --- | --- | --- | | Dictionary<TKey, TValue> | ICollection<T>IDictionary<TKey, TValue>IEnumerable<T> | 这表示键和值的一般集合。 | | LinkedList<T> | ICollection<T>IEnumerable<T> | 这代表了一个双向链表。 | | List<T> | ICollection<T>IEnumerable<T>IList<T> | 这是一个可动态调整大小的项目顺序列表。 | | Queue<T> | ICollection(不是错别字!这是非泛型集合接口。),IEnumerable<T> | 这是先进先出列表的一般实现。 | | SortedDictionary<TKey, TValue> | ICollection<T>IDictionary<TKey, TValue>IEnumerable<T> | 这是一组排序的键值对的一般实现。 | | SortedSet<T> | ICollection<T>IEnumerable<T>ISet<T> | 这表示对象的集合,这些对象按排序顺序维护,没有重复。 | | Stack<T> | ICollection(不是错别字!这是非泛型集合接口。),IEnumerable<T> | 这是后进先出列表的一般实现。 |

System.Collections.Generic名称空间还定义了许多与特定容器协同工作的辅助类和结构。例如,LinkedListNode<T>类型表示泛型LinkedList<T>中的一个节点,当试图使用不存在的键从容器中获取一个项目时会引发KeyNotFoundException异常,等等。请务必查阅。NET 核心文档来获得关于System.Collections.Generic名称空间的全部细节。

无论如何,您的下一个任务是学习如何使用这些通用集合类。但是,在此之前,请允许我举例说明 C# 语言的一个特性(首先在。NET 3.5),它简化了用数据填充通用(和非通用)收集容器的方式。

了解集合初始化语法

在第四章中,你学习了对象初始化语法,它允许你在构造的时候设置一个新变量的属性。与此密切相关的是集合初始化语法。C# 语言的这一特性使得通过使用与填充基本数组类似的语法来用项目填充许多容器(如ArrayListList<T>)成为可能。创建新的。名为 funwithcollectioninitial ization 的. NET 核心控制台应用。清除Program.cs中生成的代码,添加以下using语句:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;

Note

您只能将集合初始化语法应用于支持Add()方法的类,该方法由ICollection<T> / ICollection接口形式化。

考虑下面的例子:

// Init a standard array.
int[] myArrayOfInts = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

// Init a generic List<> of ints.
List<int> myGenericList = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

// Init an ArrayList with numerical data.
ArrayList myList = new ArrayList { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

如果您的容器正在管理一个类集合或一个结构,您可以将对象初始化语法与集合初始化语法结合起来,以生成一些功能代码。您可能还记得第五章中的Point类,它定义了两个名为XY的属性。如果您想构建一个通用的Point对象的List<T>,您可以编写如下代码:

List<Point> myListOfPoints = new List<Point>
{
  new Point { X = 2, Y = 2 },
  new Point { X = 3, Y = 3 },
  new Point { X = 4, Y = 4 }
};

foreach (var pt in myListOfPoints)
{
  Console.WriteLine(pt);
}

同样,这种语法的好处是您可以节省大量的击键次数。如果您不介意格式,嵌套的花括号可能会变得难以阅读,想象一下如果您没有集合初始化语法,填充下面的List<T> of Rectangle将需要多少代码。

List<Rectangle> myListOfRects = new List<Rectangle>
{
  new Rectangle {
    Height = 90, Width = 90,
    Location = new Point { X = 10, Y = 10 }},
  new Rectangle {
    Height = 50,Width = 50,
    Location = new Point { X = 2, Y = 2 }},
};
foreach (var r in myListOfRects)
{
  Console.WriteLine(r);
}

使用列表类

创建一个名为 FunWithGenericCollections 的新控制台应用项目。添加一个新文件,命名为Person.cs,并添加以下代码(与之前的Person类代码相同):

namespace FunWithGenericCollections
{
  public class Person
  {
    public int Age {get; set;}
    public string FirstName {get; set;}
    public string LastName {get; set;}

    public Person(){}
    public Person(string firstName, string lastName, int age)
    {
      Age = age;
      FirstName = firstName;
      LastName = lastName;
    }

    public override string ToString()
    {
      return $"Name: {FirstName} {LastName}, Age: {Age}";
    }
  }
}

清除Program.cs中生成的代码,添加以下using语句:

using System;
using System.Collections.Generic;
using FunWithGenericCollections;

您将研究的第一个泛型类是List<T>,您已经在本章中见过一两次了。在System.Collections.Generic命名空间中,List<T>类肯定是您最常用的类型,因为它允许您动态地调整容器内容的大小。为了说明这种类型的基本原理,考虑一下您的Program类中的以下方法,它利用List<T>来操作本章前面显示的一组Person对象;您可能还记得这些Person对象定义了三个属性(AgeFirstNameLastName)和一个定制的ToString()实现:

static void UseGenericList()
{
  // Make a List of Person objects, filled with
  // collection/object init syntax.
  List<Person> people = new List<Person>()
  {
    new Person {FirstName= "Homer", LastName="Simpson", Age=47},
    new Person {FirstName= "Marge", LastName="Simpson", Age=45},
    new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
    new Person {FirstName= "Bart", LastName="Simpson", Age=8}
  };

  // Print out # of items in List.
  Console.WriteLine("Items in list: {0}", people.Count);

  // Enumerate over list.
  foreach (Person p in people)
  {
    Console.WriteLine(p);
  }

  // Insert a new person.
  Console.WriteLine("\n->Inserting new person.");
  people.Insert(2, new Person { FirstName = "Maggie", LastName = "Simpson", Age = 2 });
  Console.WriteLine("Items in list: {0}", people.Count);

  // Copy data into a new array.
  Person[] arrayOfPeople = people.ToArray();
  foreach (Person p in arrayOfPeople)
  {
    Console.WriteLine("First Names: {0}", p.FirstName);
  }
}

这里,您使用集合初始化语法用对象填充您的List<T>,作为多次调用Add()的简写符号。在打印出集合中的条目数量(以及枚举每个条目)之后,调用Insert()。如您所见,Insert()允许您在指定的索引处将一个新项目插入到List<T>中。

*最后,注意对ToArray()方法的调用,它基于原始List<T>的内容返回一个Person对象的数组。从此数组中,使用数组的索引器语法再次循环遍历这些项。如果从顶级语句中调用此方法,将得到以下输出:

***** Fun with Generic Collections *****
Items in list: 4
Name: Homer Simpson, Age: 47
Name: Marge Simpson, Age: 45
Name: Lisa Simpson, Age: 9
Name: Bart Simpson, Age: 8

->Inserting new person.
Items in list: 5
First Names: Homer
First Names: Marge
First Names: Maggie
First Names: Lisa
First Names: Bart

List<T>类定义了许多感兴趣的额外成员,所以请务必查阅文档以获得更多信息。接下来,让我们看看几个更通用的集合,具体来说就是Stack<T>Queue<T>SortedSet<T>。这将使您能够很好地理解关于如何保存自定义应用数据的基本选择。

使用堆栈类

Stack<T>类表示使用后进先出方式维护项目的集合。如您所料,Stack<T>定义了名为Push()Pop()的成员来将项目放入堆栈或从堆栈中移除项目。下面的方法创建了一个Person对象的堆栈:

static void UseGenericStack()
{
  Stack<Person> stackOfPeople = new();
  stackOfPeople.Push(new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 });
  stackOfPeople.Push(new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 });
  stackOfPeople.Push(new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });

  // Now look at the top item, pop it, and look again.
  Console.WriteLine("First person is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  Console.WriteLine("\nFirst person is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  Console.WriteLine("\nFirst person item is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());

  try
  {
    Console.WriteLine("\nnFirst person is: {0}", stackOfPeople.Peek());
    Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  }
  catch (InvalidOperationException ex)
  {
    Console.WriteLine("\nError! {0}", ex.Message);
  }
}

在这里,您构建了一个包含三个人的堆栈,按照他们名字的顺序添加:Homer、Marge 和 Lisa。当你窥视堆栈时,你总是首先看到顶部的对象;因此,对Peek()的第一次调用揭示了第三个Person对象。在一系列的Pop()Peek()调用之后,堆栈最终清空,此时额外的Peek()Pop()调用引发一个系统异常。您可以在这里看到它的输出:

***** Fun with Generic Collections *****
First person is: Name: Lisa Simpson, Age: 9
Popped off Name: Lisa Simpson, Age: 9

First person is: Name: Marge Simpson, Age: 45
Popped off Name: Marge Simpson, Age: 45

First person item is: Name: Homer Simpson, Age: 47
Popped off Name: Homer Simpson, Age: 47

Error! Stack empty

.

使用队列类

队列是确保以先进先出的方式访问项目的容器。可悲的是,我们人类整天都在排队:在银行排队,在电影院排队,在早晨的咖啡馆排队。当您需要建立一个场景模型,在这个场景中,项目是按照先来先服务的原则处理的,您会发现Queue<T>类符合这个要求。除了被支持的接口所提供的功能外,Queue还定义了表 10-6 中所示的关键成员。

表 10-6。

Queue<T>类型的成员

|

选择队列成员

|

生命的意义

| | --- | --- | | Dequeue() | 移除并返回Queue<T>开头的对象 | | Enqueue() | 将一个对象添加到Queue<T>的末尾 | | Peek() | 返回Queue<T>开头的对象,但不删除它 |

现在让我们将这些方法付诸实践。您可以再次利用您的Person类,构建一个Queue<T>对象来模拟排队等候点咖啡的人群。

static void UseGenericQueue()
{
  // Make a Q with three people.
  Queue<Person> peopleQ = new();
  peopleQ.Enqueue(new Person {FirstName= "Homer", LastName="Simpson", Age=47});
  peopleQ.Enqueue(new Person {FirstName= "Marge", LastName="Simpson", Age=45});
  peopleQ.Enqueue(new Person {FirstName= "Lisa", LastName="Simpson", Age=9});

  // Peek at first person in Q.
  Console.WriteLine("{0} is first in line!", peopleQ.Peek().FirstName);

  // Remove each person from Q.
  GetCoffee(peopleQ.Dequeue());
  GetCoffee(peopleQ.Dequeue());
  GetCoffee(peopleQ.Dequeue());
  // Try to de-Q again?
  try
  {
    GetCoffee(peopleQ.Dequeue());
  }
  catch(InvalidOperationException e)
  {
    Console.WriteLine("Error! {0}", e.Message);
  }
  //Local helper function
  static void GetCoffee(Person p)
  {
    Console.WriteLine("{0} got coffee!", p.FirstName);
  }
}

这里,您使用Enqueue()方法将三个项目插入到Queue<T>类中。对Peek()的调用允许您查看(但不能删除)当前在Queue中的第一个项目。最后,对Dequeue()的调用从行中删除项目,并将其发送到GetCoffee()辅助函数进行处理。请注意,如果试图从空队列中移除项,将会引发运行时异常。以下是调用此方法时收到的输出:

***** Fun with Generic Collections *****
Homer is first in line!
Homer got coffee!
Marge got coffee!
Lisa got coffee!
Error! Queue empty.

使用 SortedSet 类

SortedSet<T>类很有用,因为它能自动确保在插入或删除项目时对集合中的项目进行排序。然而,你确实需要通过传入一个实现通用IComparer<T>接口的对象作为构造函数参数,准确地通知SortedSet<T>你希望它如何排序对象。

首先创建一个名为SortPeopleByAge的新类,它实现了IComparer<T>,其中T的类型是Person。回想一下,这个接口定义了一个名为Compare()的方法,在这个方法中,您可以编写任何需要进行比较的逻辑。下面是该类的一个简单实现:

using System.Collections.Generic;

namespace FunWithGenericCollections
{
  class SortPeopleByAge : IComparer<Person>
  {
    public int Compare(Person firstPerson, Person secondPerson)
    {
      if (firstPerson?.Age > secondPerson?.Age)
      {
          return 1;
      }
      if (firstPerson?.Age < secondPerson?.Age)
      {
        return -1;
      }
      return 0;
    }
  }
}

现在添加以下演示使用SortedSet<Person>的新方法:

static void UseSortedSet()
{
  // Make some people with different ages.
  SortedSet<Person> setOfPeople = new SortedSet<Person>(new SortPeopleByAge())
  {
    new Person {FirstName= "Homer", LastName="Simpson", Age=47},
    new Person {FirstName= "Marge", LastName="Simpson", Age=45},
    new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
    new Person {FirstName= "Bart", LastName="Simpson", Age=8}
  };

  // Note the items are sorted by age!
  foreach (Person p in setOfPeople)
  {
    Console.WriteLine(p);
  }
    Console.WriteLine();

  // Add a few new people, with various ages.
  setOfPeople.Add(new Person { FirstName = "Saku", LastName = "Jones", Age = 1 });
  setOfPeople.Add(new Person { FirstName = "Mikko", LastName = "Jones", Age = 32 });

  // Still sorted by age!
  foreach (Person p in setOfPeople)
  {
    Console.WriteLine(p);
  }
}

当您运行应用时,对象列表现在总是基于Age属性的值进行排序,而不管您插入或移除对象的顺序。

***** Fun with Generic Collections *****
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47

Name: Saku Jones, Age: 1
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Mikko Jones, Age: 32
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47

使用字典类

另一个方便的泛型集合是Dictionary<TKey,TValue>类型,它允许您保存任意数量的对象,这些对象可以通过一个惟一的键来引用。因此,您可以使用唯一的文本键(例如,“给我第二个对象”),而不是使用数字标识符从List<T>获取项目(例如,“给我我键入为 Homer 的对象”)。

像其他集合对象一样,您可以通过手动调用通用的Add()方法来填充一个Dictionary<TKey,TValue>。但是,您也可以使用集合初始化语法填充一个Dictionary<TKey,TValue>。请注意,在填充这个集合对象时,键名必须是唯一的。如果多次错误地指定了同一个键,将会收到运行时异常。

考虑以下用各种对象填充Dictionary<K,V>的方法。注意,当您创建Dictionary<TKey,TValue>对象时,您指定键类型(TKey)和底层对象类型(TValue)作为构造函数参数。在这个例子中,您使用一个string数据类型作为键,使用一个Person类型作为值。另请注意,您可以将对象初始化语法与集合初始化语法结合使用。

private static void UseDictionary()
{
    // Populate using Add() method
    Dictionary<string, Person> peopleA = new Dictionary<string, Person>();
    peopleA.Add("Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 });
    peopleA.Add("Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 });
    peopleA.Add("Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });

    // Get Homer.
    Person homer = peopleA["Homer"];
    Console.WriteLine(homer);

    // Populate with initialization syntax.
    Dictionary<string, Person> peopleB = new Dictionary<string, Person>()
    {
        { "Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 } },
        { "Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 } },
        { "Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 } }
    };

    // Get Lisa.
    Person lisa = peopleB["Lisa"];
    Console.WriteLine(lisa);
}

也可以使用特定于这种类型容器的相关初始化语法来填充Dictionary<TKey,TValue>(毫不奇怪地称为字典初始化)。类似于前面代码示例中用于填充personB对象的语法,您仍然为集合对象定义一个初始化范围;但是,您可以使用索引器来指定键,并将其分配给新对象,如下所示:

// Populate with dictionary initialization syntax.
Dictionary<string, Person> peopleC = new Dictionary<string, Person>()
{
    ["Homer"] = new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 },
    ["Marge"] = new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 },
    ["Lisa"] = new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 }
};

系统。Collections.ObjectModel 命名空间

既然您已经理解了如何使用主要的泛型类,我们将简要地研究一个额外的以集合为中心的名称空间,System.Collections.ObjectModel。这是一个相对较小的名称空间,包含少量的类。表 10-7 记录了你最应该知道的两个类别。

表 10-7。

System.Collections.ObjectModel的有用成员

|

系统。集合. ObjectModel 类型

|

生命的意义

| | --- | --- | | ObservableCollection<T> | 表示一个动态数据集合,该集合在添加项、移除项或刷新整个列表时提供通知 | | ReadOnlyObservableCollection<T> | 表示只读版本的ObservableCollection<T> |

ObservableCollection<T>类是有用的,因为当它的内容以某种方式改变时,它能够通知外部对象(正如您可能猜到的,使用ReadOnlyObservableCollection<T>类似,但本质上是只读的)。

使用 ObservableCollection

创建一个名为 FunWithObservableCollections 的新控制台应用项目,并将名称空间System.Collections.ObjectModel导入到初始 C# 代码文件中。在许多方面,使用ObservableCollection<T>与使用List<T>是相同的,因为这两个类实现了相同的核心接口。ObservableCollection<T>类的独特之处在于它支持一个名为CollectionChanged的事件。每当插入新项、移除(或重新定位)当前项或修改整个集合时,都会触发此事件。

像任何事件一样,CollectionChanged是根据委托定义的,在本例中是NotifyCollectionChangedEventHandler。这个委托可以调用任何以一个对象作为第一个参数,以一个NotifyCollectionChangedEventArgs作为第二个参数的方法。考虑下面的代码,它填充了一个包含Person对象的可观察集合,并连接了CollectionChanged事件:

using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using FunWithObservableCollections;

// Make a collection to observe
//and add a few Person objects.
ObservableCollection<Person> people = new ObservableCollection<Person>()
{
  new Person{ FirstName = "Peter", LastName = "Murphy", Age = 52 },
  new Person{ FirstName = "Kevin", LastName = "Key", Age = 48 },
};

// Wire up the CollectionChanged event.
people.CollectionChanged += people_CollectionChanged;

static void people_CollectionChanged(object sender,
    System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
  throw new NotImplementedException();
}

传入的NotifyCollectionChangedEventArgs参数定义了两个重要的属性,OldItemsNewItems,这将为您提供一个列表,其中列出了事件触发前集合中的当前项目以及变更中涉及的新项目。但是,您只希望在正确的情况下检查这些列表。回想一下,当添加、删除、重定位或重置项目时,会触发CollectionChanged事件。要发现这些动作中的哪一个触发了事件,您可以使用NotifyCollectionChangedEventArgsAction属性。可以针对NotifyCollectionChangedAction枚举的以下任何成员测试Action属性:

public enum NotifyCollectionChangedAction
{
  Add = 0,
  Remove = 1,
  Replace = 2,
  Move = 3,
  Reset = 4,
}

下面是一个CollectionChanged事件处理程序的实现,当一个项目被插入到集合中或从集合中删除时,它将遍历旧的和新的集合(注意System.Collections.Specializedusing):

using System.Collections.Specialized;
...
static void people_CollectionChanged(object sender,
  NotifyCollectionChangedEventArgs e)
{
  // What was the action that caused the event?
  Console.WriteLine("Action for this event: {0}", e.Action);

  // They removed something.
  if (e.Action == NotifyCollectionChangedAction.Remove)
  {
    Console.WriteLine("Here are the OLD items:");
    foreach (Person p in e.OldItems)
    {
      Console.WriteLine(p.ToString());
    }
    Console.WriteLine();
  }

  // They added something.
  if (e.Action == NotifyCollectionChangedAction.Add)
  {
    // Now show the NEW items that were inserted.
    Console.WriteLine("Here are the NEW items:");
    foreach (Person p in e.NewItems)
    {
      Console.WriteLine(p.ToString());
    }
  }
}

现在,更新您的调用代码来添加和移除一个项目。

// Now add a new item.
people.Add(new Person("Fred", "Smith", 32));
// Remove an item.
people.RemoveAt(0);

当您运行该程序时,您将看到类似如下的输出:

Action for this event: Add
Here are the NEW items:
Name: Fred Smith, Age: 32

Action for this event: Remove
Here are the OLD items:
Name: Peter Murphy, Age: 52

这就结束了对各种以集合为中心的名称空间的检查。作为本章的总结,现在您将研究如何构建自己的自定义泛型方法和自定义泛型类型。

创建自定义泛型方法

虽然大多数开发人员通常使用基类库中的现有泛型类型,但也可以构建自己的泛型成员和自定义泛型类型。让我们看看如何将自定义泛型合并到您自己的项目中。第一步是构建一个通用的交换方法。首先创建一个名为 CustomGenericMethods 的新控制台应用。

当您构建自定义泛型方法时,您实现了传统方法重载的增压版本。在第二章中,你学到了重载是定义一个方法的多个版本的行为,这些版本在参数的数量或类型上有所不同。

虽然重载在面向对象语言中是一个有用的特性,但有一个问题是,你很容易用大量本质上做同样事情的方法来结束。例如,假设您需要构建一些方法,这些方法可以使用一个简单的交换例程来交换两段数据。您可以从创建一个新的静态类开始,使用一个可以操作整数的方法,如下所示:

using System;
namespace CustomGenericMethods
{
  static class SwapFunctions
  {
    // Swap two integers.
    static void Swap(ref int a, ref int b)
    {
      int temp = a;
      a = b;
      b = temp;
    }
  }
}

目前为止,一切顺利。但是现在假设您还需要交换两个Person对象;这需要创作一个新版本的Swap()

// Swap two Person objects.
static void Swap(ref Person a, ref Person b)
{
  Person temp = a;
  a = b;
  b = temp;
}

毫无疑问,你可以看到这将走向何方。如果你还需要交换浮点数、位图、汽车、按钮等。,您将不得不构建更多的方法,这将成为维护的噩梦。你可以构建一个操作object参数的单一(非泛型)方法,但是你会面临你在本章前面检查过的所有问题,包括装箱、拆箱、缺乏类型安全、显式强制转换等等。

每当你有一组重载的方法,它们只有传入的参数不同,这是你的线索,泛型可以让你的生活更容易。考虑下面的通用Swap<T>()方法,它可以交换任意两个T:

// This method will swap any two items.
// as specified by the type parameter <T>.
static void Swap<T>(ref T a, ref T b)
{
  Console.WriteLine("You sent the Swap() method a {0}", typeof(T));
  T temp = a;
  a = b;
  b = temp;
}

请注意,泛型方法是如何通过在方法名之后、参数列表之前指定类型参数来定义的。这里,您声明了Swap<T>()方法可以对任意两个类型为<T>的参数进行操作。为了增加一点趣味,您还可以使用 C# 的typeof()操作符将所提供的占位符的类型名称打印到控制台。现在考虑下面的调用代码,它交换整数和字符串:

Console.WriteLine("***** Fun with Custom Generic Methods *****\n");

// Swap 2 ints.
int a = 10, b = 90;
Console.WriteLine("Before swap: {0}, {1}", a, b);
SwapFunctions.Swap<int>(ref a, ref b);
Console.WriteLine("After swap: {0}, {1}", a, b);
Console.WriteLine();

// Swap 2 strings.
string s1 = "Hello", s2 = "There";
Console.WriteLine("Before swap: {0} {1}!", s1, s2);
SwapFunctions.Swap<string>(ref s1, ref s2);
Console.WriteLine("After swap: {0} {1}!", s1, s2);

Console.ReadLine();

输出如下所示:

***** Fun with Custom Generic Methods *****
Before swap: 10, 90
You sent the Swap() method a System.Int32
After swap: 90, 10

Before swap: Hello There!
You sent the Swap() method a System.String
After swap: There Hello!

这种方法的主要好处是您只需要维护一个版本的Swap<T>(),但是它可以以类型安全的方式对给定类型的任意两个项目进行操作。更好的是,基于栈的项留在栈上,而基于堆的项留在堆上!

类型参数的推断

当您调用泛型方法(如Swap<T>)时,如果(且仅当)泛型方法需要参数,您可以选择省略类型参数,因为编译器可以根据成员参数推断类型参数。例如,您可以通过在顶级语句中添加以下代码来交换两个System.Boolean值:

// Compiler will infer System.Boolean.
bool b1 = true, b2 = false;
Console.WriteLine("Before swap: {0}, {1}", b1, b2);
SwapFunctions.Swap(ref b1, ref b2);
Console.WriteLine("After swap: {0}, {1}", b1, b2);

即使编译器可以根据用于声明b1b2的数据类型发现正确的类型参数,您也应该养成总是显式指定类型参数的习惯。

SwapFunctions.Swap<bool>(ref b1, ref b2);

这让你的程序员同事清楚,这个方法确实是通用的。此外,只有当泛型方法至少有一个参数时,类型参数的推断才有效。例如,假设您的Program类中有以下泛型方法:

static void DisplayBaseClass<T>()
{
  // BaseType is a method used in reflection,
  // which will be examined in Chapter 17
  Console.WriteLine("Base class of {0} is: {1}.", typeof(T), typeof(T).BaseType);
}

在这种情况下,您必须在调用时提供类型参数。

...
// Must supply type parameter if
// the method does not take params.
DisplayBaseClass<int>();
DisplayBaseClass<string>();

// Compiler error! No params? Must supply placeholder!
// DisplayBaseClass();
Console.ReadLine();

当然,泛型方法不需要像这些例子中那样是静态的。非泛型方法的所有规则和选项也适用。

创建自定义泛型结构和类

现在,您已经了解了如何定义和调用泛型方法,是时候将注意力转向在名为 GenericPoint 的新控制台应用项目中构造泛型结构了(构建泛型类的过程是相同的)。假设您已经构建了一个通用的Point结构,它支持单个类型参数,该参数表示( x,y )坐标的底层存储。然后调用者可以创建如下的Point<T>类型:

// Point using ints.
Point<int> p = new Point<int>(10, 10);

// Point using double.
Point<double> p2 = new Point<double>(5.4, 3.3);

// Point using strings.
Point<string> p3 = new Point<string>(""",""3"");

使用字符串创建一个点乍一看可能有点奇怪,但是考虑一下虚数的情况。那么使用字符串表示一个点的值XY可能是有意义的。无论如何,它展示了泛型的力量。这里是Point<T>的完整定义:

namespace GenericPoint
{
  // A generic Point structure.
  public struct Point<T>
  {
    // Generic state data.
    private T _xPos;
    private T _yPos;

    // Generic constructor.
    public Point(T xVal, T yVal)
    {
      _xPos = xVal;
      _yPos = yVal;
    }

    // Generic properties.
    public T X
    {
      get => _xPos;
      set => _xPos = value;
    }

    public T Y
    {
      get => _yPos;
      set => _yPos = value;
    }

    public override string ToString() => $"[{_xPos}, {_yPos}]";
  }
}

如您所见,Point<T>在字段数据的定义、构造函数参数和属性定义中利用了它的类型参数。

具有泛型的默认值表达式

随着泛型的引入,C# default关键字被赋予了双重身份。除了在switch构造中使用之外,它还可以用于将类型参数设置为默认值。这很有帮助,因为泛型类型事先不知道实际的占位符,这意味着它不能安全地假定默认值是什么。类型参数的默认值如下:

  • 数值的默认值为0

  • 引用类型有一个默认值null

  • 结构的字段被设置为0(对于值类型)或null(对于引用类型)。

要重置Point<T>的实例,您可以直接将XY的值设置为0。这假定调用者将只提供数字数据。那string版本呢?这就是default(T)语法派上用场的地方。default 关键字将变量重置为该变量数据类型的默认值。如下添加一个名为ResetPoint()的方法:

// Reset fields to the default value of the type parameter.
// The "default" keyword is overloaded in C#.
// When used with generics, it represents the default
// value of a type parameter.
public void ResetPoint()
{
  _xPos = default(T);
  _yPos = default(T);
}

现在你已经有了ResetPoint()的方法,你可以充分运用Point<T> struct的方法。

using System;
using GenericPoint;

Console.WriteLine("***** Fun with Generic Structures *****\n");
// Point using ints.
Point<int> p = new Point<int>(10, 10);
Console.WriteLine("p.ToString()={0}", p.ToString());
p.ResetPoint();
Console.WriteLine("p.ToString()={0}", p.ToString());
Console.WriteLine();

// Point using double.
Point<double> p2 = new Point<double>(5.4, 3.3);
Console.WriteLine("p2.ToString()={0}", p2.ToString());
p2.ResetPoint();
Console.WriteLine("p2.ToString()={0}", p2.ToString());
Console.WriteLine();

// Point using strings.
Point<string> p3 = new Point<string>("i", "3i");
Console.WriteLine("p3.ToString()={0}", p3.ToString());
p3.ResetPoint();
Console.WriteLine("p3.ToString()={0}", p3.ToString());
Console.ReadLine();

以下是输出:

***** Fun with Generic Structures *****
p.ToString()=[10, 10]
p.ToString()=[0, 0]

p2.ToString()=[5.4, 3.3]
p2.ToString()=[0, 0]

p3.ToString()=[i, 3i]
p3.ToString()=[, ]

默认文字表达式(新 7.1)

除了设置属性的默认值,C# 7.1 还引入了默认的文字表达式。这消除了在 default 语句中指定变量类型的需要。将ResetPoint()方法更新如下:

public void ResetPoint()
{
  _xPos = default;
  _yPos = default;
}

默认表达式不限于简单变量,也可以应用于复杂类型。例如,要创建并初始化Point结构,您可以编写以下代码:

Point<string> p4 = default;
Console.WriteLine("p4.ToString()={0}", p4.ToString());
Console.WriteLine();
Point<int> p5 = default;
Console.WriteLine("p5.ToString()={0}", p5.ToString());

泛型模式匹配(新 7.1)

C# 7.1 的另一个更新是泛型模式匹配的能力。以下面的方法为例,该方法检查Point实例的数据类型(可能不完整,但足以说明概念):

static void PatternMatching<T>(Point<T> p)
{
  switch (p)
  {
    case Point<string> pString:
      Console.WriteLine("Point is based on strings");
      return;
    case Point<int> pInt:
      Console.WriteLine("Point is based on ints");
      return;
  }
}

要运行模式匹配代码,请更新top-level statements to the following:

Point<string> p4 = default;
Point<int> p5 = default;
PatternMatching(p4);
PatternMatching(p5);

约束类型参数

如本章所示,任何泛型项都至少有一个类型参数,您需要在与泛型类型或成员交互时指定该类型参数。仅这一点就允许您构建一些类型安全的代码;但是,您也可以使用where关键字来非常具体地说明给定的类型参数应该是什么样子。

使用该关键字,可以向给定的类型参数添加一组约束,C# 编译器将在编译时检查这些约束。具体来说,你可以约束一个类型参数,如表 10-8 所述。

表 10-8。

泛型类型参数的可能约束

|

通用约束

|

生命的意义

| | --- | --- | | where T : struct | 类型参数<T>在其继承链中必须有System.ValueType(即<T>必须是一个结构)。 | | where T : class | 类型参数<T>的继承链中不能有System.ValueType(即<T>必须是引用类型)。 | | where T : new() | 类型参数<T>必须有默认的构造函数。如果您的泛型类型必须创建类型参数的实例,这将很有帮助,因为您无法假定自己知道自定义构造函数的格式。请注意,在多约束类型中,该约束必须列在最后。 | | where T : NameOfBaseClass | 类型参数<T>必须从NameOfBaseClass指定的类中派生。 | | where T : NameOfInterface | 类型参数<T>必须实现NameOfInterface指定的接口。您可以用逗号分隔的列表分隔多个接口。 |

除非您需要构建一些极其类型安全的自定义集合,否则您可能永远不需要在 C# 项目中使用where关键字。不管怎样,下面几个(部分)代码示例说明了如何使用where关键字。

使用 where 关键字的示例

首先假设您已经创建了一个自定义泛型类,并且希望确保类型参数有一个默认的构造函数。当自定义泛型类需要创建T的实例时,这可能是有用的,因为默认构造函数是所有类型可能共有的唯一构造函数。同样,以这种方式约束T可以让您获得编译时检查;如果T是一个引用类型,程序员记得在类定义中重定义默认构造函数(您可能记得当您定义自己的构造函数时,默认构造函数在类中被移除了)。

// MyGenericClass derives from object, while
// contained items must have a default ctor.
public class MyGenericClass<T> where T : new()
{
  ...
}

注意,where子句指定了哪个类型参数被约束,后面跟着一个冒号操作符。在冒号操作符之后,您列出了每个可能的约束(在本例中,是一个默认的构造函数)。这是另一个例子:

// MyGenericClass derives from object, while
// contained items must be a class implementing IDrawable
// and must support a default ctor.
public class MyGenericClass<T> where T : class, IDrawable, new()
{
  ...
}

在这种情况下,T有三个要求。它必须是引用类型(不是结构),用class标记。第二,T必须实现IDrawable接口。第三,它还必须有一个默认的构造函数。多个约束列在逗号分隔的列表中;然而,你应该知道new()约束必须总是列在最后!因此,下面的代码不会编译:

// Error! new() constraint must be listed last!
public class MyGenericClass<T> where T : new(), class, IDrawable
{
  ...
}

如果您曾经创建了一个指定多个类型参数的定制泛型集合类,那么您可以使用单独的where子句为每个类型参数指定一组唯一的约束。

// <K> must extend SomeBaseClass and have a default ctor,
// while <T> must be a structure and implement the
// generic IComparable interface.
public class MyGenericClass<K, T> where K : SomeBaseClass, new()
  where T : struct, IComparable<T>
{
  ...
}

您很少会遇到需要构建完整的自定义泛型集合类的情况;然而,您也可以在泛型方法上使用where关键字。例如,如果您想要指定您的泛型Swap<T>()方法只能在结构上操作,您将像这样更新该方法:

// This method will swap any structure, but not classes.
static void Swap<T>(ref T a, ref T b) where T : struct
{
  ...
}

注意,如果您以这种方式约束Swap()方法,您将不再能够交换string对象(如示例代码所示),因为string是一个引用类型。

缺乏对运算符的约束

在本章结束时,我想对泛型方法和约束再做一点评论。您可能会惊讶地发现,在创建泛型方法时,如果应用任何 C# 操作符(+-*==等),都会出现编译器错误。)上的类型参数。例如,想象一下一个可以对泛型类型进行加、减、乘、除的类有多有用。

// Compiler error! Cannot apply
// operators to type parameters!
public class BasicMath<T>
{
  public T Add(T arg1, T arg2)
  { return arg1 + arg2; }
  public T Subtract(T arg1, T arg2)
  { return arg1 - arg2; }
  public T Multiply(T arg1, T arg2)
  { return arg1 * arg2; }
  public T Divide(T arg1, T arg2)
  { return arg1 / arg2; }
}

不幸的是,前面的BasicMath类无法编译。虽然这看起来是一个主要的限制,但是你需要记住泛型是通用的。当然,数值数据可以使用 C# 的二元运算符。然而,为了便于讨论,如果<T>是一个定制类或结构类型,编译器可以假设该类支持+-*/操作符。理想情况下,C# 允许泛型类型受支持的运算符约束,如下例所示:

// Illustrative code only!
public class BasicMath<T> where T : operator +, operator -,
  operator *, operator /
{
  public T Add(T arg1, T arg2)
  { return arg1 + arg2; }
  public T Subtract(T arg1, T arg2)
  { return arg1 - arg2; }
  public T Multiply(T arg1, T arg2)
  { return arg1 * arg2; }
  public T Divide(T arg1, T arg2)
  { return arg1 / arg2; }
}

唉,当前版本的 C# 不支持运算符约束。然而,通过定义一个支持这些操作符的接口(C# 接口可以定义操作符!)来达到预期的效果是可能的(尽管这需要更多的工作)!)然后指定泛型类的接口约束。无论如何,这总结了本书对构建定制泛型类型的初步看法。在第十二章中,我将在研究委托类型时再次提起泛型的话题。

摘要

本章从检查System.CollectionsSystem.Collections.Specialized的非泛型集合类型开始,包括与许多非泛型容器相关的各种问题,包括缺乏类型安全性以及装箱和取消装箱操作的运行时开销。如上所述,由于这些原因,现代。NET 程序通常会利用System.Collections.GenericSystem.Collections.ObjectModel中的通用集合类。

正如您所看到的,泛型项允许您指定占位符(类型参数),这些占位符是您在对象创建(或调用,在泛型方法的情况下)时指定的。虽然您通常会简单地使用。NET 基础类库,您也将能够创建自己的泛型类型(和泛型方法)。当您这样做时,您可以选择指定任意数量的约束(使用where关键字)来提高类型安全级别,并确保您对保证展示某些基本功能的已知数量的类型执行操作。

最后要注意的是,记住泛型出现在。NET 基础类库。在这里,您特别关注了泛型集合。然而,当您阅读本书的剩余部分时(当您按照自己的方式深入平台时),您肯定会发现泛型类、结构和委托位于给定的名称空间中。同样,要注意非泛型类的泛型成员!**

十一、高级 C# 语言功能

在本章中,您将通过研究几个更高级的主题来加深对 C# 编程语言的理解。首先,您将学习如何实现和使用一个索引器方法。这种 C# 机制使您能够构建自定义类型,这些自定义类型使用类似数组的语法提供对内部子项的访问。在您学习了如何构建索引器方法之后,您将看到如何重载各种操作符(+-<>等)。)以及如何为您的类型创建自定义的显式和隐式转换例程(您将了解为什么您可能想要这样做)。

接下来,您将研究在使用以 LINQ 为中心的 API 时特别有用的主题(尽管您可以在 LINQ 的上下文之外使用它们),特别是扩展方法和匿名类型。

最后,您将学习如何创建一个“不安全”的代码上下文来直接操作非托管指针。虽然在 C# 应用中使用指针确实是一种不常见的活动,但是在一些涉及复杂互操作性的情况下,理解如何使用指针会很有帮助。

了解索引器方法

作为一名程序员,您肯定很熟悉使用索引操作符([])访问一个简单数组中包含的各个项的过程。这里有一个例子:

// Loop over incoming command-line arguments
// using index operator.
for(int i = 0; i < args.Length; i++)
{
  Console.WriteLine("Args: {0}", args[i]);
}

// Declare an array of local integers.
int[] myInts = { 10, 9, 100, 432, 9874};

// Use the index operator to access each element.
for(int j = 0; j < myInts.Length; j++)
{
  Console.WriteLine("Index {0}  = {1} ", j,  myInts[j]);
}
Console.ReadLine();

这个代码绝不是一个主要的新闻快讯。然而,通过定义一个索引器方法,C# 语言提供了设计定制类和结构的能力,这些类和结构可以像标准数组一样被索引。当您创建自定义集合类(泛型或非泛型)时,此功能最有用。

在研究如何实现自定义索引器之前,让我们先来看一个实例。假设您已经在第十章中开发的自定义PersonCollection类型中添加了对索引器方法的支持(具体来说,就是 issueswingnongenericcollections 项目)。虽然您尚未添加索引器,但请在名为 SimpleIndexer 的新控制台应用项目中观察以下用法:

using System;
using System.Collections.Generic;
using System.Data;
using SimpleIndexer;

// Indexers allow you to access items in an array-like fashion.
Console.WriteLine("***** Fun with Indexers *****\n");

PersonCollection myPeople = new PersonCollection();

// Add objects with indexer syntax.
myPeople[0] = new Person("Homer", "Simpson", 40);
myPeople[1] = new Person("Marge", "Simpson", 38);
myPeople[2] = new Person("Lisa", "Simpson", 9);
myPeople[3] = new Person("Bart", "Simpson", 7);
myPeople[4] = new Person("Maggie", "Simpson", 2);

// Now obtain and display each item using indexer.
for (int i = 0; i < myPeople.Count; i++)
{
  Console.WriteLine("Person number: {0}", i);
  Console.WriteLine("Name: {0} {1}",
    myPeople[i].FirstName, myPeople[i].LastName);
  Console.WriteLine("Age: {0}", myPeople[i].Age);
  Console.WriteLine();
}

如您所见,索引器允许您像操作标准数组一样操作子对象的内部集合。现在来看一个大问题:如何配置PersonCollection类(或任何定制类或结构)来支持这个功能?索引器表示为稍加修改的 C# 属性定义。最简单的形式是使用this[]语法创建一个索引器。下面是PersonCollection类所需的更新:

using System.Collections;

namespace SimpleIndexer
{
  // Add the indexer to the existing class definition.
  public class PersonCollection : IEnumerable
  {
    private ArrayList arPeople = new ArrayList();
...
    // Custom indexer for this class.
    public Person this[int index]
    {
      get => (Person)arPeople[index];
      set => arPeople.Insert(index, value);
    }
  }
}

除了使用带括号的关键字this之外,索引器看起来就像任何其他 C# 属性声明一样。例如,get作用域的作用是将正确的对象返回给调用者。这里,您通过将请求委托给ArrayList对象的索引器来实现,因为这个类也支持索引器。set范围监督添加新的Person对象;这是通过调用ArrayListInsert()方法实现的。

索引器是另一种形式的语法糖,因为这种功能也可以使用“普通的”公共方法来实现,比如AddPerson()GetPerson()。然而,当您在自定义集合类型上支持索引器方法时,它们可以很好地集成到。NET 核心基本类库。

虽然创建索引器方法在构建自定义集合时很常见,但请记住泛型类型为您提供了开箱即用的功能。考虑下面的方法,它使用了一个通用的Person对象的List<T>。注意,你可以简单地直接使用List<T>的索引器。这里有一个例子:

using System.Collections.Generic;
static void UseGenericListOfPeople()
{
  List<Person> myPeople = new List<Person>();
  myPeople.Add(new Person("Lisa", "Simpson", 9));
  myPeople.Add(new Person("Bart", "Simpson", 7));

  // Change first person with indexer.
  myPeople[0] = new Person("Maggie", "Simpson", 2);

  // Now obtain and display each item using indexer.
  for (int i = 0; i < myPeople.Count; i++)
  {
    Console.WriteLine("Person number: {0}", i);
    Console.WriteLine("Name: {0} {1}", myPeople[i].FirstName, myPeople[i].LastName);
    Console.WriteLine("Age: {0}", myPeople[i].Age);
    Console.WriteLine();
  }
}

使用字符串值索引数据

当前的PersonCollection类定义了一个索引器,该索引器允许调用者使用数值来标识子项。但是,请理解,这不是索引器方法的要求。假设您更喜欢使用System.Collections.Generic.Dictionary<TKey, TValue>而不是ArrayList来包含Person对象。假设Dictionary类型允许使用一个键(比如一个人的名字)访问包含的类型,您可以如下定义一个索引器:

using System.Collections;
using System.Collections.Generic;
namespace SimpleIndexer
{
  public class PersonCollectionStringIndexer : IEnumerable
  {
    private Dictionary<string, Person> listPeople = new Dictionary<string, Person>();

    // This indexer returns a person based on a string index.
    public Person this[string name]
    {
      get => (Person)listPeople[name];
      set => listPeople[name] = value;
    }
    public void ClearPeople()
    {
      listPeople.Clear();
    }

    public int Count => listPeople.Count;

    IEnumerator IEnumerable.GetEnumerator() => listPeople.GetEnumerator();
  }
}

调用者现在能够与包含的Person对象交互,如下所示:

Console.WriteLine("***** Fun with Indexers *****\n");

PersonCollectionStringIndexer myPeopleStrings =
  new PersonCollectionStringIndexer();

myPeopleStrings["Homer"] =
  new Person("Homer", "Simpson", 40);
myPeopleStrings["Marge"] =
  new Person("Marge", "Simpson", 38);

// Get "Homer" and print data.
Person homer = myPeopleStrings["Homer"];
Console.ReadLine();

同样,如果您直接使用泛型Dictionary<TKey, TValue>类型,您将获得现成的索引器方法功能,而无需构建一个支持字符串索引器的自定义、非泛型类。尽管如此,请理解任何索引器的数据类型都将基于支持的集合类型如何允许调用方检索子项。

重载索引器方法

索引器方法可以在单个类或结构上重载。因此,如果允许调用者使用数字索引或字符串值访问子项是有意义的,那么可以为一个类型定义多个索引器。举例来说,在 ADO.NET(。NET 的本地数据库访问 API),DataSet类支持一个名为Tables的属性,它返回给你一个强类型的DataTableCollection类型。事实证明,DataTableCollection定义了三个索引器来获取和设置DataTable对象——一个通过序号位置,另一个通过友好的字符串名字对象和可选的包含名称空间,如下所示:

public sealed class DataTableCollection : InternalDataCollectionBase
{
...
  // Overloaded indexers!
  public DataTable this[int index] { get; }
  public DataTable this[string name] { get; }
  public DataTable this[string name, string tableNamespace] { get; }
}

基类库中的类型支持索引器方法是很常见的。所以请注意,即使您当前的项目不要求您为您的类和结构构建自定义索引器,许多类型已经支持这种语法。

多维索引器

您还可以创建一个接受多个参数的索引器方法。假设您有一个在 2D 数组中存储子项的自定义集合。如果是这种情况,您可以按如下方式定义索引器方法:

public class SomeContainer
{
  private int[,] my2DintArray = new int[10, 10];

  public int this[int row, int column]
  {  /* get or set value from 2D array */  }
}

同样,除非您正在构建一个高度风格化的自定义集合类,否则您不太需要构建一个多维索引器。尽管如此,ADO.NET 再次展示了这种构造是多么有用。ADO.NETDataTable本质上是行和列的集合,很像一张绘图纸或 Microsoft Excel 电子表格的一般结构。

虽然通常使用相关的“数据适配器”代表您填充DataTable对象,但是下面的代码演示了如何手动创建包含三列(每个记录的名字、姓氏和年龄)的内存中的DataTable。请注意,一旦您向DataTable添加了一行,您如何使用多维索引器来钻取第一行(也是唯一一行)的每一列。(如果您正在跟进,您需要将System.Data名称空间导入到您的代码文件中。)

static void MultiIndexerWithDataTable()
{
  // Make a simple DataTable with 3 columns.
  DataTable myTable = new DataTable();
  myTable.Columns.Add(new DataColumn("FirstName"));
  myTable.Columns.Add(new DataColumn("LastName"));
  myTable.Columns.Add(new DataColumn("Age"));

  // Now add a row to the table.
  myTable.Rows.Add("Mel", "Appleby", 60);

  // Use multidimension indexer to get details of first row.
  Console.WriteLine("First Name: {0}", myTable.Rows[0][0]);
  Console.WriteLine("Last Name: {0}", myTable.Rows[0][1]);
  Console.WriteLine("Age : {0}", myTable.Rows[0][2]);
}

请注意,您将从第二十一章开始深入研究 ADO.NET,所以如果前面的一些代码看起来不熟悉,也不用担心。此示例的要点是索引器方法可以支持多维度,如果使用正确,可以简化您与自定义集合中包含的子对象的交互方式。

接口类型的索引器定义

索引器可以在给定的。NET 核心接口类型,以允许支持类型提供自定义实现。下面是一个简单的接口示例,它定义了使用数字索引器获取字符串对象的协议:

public interface IStringContainer
{
  string this[int index] { get; set; }
}

使用此接口定义,任何实现此接口的类或结构现在都必须支持读写索引器,该索引器使用数值来操作子项。下面是此类的部分实现:

class SomeClass : IStringContainer
{
  private List<string> myStrings = new List<string>();

  public string this[int index]
  {
    get => myStrings[index];
    set => myStrings.Insert(index, value);
  }
}

这就结束了本章的第一个主要话题。现在让我们来研究一个语言特性,它允许您构建定制的类或结构,这些类或结构对 C# 的内部运算符做出独特的响应。接下来,请允许我介绍一下运算符重载的概念。

理解运算符重载

像任何编程语言一样,C# 有一组固定的标记,用于对内部类型执行基本操作。例如,您知道可以将+运算符应用于两个整数,以产生一个更大的整数。

// The + operator with ints.
int a = 100;
int b = 240;
int c = a + b; // c is now 340

再说一次,这不是什么大新闻,但是你有没有停下来注意过同一个+操作符是如何应用于大多数 C# 数据类型的?例如,考虑以下代码:

// + operator with strings.
string s1 = "Hello";
string s2 = " world!";
string s3 = s1 + s2;  // s3 is now "Hello World!"

+操作符基于所提供的数据类型(本例中是字符串或整数)以特定的方式运行。当+运算符应用于数值类型时,结果是操作数的总和。然而,当+操作符应用于字符串类型时,结果是字符串连接。

C# 语言为您提供了构建定制类和结构的能力,这些定制类和结构也可以唯一地响应同一组基本标记(如+操作符)。虽然不是每个可能的 C# 操作符都可以重载,但是很多都可以,如表 11-1 所示。

表 11-1。

c# 运算符的可重载性

|

C# 运算符

|

过载能力

| | --- | --- | | +-!~++--truefalse | 这些一元运算符可以重载。C# 要求如果 true 或 false 被重载,两者都必须被重载。 | | +-*/%&&#124;^<<>> | 这些二元运算符可以重载。 | | ==!=<><=>= | 这些比较运算符可以重载。C# 要求“like”操作符(即<><=>===!=)一起重载。 | | [] | []运算符不能重载。然而,正如您在本章前面所看到的,索引器构造提供了相同的功能。 | | () | ()运算符不能重载。然而,正如您将在本章后面看到的,自定义转换方法提供了相同的功能。 | | +=-=*=/=%=&=&#124;=^=<<=>>= | 速记赋值运算符不能重载;然而,当你重载相关的二元操作符时,你可以免费得到它们。 |

重载二元运算符

为了说明重载二元运算符的过程,假设在一个名为 OverloadedOps 的新控制台应用项目中定义了以下简单的Point类:

using System;
namespace OverloadedOps
{
  // Just a simple, everyday C# class.
  public class Point
  {
    public int X {get; set;}
    public int Y {get; set;}

    public Point(int xPos, int yPos)
    {
      X = xPos;
      Y = yPos;
    }
    public override string ToString()
      => $"[{this.X}, {this.Y}]";
  }
}

现在,从逻辑上讲,把Point s“加”在一起是有意义的。例如,如果你将两个Point变量加在一起,你应该得到一个新的Point,它是XY值的总和。当然,从另一个中减去一个Point也是有帮助的。理想情况下,您希望能够编写以下代码:

using System;
using OverloadedOps;

// Adding and subtracting two points?
Console.WriteLine("***** Fun with Overloaded Operators *****\n");

// Make two points.
Point ptOne = new Point(100, 100);
Point ptTwo = new Point(40, 40);
Console.WriteLine("ptOne = {0}", ptOne);
Console.WriteLine("ptTwo = {0}", ptTwo);
// Add the points to make a bigger point?
Console.WriteLine("ptOne + ptTwo: {0} ", ptOne + ptTwo);

// Subtract the points to make a smaller point?
  Console.WriteLine("ptOne - ptTwo: {0} ", ptOne - ptTwo);
  Console.ReadLine();

然而,就像现在的Point一样,您将会收到编译时错误,因为Point类型不知道如何响应+-操作符。为了使自定义类型能够唯一地响应内部运算符,C# 提供了operator关键字,您只能将它与static关键字结合使用。当您重载一个二元操作符(比如+-)时,您通常会传入两个与定义类类型相同的参数(本例中为Point),如下面的代码更新所示:

// A more intelligent Point type.
public class Point
{
...
  // Overloaded operator +.
  public static Point operator + (Point p1, Point p2)
    => new Point(p1.X + p2.X, p1.Y + p2.Y);

  // Overloaded operator -.
  public static Point operator - (Point p1, Point p2)
    => new Point(p1.X - p2.X, p1.Y - p2.Y);
}

操作符+背后的逻辑只是基于传入的Point参数的字段总和返回一个新的Point对象。因此,当您编写pt1 + pt2时,您可以想象下面对静态操作符+方法的隐藏调用:

// Pseudo-code: Point p3 = Point.operator+ (p1, p2)
Point p3 = p1 + p2;

同样,p1p2映射到以下内容:

// Pseudo-code: Point p4 = Point.operator- (p1, p2)
Point p4 = p1 - p2;

有了这个更新,您的程序现在可以编译了,并且您发现您可以添加和减去Point对象,如下面的输出所示:

***** Fun with Overloaded Operators *****
ptOne = [100, 100]
ptTwo = [40, 40]
ptOne + ptTwo: [140, 140]
ptOne - ptTwo: [60, 60]

当重载二元运算符时,不需要传入两个相同类型的参数。如果这样做有意义,其中一个论点可以不同。例如,这里有一个重载操作符+,它允许调用者获得一个基于数字调整的新的Point:

public class Point
{
...
  public static Point operator + (Point p1, int change)
    => new Point(p1.X + change, p1.Y + change);

  public static Point operator + (int change, Point p1)
    => new Point(p1.X + change, p1.Y + change);
}

请注意,如果您希望参数以任意顺序传递,您需要方法的两个版本(即,您不能只定义其中一个方法,并期望编译器自动支持另一个)。您现在可以使用这些新版本的运算符+,如下所示:

// Prints [110, 110].
Point biggerPoint = ptOne + 10;
Console.WriteLine("ptOne + 10 = {0}", biggerPoint);

// Prints [120, 120].
Console.WriteLine("10 + biggerPoint = {0}", 10 + biggerPoint);
Console.WriteLine();

+=和–=运算符是什么?

如果你是从 C++背景进入 C# 的,你可能会感叹重载速记赋值操作符(+=-=等)的损失。).不要绝望。就 C# 而言,如果一个类型重载了相关的二元运算符,那么会自动模拟速记赋值运算符。因此,假设Point结构已经重载了+-操作符,您可以编写如下代码:

// Overloading binary operators results in a freebie shorthand operator.
...
// Freebie +=
Point ptThree = new Point(90, 5);
Console.WriteLine("ptThree = {0}", ptThree);
Console.WriteLine("ptThree += ptTwo: {0}", ptThree += ptTwo);

// Freebie -=
Point ptFour = new Point(0, 500);
Console.WriteLine("ptFour = {0}", ptFour);
Console.WriteLine("ptFour -= ptThree: {0}", ptFour -= ptThree);
Console.ReadLine();

重载一元运算符

C# 还允许你重载各种一元运算符,比如++--。当重载一元运算符时,还必须将static关键字与operator关键字一起使用;但是,在这种情况下,您只需传入一个与定义的类/结构类型相同的参数。例如,如果您要用以下重载操作符更新Point:

public class Point
{
...
  // Add 1 to the X/Y values for the incoming Point.
  public static Point operator ++(Point p1)
    => new Point(p1.X+1, p1.Y+1);

  // Subtract 1 from the X/Y values for the incoming Point.
  public static Point operator --(Point p1)
    => new Point(p1.X-1, p1.Y-1);
}

您可以像这样递增和递减Pointxy值:

...
// Applying the ++ and -- unary operators to a Point.
Point ptFive = new Point(1, 1);
Console.WriteLine("++ptFive = {0}", ++ptFive);  // [2, 2]
Console.WriteLine("--ptFive = {0}", --ptFive);  // [1, 1]

// Apply same operators as postincrement/decrement.
Point ptSix = new Point(20, 20);
Console.WriteLine("ptSix++ = {0}", ptSix++);    // [20, 20]
Console.WriteLine("ptSix-- = {0}", ptSix--);    // [21, 21]
Console.ReadLine();

请注意,在前面的代码示例中,您以两种不同的方式应用了自定义的++--操作符。在 C++中,可以分别重载前后递增/递减运算符。这在 C# 中是不可能的。然而,递增/递减的返回值会被自动“正确”地免费处理(例如,对于重载的++操作符,pt++将未修改对象的值作为其在表达式中的值,而++pt在表达式中使用之前应用了新值)。

重载相等运算符

你可能还记得第六章,可以覆盖System.Object.Equals()来执行引用类型之间基于值的(而不是基于引用的)比较。如果您选择覆盖Equals()(以及通常相关的System.Object.GetHashCode()方法),重载等式操作符(==!=)是微不足道的。为了说明,下面是更新后的Point类型:

// This incarnation of Point also overloads the == and != operators.
public class Point
{
...
  public override bool Equals(object o)
    => o.ToString() == this.ToString();

  public override int GetHashCode()
    => this.ToString().GetHashCode();

  // Now let's overload the == and != operators.
  public static bool operator ==(Point p1, Point p2)
    => p1.Equals(p2);

  public static bool operator !=(Point p1, Point p2)
    => !p1.Equals(p2);
}

注意操作符==和操作符!=的实现是如何简单地调用被覆盖的Equals()方法来完成大部分工作的。考虑到这一点,您现在可以如下练习您的Point类:

// Make use of the overloaded equality operators.
...
Console.WriteLine("ptOne == ptTwo : {0}", ptOne == ptTwo);
Console.WriteLine("ptOne != ptTwo : {0}", ptOne != ptTwo);
Console.ReadLine();

正如您所看到的,使用众所周知的==!=操作符来比较两个对象是非常直观的,而不是调用Object.Equals()。如果你确实重载了给定类的等式操作符,记住 C# 要求如果你覆盖了==操作符,你必须也覆盖了!=操作符(如果你忘记了,编译器会告诉你)。

重载比较运算符

在第八章中,你学习了如何实现IComparable接口来比较两个相似对象之间的关系。事实上,您还可以为同一个类重载比较操作符(<><=>=)。和等式操作符一样,C# 要求如果你重载了<,你也必须重载>。这同样适用于<=>=操作符。如果Point类型重载了这些比较操作符,对象用户现在可以比较Point s,如下所示:

// Using the overloaded < and > operators.
...
Console.WriteLine("ptOne < ptTwo : {0}", ptOne < ptTwo);
Console.WriteLine("ptOne > ptTwo : {0}", ptOne > ptTwo);
Console.ReadLine();

假设您已经实现了IComparable接口(或者更好的是通用等效接口),重载比较操作符是微不足道的。下面是更新后的类定义:

// Point is also comparable using the comparison operators.
public class Point : IComparable<Point>
{
...
  public int CompareTo(Point other)
  {
    if (this.X > other.X && this.Y > other.Y)
    {
      return 1;
    }
    if (this.X < other.X && this.Y < other.Y)
    {
      return -1;
    }
    return 0;
  }
  public static bool operator <(Point p1, Point p2)
    => p1.CompareTo(p2) < 0;

  public static bool operator >(Point p1, Point p2)
    => p1.CompareTo(p2) > 0;

  public static bool operator <=(Point p1, Point p2)
    => p1.CompareTo(p2) <= 0;

  public static bool operator >=(Point p1, Point p2)
    => p1.CompareTo(p2) >= 0;
}

关于运算符重载的最终想法

正如您所看到的,C# 提供了构建类型的能力,这些类型可以唯一地响应各种固有的、众所周知的运算符。现在,在您修改您的所有类以支持这种行为之前,您必须确保您将要重载的操作符在世界范围内具有某种逻辑意义。

例如,假设您重载了MiniVan类的乘法运算符。将两个MiniVan对象相乘到底意味着什么?不多。事实上,如果队友看到MiniVan对象的以下用法,会感到困惑:

// Huh?! This is far from intuitive...
MiniVan newVan = myVan * yourVan;

重载操作符通常只在构建原子数据类型时有用。向量、矩阵、文本、点、形状、集合等。,是运算符重载的理想选择。人、管理人员、汽车、数据库连接和网页没有。根据经验,如果一个重载的操作符使用户理解一个类型的功能变得更加困难,那就不要这样做。明智地使用这个特性。

了解自定义类型转换

现在让我们研究一个与运算符重载密切相关的主题:自定义类型转换。为了给讨论做好准备,让我们快速回顾一下数字数据和相关类类型之间显式和隐式转换的概念。

回忆:数字转换

根据固有的数字类型(sbyteintfloat等)。),当您试图在较小的容器中存储较大的值时,需要一个显式转换,因为这可能会导致数据丢失。基本上,这是你告诉编译器,“别管我,我知道我在做什么。”相反,当您试图将一个较小的类型放入一个不会导致数据丢失的目标类型中时,一个隐式转换会自动发生。

int a = 123;
long b = a;       // Implicit conversion from int to long.
int c = (int) b;  // Explicit conversion from long to int.

回忆:相关类类型之间的转换

如第六章所示,类类型可能通过经典继承相关(“is-a”关系)。在这种情况下,C# 转换过程允许您上下转换类层次结构。例如,派生类总是可以隐式转换为基类型。但是,如果您想在派生变量中存储基类类型,则必须执行显式转换,如下所示:

// Two related class types.
class Base{}
class Derived : Base{}

// Implicit cast between derived to base.
Base myBaseType;
myBaseType = new Derived();
// Must explicitly cast to store base reference
// in derived type.
Derived myDerivedType = (Derived)myBaseType;

这种显式强制转换是可行的,因为BaseDerived类通过传统继承相关联,并且myBaseType被构造为Derived的一个实例。但是,如果myBaseTypeBase的一个实例,那么 cast 抛出一个InvalidCastException。如果对转换会失败有任何疑问,你应该使用as关键字,如第六章中所讨论的。下面是重新制作的示例来演示这一点:

// Implicit cast between derived to base.
Base myBaseType2 = new();
// Throws InvalidCastException
//Derived myDerivedType2 = (Derived)myBaseType2 as Derived;
//No exception, myDerivedType2 is null
Derived myDerivedType2 = myBaseType2 as Derived;

然而,如果在不同的层次结构中有两个类类型没有共同的父类(除了System.Object)需要转换,该怎么办呢?假设它们没有传统的继承关系,典型的造型操作不会提供任何帮助(而且你会得到一个编译错误!).

另一方面,考虑值类型(结构)。假设您有两个名为SquareRectangle的结构。鉴于结构不能利用经典继承(因为它们总是密封的),您没有自然的方法在这些看似相关的类型之间进行转换。

虽然您可以在结构中创建助手方法(如Rectangle.ToSquare()),但 C# 允许您构建自定义转换例程,允许您的类型响应()转换操作符。因此,如果您正确配置了结构,您将能够使用以下语法在它们之间进行显式转换,如下所示:

// Convert a Rectangle to a Square!
Rectangle rect = new Rectangle
{
  Width = 3;
  Height = 10;
}
Square sq = (Square)rect;

创建自定义转换例程

首先创建一个名为 CustomConversions 的新控制台应用项目。C# 提供了两个关键字,explicitimplicit,您可以使用它们来控制您的类型在尝试转换期间如何响应。假设您有以下结构定义:

using System;

namespace CustomConversions
{
  public struct Rectangle
  {
    public int Width {get; set;}
    public int Height {get; set;}

    public Rectangle(int w, int h)
    {
      Width = w;
      Height = h;
    }

    public void Draw()
    {
      for (int i = 0; i < Height; i++)
      {
        for (int j = 0; j < Width; j++)
        {
          Console.Write("*");
        }
        Console.WriteLine();
      }
    }

    public override string ToString()
      => $"[Width = {Width}; Height = {Height}]";
  }
}

using System;

namespace CustomConversions
{
  public struct Square
  {
    public int Length {get; set;}
    public Square(int l) : this()
    {
      Length = l;
    }

    public void Draw()
    {
      for (int i = 0; i < Length; i++)
      {
        for (int j = 0; j < Length; j++)
        {
          Console.Write("*");
        }
        Console.WriteLine();
      }
    }

    public override string ToString() => $"[Length = {Length}]";

    // Rectangles can be explicitly converted into Squares.
    public static explicit operator Square(Rectangle r)
    {
      Square s = new Square {Length = r.Height};
      return s;
    }
  }
}

注意,Square类型的这个迭代定义了一个显式转换操作符。像重载操作符的过程一样,转换例程使用 C# operator关键字,结合explicitimplicit关键字,并且必须被定义为static。传入的参数是你要从转换的实体,而操作符类型是你要从转换的实体。

在这种情况下,假设可以从矩形的高度获得正方形(所有边都等长的几何图案)。因此,您可以自由地将Rectangle转换成Square,如下所示:

using System;
using CustomConversions;

Console.WriteLine("***** Fun with Conversions *****\n");
// Make a Rectangle.
Rectangle r = new Rectangle(15, 4);
Console.WriteLine(r.ToString());
r.Draw();

Console.WriteLine();

// Convert r into a Square,
// based on the height of the Rectangle.
Square s = (Square)r;
Console.WriteLine(s.ToString());
s.Draw();
Console.ReadLine();

您可以在这里看到输出:

***** Fun with Conversions *****
[Width = 15; Height = 4]

***************
***************
***************
***************

[Length = 4]
****
****
****
****

虽然在同一个作用域内将一个Rectangle转换成一个Square可能没什么帮助,但是假设你有一个被设计成接受Square参数的函数。

// This method requires a Square type.
static void DrawSquare(Square sq)
{
  Console.WriteLine(sq.ToString());
  sq.Draw();
}

使用对Square类型的显式转换操作,您现在可以传入Rectangle类型以使用显式强制转换进行处理,如下所示:

...
// Convert Rectangle to Square to invoke method.
Rectangle rect = new Rectangle(10, 5);
DrawSquare((Square)rect);
Console.ReadLine();

Square 类型的其他显式转换

既然您已经可以显式地将Rectangle转换成Square了,那么让我们检查一些额外的显式转换。假设一个正方形在所有边上都是对称的,那么提供一个显式的转换例程,允许调用者从整数类型转换为Square(当然,它的边长等于传入的整数)可能会有所帮助。同样,如果您要更新Square以便调用者可以将中的Square转换为int会怎么样?下面是调用逻辑:

...
// Converting an int to a Square.
Square sq2 = (Square)90;
Console.WriteLine("sq2 = {0}", sq2);

// Converting a Square to an int.
int side = (int)sq2;
Console.WriteLine("Side length of sq2 = {0}", side);
Console.ReadLine();

下面是对Square类的更新:

public struct Square
{
...
  public static explicit operator Square(int sideLength)
  {
    Square newSq = new Square {Length = sideLength};
    return newSq;
  }

  public static explicit operator int (Square s) => s.Length;
}

老实说,将Square转换成整数可能不是最直观(或最有用)的操作(毕竟,您可以将这些值传递给构造函数)。然而,它确实指出了关于自定义转换例程的一个重要事实:如果您编写了语法正确的代码,编译器并不关心您转换成什么或转换成什么。

因此,就像重载操作符一样,仅仅因为你可以为一个给定的类型创建一个显式的强制转换操作,并不意味着你应该。通常,这种技术在创建结构类型时最有帮助,因为它们不能参与经典继承(强制转换是免费的)。

定义隐式转换例程

到目前为止,您已经创建了各种自定义的显式的转换操作。但是,下面的隐式转换呢?

...
Square s3 = new Square {Length = 83};

// Attempt to make an implicit cast?
Rectangle rect2 = s3;

Console.ReadLine();

假定您没有为Rectangle类型提供隐式转换例程,这段代码将不会编译。这里有一个问题:在同一类型上定义显式和隐式转换函数是非法的,如果它们的返回类型或参数集没有区别的话。这似乎是一种限制;然而,第二个问题是,当一个类型定义了一个隐式转换例程时,调用者使用显式转换语法是合法的!

迷茫?为了弄清楚,让我们使用 C# implicit关键字向Rectangle结构添加一个隐式转换例程(注意,下面的代码假设产生的Rectangle的宽度是通过将Square的边乘以 2 来计算的):

public struct Rectangle
{
...
  public static implicit operator Rectangle(Square s)
  {
    Rectangle r = new Rectangle
    {
      Height = s.Length,
      Width = s.Length * 2 // Assume the length of the new Rectangle with (Length x 2).
    };
    return r;
  }
}

通过此更新,您现在可以在类型之间进行转换,如下所示:

...
// Implicit cast OK!
Square s3 = new Square { Length= 7};

Rectangle rect2 = s3;
Console.WriteLine("rect2 = {0}", rect2);

// Explicit cast syntax still OK!
Square s4 = new Square {Length = 3};
Rectangle rect3 = (Rectangle)s4;

Console.WriteLine("rect3 = {0}", rect3);
Console.ReadLine();

这就结束了您对自定义转换例程的定义。与重载操作符一样,记住这一点语法只是“普通”成员函数的简写符号,从这个角度来看,它总是可选的。然而,当正确使用时,自定义结构可以更自然地使用,因为它们可以被视为通过继承相关的真正的类类型。

理解扩展方法

。NET 3.5 引入了扩展方法的概念,它允许你向一个类或结构添加新的方法或属性,而不用以任何直接的方式修改原始类型。那么,这在哪里会有帮助呢?考虑以下可能性。

首先,假设您有一个生产中的给定类。随着时间的推移,很明显这个类应该支持一些新成员。如果直接修改当前的类定义,就有可能破坏使用它的旧代码库的向后兼容性,因为它们可能没有用最新和最好的类定义编译。确保向后兼容的一种方法是从现有父类创建新的派生类;然而,现在您有两个类需要维护。众所周知,代码维护是软件工程师工作描述中最不光彩的部分。

现在考虑这种情况。假设您有一个结构(或者一个密封的类)并想添加新的成员,这样它在您的系统中的行为就多样化了。由于结构和密封类不能被扩展,你唯一的选择就是将成员添加到类型中,这又一次冒着破坏向后兼容性的风险!

使用扩展方法,您可以修改类型,而无需创建子类,也无需直接修改类型。问题是,只有在当前项目中引用了扩展方法时,才会向类型提供新功能。

定义扩展方法

当你定义扩展方法时,第一个限制是它们必须在静态类中定义(见第五章);因此,每个扩展方法都必须用关键字static声明。第二点是所有的扩展方法都是这样标记的,使用this关键字作为方法的第一个(也是唯一的一个)参数的修饰符。“this合格”参数代表被扩展的项目。

为了进行说明,创建一个名为 ExtensionMethods 的新控制台应用项目。现在,假设您正在创作一个名为MyExtensions的类,它定义了两个扩展方法。第一种方法允许任何一个object使用一个名为DisplayDefiningAssembly()的新方法,该方法利用System.Reflection名称空间中的类型来显示包含该类型的程序集的名称。

Note

你将在第十七章中正式检查反射 API。如果您不熟悉这个主题,只需理解反射允许您在运行时发现程序集、类型和类型成员的结构。

第二种扩展方法名为ReverseDigits(),允许任何int获得其自身的新版本,其中值被逐位反转。例如,如果一个值为 1234 的整数被称为ReverseDigits(),那么返回的整数被设置为值 4321。考虑下面的类实现(如果您继续学习,请确保导入System.Reflection名称空间):

using System;
using System.Reflection;

namespace MyExtensionMethods
{
  static class MyExtensions
  {
    // This method allows any object to display the assembly
    // it is defined in.
    public static void DisplayDefiningAssembly(this object obj)
    {
      Console.WriteLine("{0} lives here: => {1}\n",
        obj.GetType().Name,
        Assembly.GetAssembly(obj.GetType()).GetName().Name);
    }

    // This method allows any integer to reverse its digits.
    // For example, 56 would return 65.
    public static int ReverseDigits(this int i)
    {
      // Translate int into a string, and then
      // get all the characters.
      char[] digits = i.ToString().ToCharArray();

      // Now reverse items in the array.
      Array.Reverse(digits);

      // Put back into string.
      string newDigits = new string(digits);

      // Finally, return the modified string back as an int.
      return int.Parse(newDigits);
    }
  }
}

同样,在定义参数类型之前,注意每个扩展方法的第一个参数是如何用关键字this限定的。扩展方法的第一个参数总是表示被扩展的类型。鉴于DisplayDefiningAssembly()已经被原型化以扩展System.Object,每个类型现在都有了这个新成员,因为Object是。NET 核心平台。然而,ReverseDigits()已经被原型化,只扩展整数类型;因此,如果整数以外的任何东西试图调用此方法,您将收到一个编译时错误。

Note

理解一个给定的扩展方法可以有多个参数,但是只有的第一个参数可以用this限定。附加参数将被视为该方法使用的正常输入参数。

调用扩展方法

现在已经有了这些扩展方法,请考虑下面的代码示例,该示例将扩展方法应用于基类库中的各种类型:

using System;
using MyExtensionMethods;

Console.WriteLine("***** Fun with Extension Methods *****\n");

// The int has assumed a new identity!
int myInt = 12345678;
myInt.DisplayDefiningAssembly();

// So has the DataSet!
System.Data.DataSet d = new System.Data.DataSet();
d.DisplayDefiningAssembly();

// Use new integer functionality.
Console.WriteLine("Value of myInt: {0}", myInt);
Console.WriteLine("Reversed digits of myInt: {0}",
  myInt.ReverseDigits());

Console.ReadLine();

以下是输出:

***** Fun with Extension Methods *****
Int32 lives here: => System.Private.CoreLib

DataSet lives here: => System.Data.Common

Value of myInt: 12345678
Reversed digits of myInt: 87654321

导入扩展方法

当您定义一个包含扩展方法的类时,它无疑将被定义在一个名称空间中。如果这个名称空间不同于使用扩展方法的名称空间,您将需要使用预期的 C# using关键字。当您这样做时,您的代码文件可以访问被扩展类型的所有扩展方法。记住这一点很重要,因为如果不显式导入正确的命名空间,扩展方法就不能用于该 C# 代码文件。

实际上,尽管表面上看起来扩展方法本质上是全局的,但实际上它们仅限于定义它们的命名空间或导入它们的命名空间。回想一下,您将MyExtensions类包装到一个名为MyExtensionMethods的名称空间中,如下所示:

namespace MyExtensionMethods
{
  static class MyExtensions
  {
    ...
  }
}

要在类中使用扩展方法,您需要显式导入MyExtensionMethods名称空间,正如我们在用于练习示例的顶级语句中所做的那样。

扩展实现特定接口的类型

至此,您已经看到了如何通过扩展方法用新的功能来扩展类(并间接地扩展遵循相同语法的结构)。也可以定义一个只能扩展实现正确接口的类或结构的扩展方法。例如,你可以说“如果一个类或结构实现了IEnumerable<T>,那么该类型将获得下面的新成员。”当然,有可能要求一个类型支持任何接口,包括您自己的自定义接口。

为了进行说明,创建一个名为 InterfaceExtensions 的新控制台应用项目。这里的目标是向实现IEnumerable的任何类型添加一个新方法,这将包括任何数组和许多非泛型集合类(回想一下第十章中的泛型IEnumerable<T>接口扩展了非泛型IEnumerable接口)。将以下扩展类添加到新项目中:

using System;

namespace InterfaceExtensions
{
  static class AnnoyingExtensions
  {
    public static void PrintDataAndBeep(
      this System.Collections.IEnumerable iterator)
    {
      foreach (var item in iterator)
      {
        Console.WriteLine(item);
        Console.Beep();
      }
    }
  }
}

假设任何实现IEnumerable的类或结构都可以使用PrintDataAndBeep()方法,您可以通过下面的代码进行测试:

using System;
using System.Collections.Generic;
using InterfaceExtensions;

Console.WriteLine("***** Extending Interface Compatible Types *****\n");

// System.Array implements IEnumerable!
string[] data =
  { "Wow", "this", "is", "sort", "of", "annoying",
      "but", "in", "a", "weird", "way", "fun!"};
data.PrintDataAndBeep();

Console.WriteLine();

// List<T> implements IEnumerable!
List<int> myInts = new List<int>() {10, 15, 20};
myInts.PrintDataAndBeep();

Console.ReadLine();

这就结束了对 C# 扩展方法的研究。请记住,每当您希望扩展类型的功能,但不想创建子类(或者如果类型是密封的,则无法创建子类)时,这种语言特性就非常有用,因为这是为了实现多态性。正如您将在本文后面看到的,扩展方法对 LINQ API 起着关键作用。事实上,你会看到在 LINQ API 下,最常见的扩展项之一是一个类或结构实现(惊喜!)通用版的IEnumerable

扩展方法 GetEnumerator 支持(新 9.0)

在 C# 9.0 之前,要在一个类上使用foreach,必须直接在那个类上定义GetEnumerator()方法。在 C# 9.0 中,foreach方法将检查类的扩展方法,如果找到了GetEnumerator()方法,将使用该方法获取该类的IEnumerator。要看到这一点,添加一个名为 ForEachWithExtensionMethods 的新控制台应用,并添加第八章中的CarGarage类的简化版本。

//Car.cs
using System;

namespace ForEachWithExtensionMethods
{
  class Car
  {
    // Car properties.
    public int CurrentSpeed {get; set;} = 0;
    public string PetName {get; set;} = "";

    // Constructors.
    public Car() {}
    public Car(string name, int speed)
    {
      CurrentSpeed = speed;
      PetName = name;
    }

    // See if Car has overheated.
  }
}

//Garage.cs
namespace ForEachWithExtensionMethods
{
  class Garage
  {
    public Car[] CarsInGarage { get; set; }

    // Fill with some Car objects upon startup.
    public Garage()
    {
      CarsInGarage = new Car[4];
      CarsInGarage[0] = new Car("Rusty", 30);
      CarsInGarage[1] = new Car("Clunker", 55);
      CarsInGarage[2] = new Car("Zippy", 30);
      CarsInGarage[3] = new Car("Fred", 30);
    }

  }
}

注意,Garage类没有实现IEnumerable,也没有GetEnumerator()方法。通过GarageExtensions类添加了GetEnumerator()方法,如下所示:

using System.Collections;

namespace ForEachWithExtensionMethods
{
  static class GarageExtensions
  {
    public static IEnumerator GetEnumerator(this Garage g)
        => g.CarsInGarage.GetEnumerator();
  }
}

测试这个新特性的代码与测试第八章中的GetEnumerator()方法的代码相同。将Program.cs更新如下:

using System;
using ForEachWithExtensionMethods;

Console.WriteLine("***** Support for Extension Method GetEnumerator *****\n");
Garage carLot = new Garage();

// Hand over each car in the collection?
foreach (Car c in carLot)
{
    Console.WriteLine("{0} is going {1} MPH",
        c.PetName, c.CurrentSpeed);
}

您将看到代码起作用了,将汽车及其速度的列表打印到控制台上。

***** Support for Extension Method GetEnumerator *****

Rusty is going 30 MPH
Clunker is going 55 MPH
Zippy is going 30 MPH
Fred is going 30 MPH

Note

这个新特性有一个潜在的缺点,那就是从来没有打算foreach ed 的类现在可以foreach ed 了。

了解匿名类型

作为一个面向对象的程序员,您知道定义类来表示您试图建模的给定项目的状态和功能的好处。可以肯定的是,每当您需要定义一个要在项目中重用的类,并且这个类通过一组方法、事件、属性和自定义构造函数提供大量的功能时,创建一个新的 C# 类是常见的做法。

然而,有些时候,您希望定义一个类,只是为了对一组封装的(或以某种方式相关的)数据点进行建模,而没有任何相关的方法、事件或其他专门的功能。此外,如果程序中只有少数方法使用这种类型,那该怎么办呢?当你很清楚这个类只在少数地方使用时,定义一个完整的类定义会很麻烦,如下所示。为了强调这一点,下面是当您需要创建一个遵循典型的基于值的语义的“简单”数据类型时,您可能需要做的事情的大致轮廓:

class SomeClass
{
  // Define a set of private member variables...

  // Make a property for each member variable...

  // Override ToString() to account for key member variables...

  // Override GetHashCode() and Equals() to work with value-based equality...
}

如你所见,事情不一定这么简单。您不仅需要编写大量的代码,还需要在系统中维护另一个类。对于像这样的临时数据,快速创建一个自定义数据类型会很有用。例如,假设您需要构建一个接收一组传入参数的自定义方法。您希望获取这些参数,并使用它们来创建一个新的数据类型,以便在此方法范围内使用。此外,您可能希望使用典型的ToString()方法快速打印出这些数据,并且可能使用System.Object的其他成员。您可以使用匿名类型语法来做这件事。

定义匿名类型

当你定义一个匿名类型时,你可以通过使用var关键字(参见第三章)和对象初始化语法(参见第五章)来实现。您必须使用var关键字,因为编译器会在编译时自动生成一个新的类定义(并且您永远不会在 C# 代码中看到这个类的名称)。初始化语法用于告诉编译器为新创建的类型创建私有支持字段和(只读)属性。

举例来说,创建一个名为 AnonymousTypes 的新控制台应用项目。现在,使用传入的参数数据,将下面的方法添加到您的Program类中,动态地组成一个新的类型:

static void BuildAnonymousType( string make, string color, int currSp )
{
  // Build anonymous type using incoming args.
  var car = new { Make = make, Color = color, Speed = currSp };

  // Note you can now use this type to get the property data!
   Console.WriteLine("You have a {0} {1} going {2} MPH", car.Color, car.Make, car.Speed);

  // Anonymous types have custom implementations of each virtual
  // method of System.Object. For example:
  Console.WriteLine("ToString() == {0}", car.ToString());
}

请注意,除了将代码包装在函数中之外,还可以以内联方式创建匿名类型,如下所示:

Console.WriteLine("***** Fun with Anonymous Types *****\n");

// Make an anonymous type representing a car.
var myCar = new { Color = "Bright Pink", Make = "Saab", CurrentSpeed = 55 };

// Now show the color and make.
Console.WriteLine("My car is a {0} {1}.", myCar.Color, myCar.Make);

// Now call our helper method to build anonymous type via args.
BuildAnonymousType("BMW", "Black", 90);

Console.ReadLine();

因此,在这一点上,简单地理解一下,匿名类型允许您以很少的开销快速地对数据的“形状”建模。这种技术只不过是一种快速创建新数据类型的方法,它通过属性支持基本的封装,并根据基于值的语义进行操作。为了理解最后一点,让我们看看 C# 编译器如何在编译时构建匿名类型,具体来说,它如何覆盖System.Object的成员。

匿名类型的内部表示

所有匿名类型都是从System.Object自动派生的,因此支持这个基类提供的每个成员。鉴于此,您可以对隐式类型化的myCar对象调用ToString()GetHashCode()Equals()GetType()。假设您的Program类定义了以下静态助手函数:

static void ReflectOverAnonymousType(object obj)
{
  Console.WriteLine("obj is an instance of: {0}",
    obj.GetType().Name);
  Console.WriteLine("Base class of {0} is {1}",
    obj.GetType().Name, obj.GetType().BaseType);
  Console.WriteLine("obj.ToString() == {0}", obj.ToString());
  Console.WriteLine("obj.GetHashCode() == {0}",
    obj.GetHashCode());
  Console.WriteLine();
}

现在假设您调用这个方法,将myCar对象作为参数传入,如下所示:

Console.WriteLine("***** Fun with Anonymous Types *****\n");

// Make an anonymous type representing a car.
var myCar = new {Color = "Bright Pink", Make = "Saab",
  CurrentSpeed = 55};

// Reflect over what the compiler generated.
ReflectOverAnonymousType(myCar);
...

Console.ReadLine();

输出将如下所示:

***** Fun with Anonymous Types *****
obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() = { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() = -564053045

首先,注意,在这个例子中,myCar对象的类型是<>f__AnonymousType03`(您的名字可能不同)。请记住,分配的类型名称完全由编译器决定,并且不能在 C# 代码库中直接访问。

也许最重要的是,注意使用对象初始化语法定义的每个名称-值对都被映射到一个同名的只读属性和一个相应的私有的仅支持init的字段。下面的 C# 代码近似于编译器生成的用于表示myCar对象的类(同样可以使用ildasm.exe进行验证):

private sealed class <>f__AnonymousType0'3'<'<Color>j__TPar',
  '<Make>j__TPar', <CurrentSpeed>j__TPar>'
  extends [System.Runtime][System.Object]
{
  // init-only fields.
  private initonly <Color>j__TPar <Color>i__Field;
  private initonly <CurrentSpeed>j__TPar <CurrentSpeed>i__Field;
  private initonly <Make>j__TPar <Make>i__Field;

  // Default constructor.
  public <>f__AnonymousType0(<Color>j__TPar Color,
    <Make>j__TPar Make, <CurrentSpeed>j__TPar CurrentSpeed);
  // Overridden methods.
  public override bool Equals(object value);
  public override int GetHashCode();
  public override string ToString();

  // Read-only properties.
  <Color>j__TPar Color { get; }
  <CurrentSpeed>j__TPar CurrentSpeed { get; }
  <Make>j__TPar Make { get; }
}

ToString()和 GetHashCode()的实现

所有匿名类型都自动从System.Object派生而来,并提供有Equals()GetHashCode()ToString()的覆盖版本。ToString()实现简单地从每个名称-值对构建一个字符串。这里有一个例子:

public override string ToString()
{
  StringBuilder builder = new StringBuilder();
  builder.Append("{ Color = ");
  builder.Append(this.<Color>i__Field);
  builder.Append(", Make = ");
  builder.Append(this.<Make>i__Field);
  builder.Append(", CurrentSpeed = ");
  builder.Append(this.<CurrentSpeed>i__Field);
  builder.Append(" }");
  return builder.ToString();
}

GetHashCode()实现使用每个匿名类型的成员变量作为System.Collections.Generic.EqualityComparer<T>类型的输入来计算哈希值。使用GetHashCode()的这种实现,当(且仅当)两个匿名类型具有相同的属性集并被赋予相同的值时,它们将产生相同的哈希值。给定这个实现,匿名类型非常适合包含在一个Hashtable容器中。

匿名类型相等的语义

虽然被覆盖的ToString()GetHashCode()方法的实现很简单,但是您可能想知道Equals()方法是如何实现的。例如,如果您要定义两个指定相同名称-值对的“匿名汽车”变量,这两个变量会被认为是相等的吗?为了直接看到结果,用下面的新方法更新您的Program类型:

static void EqualityTest()
{
  // Make 2 anonymous classes with identical name/value pairs.
  var firstCar = new { Color = "Bright Pink", Make = "Saab",
    CurrentSpeed = 55 };
  var secondCar = new { Color = "Bright Pink", Make = "Saab",
    CurrentSpeed = 55 };

  // Are they considered equal when using Equals()?
  if (firstCar.Equals(secondCar))
  {
    Console.WriteLine("Same anonymous object!");
  }
  else
  {
    Console.WriteLine("Not the same anonymous object!");
  }

  // Are they considered equal when using ==?
  if (firstCar == secondCar)
  {
    Console.WriteLine("Same anonymous object!");
  }
  else
  {
    Console.WriteLine("Not the same anonymous object!");
  }

  // Are these objects the same underlying type?
  if (firstCar.GetType().Name == secondCar.GetType().Name)
  {
    Console.WriteLine("We are both the same type!");
  }
  else
  {
    Console.WriteLine("We are different types!");
  }

  // Show all the details.
  Console.WriteLine();
  ReflectOverAnonymousType(firstCar);
  ReflectOverAnonymousType(secondCar);
}

当您调用此方法时,输出可能有些令人惊讶。

My car is a Bright Pink Saab.
You have a Black BMW going 90 MPH
ToString() == { Make = BMW, Color = Black, Speed = 90 }

Same anonymous object!
Not the same anonymous object!
We are both the same type!

obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951

obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951

当您运行这个测试代码时,您将看到您调用Equals()的第一个条件测试返回了true,因此,消息“相同的匿名对象!”打印到屏幕上。这是因为编译器生成的Equals()方法在测试相等性时使用基于值的语义(例如,检查被比较对象的每个字段的值)。

然而,第二个条件测试使用了 C# 的等式操作符(==),打印出“不是同一个匿名对象!”乍一看,这似乎有点违反直觉。这个结果是因为匿名类型没有接收 C# 等式操作符的重载版本(==!=)。鉴于此,当您使用 C# 相等操作符(而不是Equals()方法)测试匿名类型的相等性时,测试的是引用,而不是对象维护的值。

最后,在最后的条件测试中(检查底层类型名),您会发现匿名类型是同一个编译器生成的类类型的实例(在本例中为<>f AnonymousType03),因为firstCarsecondCar具有相同的属性(ColorMakeCurrentSpeed`)。

这说明了重要但微妙的一点:只有当匿名类型包含匿名类型的唯一名称时,编译器才会生成新的类定义。因此,如果在同一个程序集中声明相同的匿名类型(也就是相同的名称),编译器只会生成一个匿名类型定义。

包含匿名类型的匿名类型

可以创建一个由其他匿名类型组成的匿名类型。例如,假设您想要对一个由时间戳、价格点和购买的汽车组成的采购订单进行建模。下面是一个新的(稍微复杂一点)匿名类型,表示这样一个实体:

// Make an anonymous type that is composed of another.
var purchaseItem = new {
  TimeBought = DateTime.Now,
  ItemBought = new {Color = "Red", Make = "Saab", CurrentSpeed = 55},
  Price = 34.000};

ReflectOverAnonymousType(purchaseItem);

至此,您应该理解了用于定义匿名类型的语法,但是您可能仍然想知道在什么地方(以及什么时候)使用这个新的语言特性。坦率地说,匿名类型声明应该尽量少用,通常只在使用 LINQ 技术集时使用(参见第十三章)。鉴于匿名类型的诸多限制,您绝不会为了放弃使用强类型类/结构而放弃使用它们,这些限制包括:

  • 您不能控制匿名类型的名称。

  • 匿名类型总是扩展System.Object

  • 匿名类型的字段和属性总是只读的。

  • 匿名类型不支持事件、自定义方法、自定义运算符或自定义重写。

  • 匿名类型总是隐式密封的。

  • 匿名类型总是使用默认构造函数创建的。

然而,当使用 LINQ 技术集编程时,您会发现,在许多情况下,当您想要快速建模一个实体的整体形状而不是它的功能时,这个语法会很有帮助。

使用指针类型

现在是本章的最后一个主题,它很可能是大多数 C# 中最少使用的特性。净核心项目。

Note

在下面的例子中,我假设你有一些 C++指针操作的背景知识。如果不是这样,请完全跳过这个话题。对于大多数 C# 应用来说,使用指针并不是一项常见的任务。

在第四章中,你了解到。NET 核心平台定义了两大类数据:值类型和引用类型。不过,说实话,还有第三类:指针类型。要使用指针类型,您需要特定的运算符和关键字来绕过。Net 5 运行时的内存管理方案,把事情掌握在自己手中(见表 11-2 )。

表 11-2。

以指针为中心的 C# 运算符和关键字

|

运算符/关键字

|

生命的意义

| | --- | --- | | * | 该操作符用于创建指针变量(即表示内存中直接位置的变量)。与 C++中一样,这个操作符也用于指针间接寻址。 | | & | 该操作符用于获取变量在内存中的地址。 | | -> | 该运算符用于访问由指针表示的类型的字段(C# 点运算符的不安全版本)。 | | [] | 这个操作符(在不安全的上下文中)允许你索引指针变量所指向的槽(如果你是 C++程序员,你会记得指针变量和[]操作符之间的相互作用)。 | | ++-- | 在不安全的上下文中,递增和递减运算符可以应用于指针类型。 | | +- | 在不安全的上下文中,加法和减法运算符可以应用于指针类型。 | | ==!=<><==> | 在不安全的上下文中,比较和相等运算符可以应用于指针类型。 | | Stackalloc | 在不安全的上下文中,stackalloc关键字可以用来直接在堆栈上分配 C# 数组。 | | Fixed | 在不安全的上下文中,fixed关键字可以用来临时修复一个变量,以便可以找到它的地址。 |

现在,在深入细节之前,让我再次指出,你将很少需要使用指针类型。尽管 C# 确实允许您下降到指针操作的级别,但是要理解。NET 核心运行时完全不知道你的意图。因此,如果你对一个指针管理不当,你就是负责处理后果的人。考虑到这些警告,什么时候需要使用指针类型呢?有两种常见情况。

  • 您希望通过在管理之外直接操作内存来优化应用的选定部分。NET 5 运行时。

  • 您正在调用基于 C 的.dll或 COM 服务器的方法,这些方法需要指针类型作为参数。即使在这种情况下,你也可以绕过指针类型,转而使用System.IntPtr类型和System.Runtime.InteropServices.Marshal类型的成员。

当您决定利用 C# 语言的这一特性时,您需要通过使您的项目支持“不安全代码”来告知 C# 编译器您的意图。创建一个名为 UnsafeCode 的新控制台应用项目,并通过将以下内容添加到UnsafeCode.csproj文件中,将该项目设置为支持不安全代码:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Visual Studio 提供了一个 GUI 来设置此属性。访问项目的属性页,导航到 Build 选项卡,选择顶部的所有配置,然后选中“允许不安全的代码”框。见图 11-1 。

img/340876_10_En_11_Fig1_HTML.jpg

图 11-1。

使用 Visual Studio 启用不安全代码

不安全的关键字

当您想在 C# 中使用指针时,您必须使用unsafe关键字明确声明一个“不安全代码”块(任何没有用unsafe关键字标记的代码都自动被认为是“安全的”)。例如,下面的Program类在安全顶级语句中声明了不安全代码的范围:

using System;
using UnsafeCode;

Console.WriteLine("***** Calling method with unsafe code *****");

unsafe
{
  // Work with pointer types here!
}
// Can't work with pointers here!

除了在方法中声明不安全代码的范围之外,您还可以构建“不安全”的结构、类、类型成员和参数下面是几个例子(不需要在当前项目中定义NodeNode2类型):

// This entire structure is "unsafe" and can
// be used only in an unsafe context.
unsafe struct Node
{
  public int Value;
  public Node* Left;
  public Node* Right;
}

// This struct is safe, but the Node2* members
// are not. Technically, you may access "Value" from
// outside an unsafe context, but not "Left" and "Right".
public struct Node2
{
  public int Value;

  // These can be accessed only in an unsafe context!
  public unsafe Node2* Left;
  public unsafe Node2* Right;
}

方法(静态或实例级)也可能被标记为不安全的。例如,假设您知道静态方法将使用指针逻辑。为了确保只能从不安全的上下文中调用该方法,可以将该方法定义如下:

static unsafe void SquareIntPointer(int* myIntPointer)
{
  // Square the value just for a test.
  *myIntPointer *= *myIntPointer;
}

您的方法的配置要求调用者如下调用SquareIntPointer():

  unsafe
  {
    int myInt = 10;

    // OK, because we are in an unsafe context.
    SquareIntPointer(&myInt);
    Console.WriteLine("myInt: {0}", myInt);
  }

  int myInt2 = 5;

  // Compiler error! Must be in unsafe context!
  SquareIntPointer(&myInt2);
  Console.WriteLine("myInt: {0}", myInt2);

如果您不想强迫调用者在不安全的上下文中包装调用,您可以用不安全的块包装所有的顶级语句。如果你使用一个Main()方法作为入口点,你可以用unsafe关键字更新Main()。在这种情况下,将编译以下代码:

static unsafe void Main(string[] args)
{
  int myInt2 = 5;
  SquareIntPointer(&myInt2);
  Console.WriteLine("myInt: {0}", myInt2);
}

如果您运行这个版本的code,您将看到以下输出:

myInt: 25

Note

值得注意的是,选择术语不安全是有原因的。直接访问堆栈和使用指针会导致应用以及运行它的机器出现意外问题。如果你不得不用不安全的代码工作,要格外勤奋。

使用*和&运算符

在你建立了一个不安全的上下文之后,你就可以使用*操作符构建指向数据类型的指针,并使用&操作符获得所指向的地址。与 C 或 C++不同,在 C# 中,*运算符只应用于基础类型,而不是作为每个指针变量名的前缀。例如,考虑下面的代码,它说明了声明指向整型变量的指针的正确和不正确的方法:

// No! This is incorrect under C#!
int *pi, *pj;

// Yes! This is the way of C#.
int* pi, pj;

考虑以下不安全的方法:

static unsafe void PrintValueAndAddress()
{
  int myInt;

  // Define an int pointer, and
  // assign it the address of myInt.
  int* ptrToMyInt = &myInt;

  // Assign value of myInt using pointer indirection.
  *ptrToMyInt = 123;

  // Print some stats.
  Console.WriteLine("Value of myInt {0}", myInt);
  Console.WriteLine("Address of myInt {0:X}", (int)&ptrToMyInt);
}

如果您从不安全的块中运行此方法,您将看到以下输出:

**** Print Value And Address ****
Value of myInt 123
Address of myInt 90F7E698

不安全(和安全)的交换函数

当然,仅仅为了赋值而声明指向局部变量的指针(如前一个例子)从来都不是必需的,也不是完全有用的。为了说明不安全代码的一个更实际的例子,假设您想使用指针算法构建一个交换函数。

unsafe static void UnsafeSwap(int* i, int* j)
{
  int temp = *i;
  *i = *j;
  *j = temp;
}

很 C 的样子,你不觉得吗?但是,鉴于您之前的工作,您应该知道您可以使用 C# ref关键字编写以下安全版本的交换算法:

static void SafeSwap(ref int i, ref int j)
{
  int temp = i;
  i = j;
  j = temp;
}

每个方法的功能都是相同的,因此强调了直接指针操作不是 C# 下的强制任务这一点。下面是使用安全顶级语句和不安全上下文的调用逻辑:

Console.WriteLine("***** Calling method with unsafe code *****");

// Values for swap.
int i = 10, j = 20;

// Swap values "safely."
Console.WriteLine("\n***** Safe swap *****");
Console.WriteLine("Values before safe swap: i = {0}, j = {1}", i, j);
SafeSwap(ref i, ref j);
Console.WriteLine("Values after safe swap: i = {0}, j = {1}", i, j);

// Swap values "unsafely."
Console.WriteLine("\n***** Unsafe swap *****");
Console.WriteLine("Values before unsafe swap: i = {0}, j = {1}", i, j);
unsafe { UnsafeSwap(&i, &j); }

Console.WriteLine("Values after unsafe swap: i = {0}, j = {1}", i, j);
Console.ReadLine();

通过指针访问字段(运算符->运算符)

现在假设您已经定义了一个简单、安全的Point结构,如下所示:

struct Point
{
  public int x;
  public int y;

  public override string ToString() => $"({x}, {y})";
}

如果你声明一个指向Point类型的指针,你将需要使用指针字段访问操作符(由->表示)来访问它的公共成员。如表 11-2 所示,这是标准(安全)点运算符(.的不安全版本。事实上,使用指针间接操作符(*),可以取消引用指针(再次)应用点操作符符号。检查不安全的方法:

static unsafe void UsePointerToPoint()
{
  // Access members via pointer.
  Point;
  Point* p = &point;
  p->x = 100;
  p->y = 200;
  Console.WriteLine(p->ToString());

  // Access members via pointer indirection.
  Point point2;
  Point* p2 = &point2;
  (*p2).x = 100;
  (*p2).y = 200;
  Console.WriteLine((*p2).ToString());
}

stackalloc 关键字

在不安全的上下文中,您可能需要声明一个局部变量,该变量直接从调用堆栈分配内存(因此不受。NET 核心垃圾收集)。为此,C# 提供了stackalloc关键字,它相当于 C 运行时库的_alloca函数。这里有一个简单的例子:

static unsafe string UnsafeStackAlloc()
{
  char* p = stackalloc char[52];
  for (int k = 0; k < 52; k++)
  {
    p[k] = (char)(k + 65)k;
  }
  return new string(p);
}

通过 fixed 关键字固定类型

正如您在前面的例子中看到的,在不安全的上下文中分配一块内存可能会通过关键字stackalloc变得更容易。由于这种操作的本质,一旦分配方法返回(从堆栈中获取内存),分配的内存就会被清除。然而,假设一个更复杂的例子。在我们检查->操作符的过程中,您创建了一个名为Point的值类型。像所有值类型一样,一旦执行范围终止,分配的内存将弹出堆栈。为了便于讨论,假设Point被定义为引用类型,如下所示:

class PointRef // <= Renamed and retyped.
{
  public int x;
  public int y;
  public override string ToString() => $"({x}, {y})";
}

如您所知,如果调用者声明了一个类型为Point的变量,那么内存将被分配到垃圾收集堆中。紧迫的问题变成了“如果一个不安全的上下文想要与这个对象(或者堆上的任何对象)交互怎么办?”考虑到垃圾收集可能在任何时候发生,想象一下当访问Point的成员时遇到的问题,就在这样一个正在进行堆清理的时间点上。从理论上讲,不安全上下文有可能试图与一个不再可访问的成员进行交互,或者在经历了分代清除之后被重新定位到堆上(这是一个明显的问题)。

为了从不安全的上下文中锁定内存中的引用类型变量,C# 提供了fixed关键字。fixed语句设置一个指向托管类型的指针,并在代码执行期间“固定”该变量。如果没有fixed,指向托管变量的指针将没有什么用处,因为垃圾收集可能会不可预测地重新定位变量。(事实上,除了在fixed语句中,C# 编译器不允许你设置指向托管变量的指针。)

因此,如果您创建了一个PointRef对象并希望与其成员交互,您必须编写以下代码(否则会收到一个编译器错误):

unsafe static void UseAndPinPoint()
{
  PointRef pt = new PointRef
  {
    x = 5,
    y = 6
  };

  // Pin pt in place so it will not
  // be moved or GC-ed.
  fixed (int* p = &pt.x)
  {
    // Use int* variable here!
  }

  // pt is now unpinned, and ready to be GC-ed once
  // the method completes.
  Console.WriteLine ("Point is: {0}", pt);
}

简而言之,fixed关键字允许您构建一个锁定内存中引用变量的语句,这样它的地址在语句(或作用域块)的持续时间内保持不变。任何时候在不安全代码的上下文中与引用类型进行交互时,都必须锁定引用。

sizeof 关键字

最后要考虑的以不安全为中心的 C# 关键字是sizeof。与 C++一样,C# sizeof关键字用于获取内在数据类型的字节大小,但不是自定义类型,除非在不安全的上下文中。例如,下面的方法不需要声明为“不安全”,因为sizeof关键字的所有参数都是内部类型:

static void UseSizeOfOperator()
{
  Console.WriteLine("The size of short is {0}.", sizeof(short));
  Console.WriteLine("The size of int is {0}.", sizeof(int));
  Console.WriteLine("The size of long is {0}.", sizeof(long));
}

但是,如果您想获得自定义的Point结构的大小,您需要更新这个方法(注意已经添加了unsafe关键字):

unsafe static void UseSizeOfOperator()
{
...
  unsafe {
    Console.WriteLine("The size of Point is {0}.", sizeof(Point));
  }
}

以上介绍了 C# 编程语言的一些更高级的特性。为了确保我们都在同一页上,我必须再次说,你的大部分。NET 项目可能永远不需要直接使用这些特性(尤其是指针)。然而,正如你将在后面的章节中看到的,当使用 LINQ API 时,有些主题即使不是必需的,也是非常有用的,尤其是扩展方法和匿名类型。

摘要

本章的目的是加深你对 C# 编程语言的理解。首先,您研究了各种高级类型构造技术(索引器方法、重载操作符和自定义转换例程)。

接下来,您研究了扩展方法和匿名类型的作用。正如你将在第十三章中看到的一些细节,这些特性在使用以 LINQ 为中心的 API 时很有用(尽管你可以在代码中的任何地方使用它们,如果它们有用的话)。回想一下,匿名方法允许您快速地为类型的“形状”建模,而扩展方法允许您为类型添加新的功能,而不需要子类化。

在本章的剩余部分,您研究了一小组鲜为人知的关键字(sizeofunsafe等)。)并在此过程中学习了如何使用原始指针类型。正如在整个指针类型的研究中所说的,你的大多数 C# 应用永远不需要使用它们。