• C#高级编程第11版


    导航

    第七章 Arrays

    7.1 相同类型的多个对象

    假如你需要处理同一类型的多个对象,你可以使用集合或者数组。C#有自己特别的语法来定义、初始化和使用数组。在后台,数组提供了相当多的方法来排序和过滤数组内部元素。使用枚举器(enumerator),你可以遍历数组的所有元素。

    注意:如果你想使用不同类型的不同对象,你最好将它们组合成class、struct或者元组。第三章我们已经介绍过class和struct了。元组tuple我们将在第13章介绍它。

    7.2 简单数组

    7.2.1 数组的声明

    数组是一种数据结构,它包含一系列同类型的元素。数组通过声明内部元素类型,并随后紧跟着一对中括号来定义一个数组变量。举个例子,假如你要定义整型数组,你可能会像这么写:

    int[] myArray;
    

    7.2.2 数组的初始化

    一旦声明了一个数组,系统会直接划分出能容纳整个数组元素的内存空间。数组是引用类型,所以在托管堆上分配实际的内存。你可以通过new关键字来初始化一个数组变量,包含数组元素类型以及元素个数,例如你这么初始化:

    myArray = new int[4];
    

    通过前面的定义和初始化,变量myArray将是4个整型值的引用,并且这4个整型值的内存由托管堆进行分配,如下图所示:

    数组变量内存分配情况

    注意:一旦一个元素完成初始化,它无法简单地调整自己的大小。假如你事先并不知道数组的元素数量,你可以使用集合,集合将在第10章进行介绍。

    你也可以将声明和初始化在一行代码里完成:

    int[] myArray = new int[4];
    

    还可以在初始化时同时指定数组的具体元素:

    int[] myArray = new int[4] {4, 7, 11, 2};
    

    因为你指定了具体的元素了,其实这个数量4有点多余,可以省略不写:

    int[] myArray = new int[] {4, 7, 11, 2};
    

    而C#编译器还为你提供了一种更加简便的写法,咱把new int[]也省略了:

    int[] myArray = {4, 7, 11, 2};
    

    7.2.3 访问数组元素

    当数组声明与初始化之后,你可以通过索引访问数组元素。数组只支持的索引参数为整型的索引。

    通过索引,你传递元素序号给数组。元素序号以0开始,代表第一个元素。因此,序号的最大值为数组长度减1。让我们接着用上面定义的myArray变量来举例,它有4个元素,它的元素序号就是0,1,2,4 - 1=3:

    int[] myArray = new int[] {4, 7, 11, 2};
    int v1 = myArray[0]; // 读取第一个元素
    int v2 = myArray[1]; // 第二个元素
    myArray[3] = 44; // 修改第四个元素,注意序号为3
    

    注意:假如你使用了一个越界的序号,你将会得到一个IndexOutOfRangeException。

    你可以通过数组的Length属性来访问数组的长度,例如这样子:

    for (int i = 0; i < myArray.Length; i++)
    {
    	Console.WriteLine(myArray[i]);
    }
    

    你也可以用foreach来遍历数组:

    foreach (var val in myArray)
    {
    	Console.WriteLine(val);
    }
    

    注意:foreach语句使用了IEnumerable和IEnumerator接口,并且遍历数组中的每个元素。本章稍后将会展开介绍。

    7.2.4 使用引用类型

    除了可以定义预定义类型的数组之外,你也可以将自定义类型声明成数组。下面我们将以Person类进行介绍,它的定义如下所示:

    public class Person
    {
    	public Person(string firstName, string lastName)
    	{
    		FirstName = firstName;
    		LastName = lastName;
    	}
    	public string FirstName { get; }
    	public string LastName { get; }
    	public override string ToString() => $"{FirstName} {LastName}";
    }
    

    你可以像下面这样定义一个存储俩个Person实例的数组对象:

    Person[] myPersons = new Person[2];
    

    尽管如此,你需要注意到数组中的元素是引用类型,它们也需要分配相应的内存。假如你使用了某个数组元素,它尚未分配内存,此时你想使用它的成员的话,就会抛出一个NullReferenceException。

    因此你需要对数组中的每个元素都进行内存分配,例如这样:

    myPersons[0] = new Person("Ayrton", "Senna");
    myPersons[1] = new Person("Michael", "Schumacher");
    

    内存中的情况实际上是这样子的:

    引用类型数组内存分配

    其中数组变量myPersons是一个引用地址,它存储在栈上。而它指向一个带有两个Person引用地址的数组,实际指向托管堆上的两个不同Person对象实例。

    跟int类型类似,你也可以使用大括号这样的初始化器直接声明数组变量,省略多余的部分:

    Person[] myPersons2 =
    {
    	new Person("Ayrton", "Senna"),
    	new Person("Michael", "Schumacher")
    };
    

    7.3 多维数组

    通常的数组(或者说一维数组)通过一个整数进行索引,而多维数组则是通过2个或者更多的整数进行索引。

    下图显示了一个3行3列的二维数组的数学定义,第一行的数据为1,2,3,而第三行的数据则为7,8,9。

    二维数组

    C#里声明二维数组,你可以在中括号中使用一个逗号。数组通过指定每个维度的大小(也被称之为数组的排列,rank)进行初始化。然后你就可以通过2个整数作为索引来访问其中的数组元素:

    int[,] twodim = new int[3, 3];
    

    注意:声明完数组之后,你无法修改其大小。

    假如你事先就知道数组中的元素值,你也可以通过使用数组索引器来为每个位置指定值:

    twodim[0, 0] = 1;
    twodim[0, 1] = 2;
    twodim[0, 2] = 3;
    twodim[1, 0] = 4;
    twodim[1, 1] = 5;
    twodim[1, 2] = 6;
    twodim[2, 0] = 7;
    twodim[2, 1] = 8;
    twodim[2, 2] = 9;
    

    你也可以通过使用{}包裹起来的每一行数据来声明数组,并且所有行的数据也包含在一对{}中:

    int[,] twodim = {
    	{1, 2, 3},
    	{4, 5, 6},
    	{7, 8, 9}
    };
    

    注意:假如你试图使用这种方式声明数组,你必须指定每一个元素的值。你无法事先留空某个位置不做任何声明,而试图在之后再指定该位置的值。

    通过在中括号里使用两个逗号,你可以声明一个三维数组:

    int[,,] threedim = {
    	{ { 1, 2 }, { 3, 4 } },
    	{ { 5, 6 }, { 7, 8 } },
    	{ { 9, 10 }, { 11, 12 } }
    };
    Console.WriteLine(threedim[0, 1, 1]);
    

    7.4 锯齿数组

    二维数组是矩形尺寸,例如,3×3大小的元素。而锯齿数组(jagged array)提供了一种更灵活的方式来使用数组,通过它每一行可以是不同的尺寸。

    如下图所示,左边的二维数组拥有一个3×3大小的尺寸。而与之相比,右侧的锯齿数组虽然也包括3行,但每一行的数据量都不同,第一行拥有2个元素,第二行拥有6个元素,第三行则拥有3个元素。

    二维数组和锯齿数组

    锯齿数组是通过两对[]进行声明的,初始化锯齿数组时,仅仅只需要指定第一个[]的值,因为它代表具体有多少行。而第二个[]用来定义每一行的元素数量,这里需要留空,因为每一行拥有不同数量的元素。接下来我们初始化每一行的元素:

    int[][] jagged = new int[3][]; // 假如你指定了第二个中括号的大小,则会得到一个编译错误CS0178
    jagged[0] = new int[2] { 1, 2 };
    jagged[1] = new int[6] { 3, 4, 5, 6, 7, 8 };
    jagged[2] = new int[3] { 9, 10, 11 };
    

    你可以通过嵌套for循环来遍历锯齿数组的所有元素,外层的for循环遍历了每一行,而内部的for循环则遍历行内的每个元素:

    for (int row = 0; row < jagged.Length; row++)
    {
    	for (int element = 0; element < jagged[row].Length; element++)
    	{
    		Console.WriteLine($"row: {row}, element: {element}, " + $"value: {jagged[row][element]}");
    	}
    }
    

    输出如下所示:

    row: 0, element: 0, value: 1
    row: 0, element: 1, value: 2
    row: 1, element: 0, value: 3
    row: 1, element: 1, value: 4
    row: 1, element: 2, value: 5
    row: 1, element: 3, value: 6
    row: 1, element: 4, value: 7
    row: 1, element: 5, value: 8
    row: 2, element: 0, value: 9
    row: 2, element: 1, value: 10
    row: 2, element: 2, value: 11
    

    7.5 Array 类

    通过括号定义数组是C#的记法(notation),实际上它用到的是Array类。使用C#的数组创建语法,后台(behind the scenes)实际上创建的是一个新的类(create a new class),这个新类派生自虚拟基类Array。这个机制使得你可以在每一个C#数组中,使用Array类里定义的方法和属性。例如,你可能已经用过Length属性又或者使用foreach语句来遍历数组中的所有元素。而想做到这一点,你实际上使用的是Array类中定义的GetEnumerator方法。

    另外一些Array类实现的属性,如LongLength,来表示那些大型数组,数量远超int所能代表的范围;又譬如Rank,用来获取数组的维度。

    7.5.1 创建数组

    因为Array类是抽象类,因此你无法通过构造函数创建一个数组的实例对象。然而,除了使用上面介绍的C#语法之外,你仍然可以通过使用Array类中的CreateInstance静态方法来创建数组实例。当你无法事先知道数组中的元素类型的时候,这个方法会特别管用,因为你可以将实际类型作为一个Type实例传递给CreateInstance方法。

    下面是一个使用CreateInstance方法创建一个长度为5的int类型数组的例子。方法的第一个参数传递的是元素的类型,而第二个参数则定义了数组的大小。你可以通过SetValue方法来设置数组的值,并通过GetValue方法来获取它:

    Array intArray1 = Array.CreateInstance(typeof(int), 5);
    for (int i = 0; i < 5; i++)
    {
    	intArray1.SetValue(33, i);
    }
    for (int i = 0; i < 5; i++)
    {
    	Console.WriteLine(intArray1.GetValue(i));
    }
    

    你也可以通过强制转换将Array实例转换成int[]类型:

    int[] intArray2 = (int[])intArray1;
    

    CreateInstance方法有着多个重载版本,用来创建多维数组或者非0下限(not 0 based)的数组。下面的例子创建了一个二维数组,包含2×3个元素。其中一维是从1开始的(1 based),而二维则从10开始(10 based):

    int[] lengths = { 2, 3 };
    int[] lowerBounds = { 1, 10 };
    Array racers = Array.CreateInstance(typeof(Person), lengths, lowerBounds);
    

    给racers数组赋值的时候,SetValue方法可以设置每个维度的索引(indices for every dimension):

    racers.SetValue(new Person("Alain", "Prost"), 1, 10);
    racers.SetValue(new Person("Emerson", "Fittipaldi", 1, 11);
    racers.SetValue(new Person("Ayrton", "Senna"), 1, 12);
    racers.SetValue(new Person("Michael", "Schumacher"), 2, 10);
    racers.SetValue(new Person("Fernando", "Alonso"), 2, 11);
    racers.SetValue(new Person("Jenson", "Button"), 2, 12);
    

    虽然此Array并非以0为下限,你也可以通过C#记法将它赋值给另外一个数组变量,只是你要小心不要越过数组的边界:

    Person[,] racers2 = (Person[,])racers;
    Person first = racers2[1, 10];
    Person last = racers2[2, 12];
    

    7.5.2 复制数组

    因为数组是引用类型,将一个数组变量赋值给另外一个变量,仅仅只是为你创建了一个新的引用,指向同一个数组。

    为了复制数组,array实现了ICloneable接口,其中定义了Clone方法,这个方法创建数组的浅拷贝(shallow copy)。

    假如数组的元素都是值类型,就像下面代码段:

    int[] intArray1 = {1, 2};
    int[] intArray2 = (int[])intArray1.Clone();
    

    Clone方法执行完成后,内存中的情况如下图所示:

    值类型数组的Clone

    而假如数组包含引用类型,Clone方法仅仅拷贝引用,而非所有元素。假定我们这会有beatles和beatlesClone两个变量,其中beatlesClone是通过beatles的Clone方法进行创建的:

    Person[] beatles = {
    	new Person { FirstName="John", LastName="Lennon" },
    	new Person { FirstName="Paul", LastName="McCartney" }
    };
    Person[] beatlesClone = (Person[])beatles.Clone();
    

    它们在内存中的分配如下所示:

    引用类型Clone

    假如你在beatlesClone中修改了某个元素的属性,那么实际上你修改的也是beatles中元素的属性。

    除了使用Clone方法之外,你也可以使用Array.Copy方法,它也提供一份浅拷贝。然而,这两者之间有一个最重要的区别:Clone方法返回的是一个新的数组;而Copy方法你需要先创建一个同样大小的数组,然后将它作为参数传递给Copy方法。

    注意:假如你需要对引用类型数组提供深拷贝,你需要遍历整个数组,并创建新的对象实例。

    7.5.3 排序

    Array类使用的是快速排序算法(QuickSort)来对数组中的元素进行排序。Sort方法需要数组内的元素实现IComparable接口。普通类型例如System.String和System.Int32也实现了IComparable,所以你也可以对这些类型进行排序。

    下面提供了一个例子,其中数组元素为string类型,并且它可以被排序:

    string[] names = {
    	"Christina Aguilera",
    	"Shakira",
    	"Beyonce",
    	"Lady Gaga"
    };
    Array.Sort(names);
    foreach (var name in names)
    {
    	Console.WriteLine(name);
    }
    

    排序后在控制台上的输出如下所示:

    Beyonce
    Christina Aguilera
    Lady Gaga
    Shakira
    

    如果你是使用自定义的类作为数组的元素,想要通过Sort方法进行排序,你需要为你的类实现IComparable接口。这个接口仅仅只定义了一个方法CompareTo,当两个比较的对象是相等的时候,它返回0;负数则表示当前实例应该排在参数的前面,而正数则相反。

    让我们修改一下Person类,为它实现IComparable<Person>接口。首先我们通过使用String类的Compare方法对LastName进行比较,假如LastName相同的话,接着比较FirstName:

    public class Person: IComparable<Person>
    {
    	public int CompareTo(Person other)
    	{
    		if (other == null) return 1;
    		int result = string.Compare(this.LastName, other.LastName);
    		if (result == 0)
    		{
    			result = string.Compare(this.FirstName, other.FirstName);
    		}
    		return result;
    	}
        //…
    }
    

    现在你就可以对Person数组使用Sort方法了:

    Person[] persons = {
    	new Person("Damon", "Hill"),
    	new Person("Niki", "Lauda"),
    	new Person("Ayrton", "Senna"),
    	new Person("Graham", "Hill")
    };
    Array.Sort(persons);
    foreach (var p in persons)
    {
    	Console.WriteLine(p);
    }
    

    排序后的内容如下所示:

    Damon Hill
    Graham Hill
    Niki Lauda
    Ayrton Senna
    

    假如Person对象需要其他的排序方式,又或者你无法修改已经成为某些数组元素的类,你可以另外实现IComparer或者IComparer<T>接口。这些接口里定义了Compare方法。那些处理比较过程的类需要实现这个接口,这也是为何Compare方法定义了两个需要进行比较的参数。返回值则与IComparable接口的CompareTo方法很类似。

    下面的PersonComparer类实现了IComparer<Person>接口来根据firstName或者lastName排序Person实例。PersonCompareType枚举定义了不同的排序选项,在构造函数里设置了使用何种排序。Compare方法则是通过switch语句进行实现:

    public enum PersonCompareType
    {
    	FirstName,
    	LastName
    } 
    public class PersonComparer: IComparer<Person>
    {
    	private PersonCompareType _compareType;
    	public PersonComparer(PersonCompareType compareType) => _compareType = compareType;
    	public int Compare(Person x, Person y)
    	{
    		if (x is null && y is null) return 0;
    		if (x is null) return 1;
    		if (y is null) return -1;
    		switch (_compareType)
    		{
    			case PersonCompareType.FirstName:
    				return string.Compare(x.FirstName, y.FirstName);
    			case PersonCompareType.LastName:
    				return string.Compare(x.LastName, y.LastName);
    			default:
    				throw new ArgumentException("unexpected compare type");
    		}
    	}
    }
    

    现在你可以创建一个PersonComparer对象,将它作为第二个参数传递给Array.Sort方法。这里,Person类根据firstName进行排序:

    Array.Sort(persons, new PersonComparer(PersonCompareType.FirstName));
    foreach (var p in persons)
    {
    	Console.WriteLine(p);
    }
    

    输出结果如下所示:

    Ayrton Senna
    Damon Hill
    Graham Hill
    Niki Lauda
    

    注意:Array类同样提供了接收委托作为参数的Sort方法。通过这个参数,你可以直接传递一个方法来对两个对象进行比较而非依赖于IComparable或者IComparer接口。第八章将会介绍如何使用委托。

    7.6 数组作为参数

    数组可以作为参数传递给方法,也可以作为方法的返回值。为了返回一个数组,你只需要声明相应的数组类型作为返回类型,就像下面这个GetPersons方法这样:

    static Person[] GetPersons() =>
    	new Person[] {
    		new Person("Damon", "Hill"),
    		new Person("Niki", "Lauda"),
    		new Person("Ayrton", "Senna"),
    		new Person("Graham", "Hill")
    };
    

    同样地你将参数声明成数组类型,你就可以为方法传递相应的数组作为参数,如下面的DisplayPersons方法所示:

    static void DisplayPersons(Person[] persons)
    {
    	//…
    }
    

    7.7 数组协变

    数组支持协变。这意味着数组可以被定义成基础类型,但是数组元素可以赋值成派生类型。

    例如,你声明了类型为object[]的方法参数,你也可以直接为方法传递Person[]类型的数组作为参数,如下所示:

    static void DisplayArray(object[] data)
    {
    	//…
    }
    

    注意:数组的协变仅仅对引用类型有效,对值类型是不起作用的。另外,数组协变有个小问题,只能在运行时通过异常进行处理(can only be resolved with runtime exceptions)。假如你将一个Person数组赋值给一个Object数组,这个Object数组可以被用在Object的派生类上。例如,编译器会允许你为这个数组赋值一个string类型的元素,编译器并不会提示错误。而当你实际运行的时候,因为这个Object数组实际上指向的是Person类型的数组,试图给Person对象相应的内存赋值一个string类型会引发一个运行时错误:ArrayTypeMismatchException。

    7.8 枚举

    通过使用foreach语句你可以遍历集合中的所有元素(第10章介绍),而不需要提前知道集合中元素的个数。实际上foreach语句使用了一个枚举器(enumerator)。下图说明了客户端(client)是如何调用foreach方法和集合的。

    foreach调用堆栈

    数组或者集合需要实现IEnumerable接口里的GetEnumerator方法,这个方法返回了一个实现了IEnumerator接口的枚举器对象。然后foreach语句遍历的是IEnumerator对象来遍历集合里的所有元素。

    注意:GetEnumerator方法定义在IEnumerable接口中。foreach语句并非一定需要集合类实现这个接口。只需要集合类中有个叫GetEnumerator的方法并且它返回的对象实现了IEnumerator接口即可。

    7.8.1 IEnumerator 接口

    foreach语句使用IEnumerator接口对象中的方法和属性来遍历集合里的素有元素。为了实现这一点,IEnumerator中定义了一个Current属性,用来返回当前游标(Cursor)指向集合中的哪个元素,然后定义了方法MoveNext,来访问集合中的下一个元素。假如仍然存在下一个元素,MoveNext方法则返回true,假如当前已经是集合的最后一个元素了,则返回false。

    泛型版本的IEnumerator<T>接口派生自IDisposable接口,因此它定义了Dispose方法来清除枚举器申请的内存资源。

    注意:IEnumerator接口同样为COM互操作(interoperability)定义了Reset方法。许多.NET枚举器仅仅在这个方法中抛出了一个NotSupportedException异常。

    7.8.2 foreach 语句

    C#的foreach语句在IL代码中并不是以foreach语句的形式生成的,而是将其转换成了IEnumerator接口相应的方法和属性。对于上面的代码,我们可以看见IL中的代码是这样子的,其中并没有foreach的字样:

    foreach的IL代码

    这里我们简单地假设有一个foreach语句来遍历persons数组里的所有Person元素并依次输出到控制台上:

    foreach (var p in persons)
    {
    	Console.WriteLine(p);
    }
    

    foreach语句实际上执行的是下面这样的代码段:

    IEnumerator<Person> enumerator = persons.GetEnumerator();
    while (enumerator.MoveNext())
    {
    	Person p = enumerator.Current;
    	Console.WriteLine(p);
    }
    

    首先,调用数组对象的GetEnumerator方法返回一个数组的枚举器。然后在while循环中,只要MoveNext方法返回true,你就可以通过Current属性来访问数组当前的元素。

    7.8.3 yield 语句

    从C#第一次发布开始,使用foreach语句就可以简单地遍历集合元素。在C#1.0的时候,创建一个枚举器仍然需要做很多工作。而C#2.0开始,新增了yield语句,以便你能更加轻松地创建枚举器。yield return语句返回集合中的一个元素,并且移动指针到下一个元素,而yield break则停止这次遍历。

    下面的例子演示了如何使用yield return语句来实现一个简单集合的枚举器。HelloCollection类包含了方法GetEnumerator。这个方法中包含了两个yield return语句,返回了两个字符串,一个"Hello",一个"World":

    using System;
    using System.Collections;
    namespace Wrox.ProCSharp.Arrays
    {
    	public class HelloCollection
    	{
    		public IEnumerator<string> GetEnumerator()
    		{
    			yield return "Hello";
    			yield return "World";
    		}
    	}
    }
    

    注意:包含有yield语句的方法或者属性也被称为迭代器(iterator block)。一个迭代器必须返回的是IEnumerator接口或者IEnumerable接口,或者这些接口的泛型版本。迭代器中可能包含有多个yield return或者yield break语句,单纯的return语句不允许在这里使用。

    现在你就可以通过foreach语句来遍历这个HelloCollection集合了:

    public void HelloWorld()
    {
    	var helloCollection = new HelloCollection();
    	foreach (var s in helloCollection)
    	{
    		Console.WriteLine(s);
    	}
    }
    

    通过迭代器,编译器生成了yield类型,包含一个状态机(state machine),就像下面的代码片段这样子:

    public class HelloCollection
    {
    	public IEnumerator GetEnumerator() => new Enumerator(0);
    	public class Enumerator: IEnumerator<string>, IEnumerator, IDisposable
    	{
    		private int _state;
    		private string _current;
    		public Enumerator(int state) => _state = state;
    		bool System.Collections.IEnumerator.MoveNext()
    		{
    			switch (state)
    			{
    				case 0:
    					_current = "Hello";
    					_state = 1;
    					return true;	
    				case 1:
    					_current = "World";
    					_state = 2;
    					return true;
    				case 2:
    					break;
    			}
    			return false;
    		}
    		void System.Collections.IEnumerator.Reset() => throw new NotSupportedException();
    		string System.Collections.Generic.IEnumerator<string>.Current => current;
    		object System.Collections.IEnumerator.Current => current;
    		void IDisposable.Dispose() { }
    	}
    }
    

    yield类型实现了IEnumerator接口和IDisposable接口中定义的属性和方法。在上面的例子中,你可以看到yield类是内部类Enumerator。外部类中的GetEnumerator方法实例化并且返回一个新的yield类型。在这个yield类型里,变量state定义了当前遍历的位置,并且在MoveNext方法被调用时修改。MoveNext方法封装了迭代器的代码,并且同时设置current变量的值所以Current属性可以返回当前位置的对象。

    注意:记住yield语句生成了一个枚举器,而非是一个item列表。这个枚举器是被foreach语句调用的。因为foreach调用过程中的每一项都会调用到这个枚举器。这使得它可以逐步遍历大量的数据而非需要一次性地将所有数据加载到内存中。

    7.8.4 遍历集合的其他方式

    比起上面这个Hello World示例,有一种稍微现实一些的yield return语句的使用方式。MusicTitles类中允许在GetEnumerator方法中通过默认的方式遍历标题,然后Reverse方法提供反序遍历,而通过Subset方法来访问子集:

    public class MusicTitles
    {
    	string[] names = {"Tubular Bells", "Hergest Ridge", "Ommadawn", "Platinum"};
    	public IEnumerator<string> GetEnumerator()
    	{
    		for (int i = 0; i < 4; i++)
    		{
    			yield return names[i];
    		}
    	}
    	public IEnumerable<string> Reverse()
    	{
    		for (int i = 3; i >= 0; i—)
    		{
    			yield return names[i];
    		}
    	}
    	public IEnumerable<string> Subset(int index, int length)
    	{
    		for (int i = index; i < index + length; i++)
    		{
    			yield return names[i];
    		}
    	}
    }
    

    注意:类默认支持的迭代方法(iteration)是GetEnumerator方法,返回IEnumerator对象。命名迭代(Named iterations)则返回IEnumerable对象。

    下面这个例子中,客户端代码首先通过GetEnumerator方法遍历字符串数组,这个方法不用你手动写代码进行调用,因为foreach语句会默认帮你调用这个实现。然后标题被反序遍历,并且最后通过传递索引和元素数量,访问了其中某个子集:

    var titles = new MusicTitles();
    foreach (var title in titles)
    {
    	Console.WriteLine(title);
    }
    Console.WriteLine();
    Console.WriteLine("reverse");
    foreach (var title in titles.Reverse())
    {
    	Console.WriteLine(title);
    }
    Console.WriteLine();
    Console.WriteLine("subset");
    foreach (var title in titles.Subset(2, 2))
    {
    	Console.WriteLine(title);
    }
    

    7.8.5 通过yield return返回枚举器

    通过yield语句你可以做更多复杂的东西,比如说通过yield return语句返回一个枚举器。下面我们以一个Tic-Tac-Toe井字棋游戏作为例子,玩家可以交替在9宫格中的某个位置画上×或者○。这些移动我们在GameMove类中进行模拟。其中Cross和Circle方法用来创建迭代类型的迭代器。变量cross和circle在构造函数里设置为Cross和Circle枚举器。设置这些字段的时候,方法并未被调用,但它们被设置成迭代器相应的迭代类型了。通过Cross迭代器,步数信息被输出到控制台上,并且步数值进行自增。假如步数超过8了,迭代器则通过yield break语句结束。另外一方面,枚举器对象在每一次被遍历的时候都进行返回。Circle迭代器和Cross迭代器看起来非常类似,只不过要注意的是Circle迭代器返回的是Cross迭代类型,而Cross迭代器返回的是Circle迭代类型:

    public class GameMoves
    {
    	private IEnumerator _cross;
    	private IEnumerator _circle;
    	public GameMoves()
    	{
    		_cross = Cross();
    		_circle = Circle();
    	}
    	private int _move = 0;
    	const int MaxMoves = 9;
    	public IEnumerator Cross()
    	{
    		while (true)
    		{
    			Console.WriteLine($"Cross, move {_move}");
    			if (++_move >= MaxMoves)
    			{
    				yield break;
    			}
    			yield return _circle;
    		}
    	}
    	public IEnumerator Circle()
    	{
    		while (true)
    		{
    			Console.WriteLine($"Circle, move {_move}");
    			if (++_move >= MaxMoves)
    			{
    				yield break;
    			}
    			yield return _cross;
    		}
    	}
    }
    

    在客户端代码里,你可能会像下面这样使用GameMoves类:

    var game = new GameMoves();
    IEnumerator enumerator = game.Cross();
    while (enumerator.MoveNext())
    {
    	enumerator = enumerator.Current as IEnumerator;
    }
    

    首先我们先用game.Cross方法设定一个枚举器变量enumerator,注意此时Cross方法没有执行。在while循环中,我们调用了enumerator的MoveNext方法,此时进入Cross方法体开始执行,并通过yield return语句返回另外一个Circle类型的枚举器。Cross方法的返回值可以通过IEnumerator的Current属性进行访问,并将它赋值给enumerator变量以便进行下一次循环的处理。

    程序的输出如下所示,直到最后一步移动:

    Cross, move 0
    Circle, move 1
    Cross, move 2
    Circle, move 3
    Cross, move 4
    Circle, move 5
    Cross, move 6
    Circle, move 7
    Cross, move 8
    

    7.9 结构的比较

    数组和元组(Tuples,第13章介绍)一样页实现了接口IStructuralEquatable和IStructuralComparable。这些接口不单只比较引用还包括内容比较。这个接口是显式实现的,所以有必要在使用的时候将数组和元组强制转换成接口类型。IStructuralEquatable用来比较两个元组或者数组是否具有相同的内容,而IStructuralComparable则用来对元组或者数组进行排序。

    为了演示IStructuralEquatable接口,Person类实现了IEquatable接口。IEquatable接口定义了一个强类型的Equals方法并在之中进行FirstName和LastName属性的比较:

    public class Person: IEquatable<Person>
    {
    	public int Id { get; }
    	public string FirstName { get; }
    	public string LastName { get; }
    	public Person(int id, string firstName, string lastName)
    	{
    	    Id = id;
    		FirstName = firstName;
    		LastName = lastName;
    	}
    	public override string ToString() => $"{Id}, {FirstName} {LastName}";
    	public override bool Equals(object obj)
    	{
    		if (obj == null)
    		{
    			return base.Equals(obj);
    		}
    		return Equals(obj as Person);
    	}
    	public override int GetHashCode() => Id.GetHashCode();
    	public bool Equals(Person other)
    	{
    		if (other == null)
    			return base.Equals(other);
    		return Id == other.Id && FirstName == other.FirstName && LastName == other.LastName;
    	}
    }
    

    现在我们创建两个包含Person项的数组。每个数组都包含了同一个Person对象,由变量名janet指向的实例,并且拥有还拥有一个不同引用的对象,但它们的内容是一致的。比较运算符!=返回的是true,因为实际上这是两个不同的引用数组。因为并未修改Array类中的单参数的Equals方法,就跟==运算符处理的一样,比较两个数组的时候比较的是引用,显而易见它们不一样:

    var janet = new Person("Janet", "Jackson");
    Person[] people1 = {
    	new Person("Michael", "Jackson"),
    	janet
    };
    Person[] people2 = {
    	new Person("Michael", "Jackson"),
        janet
    };
    if (people1 != people2)
    {
    	Console.WriteLine("not the same reference");
    }
    

    而当我们调用IStructuralEquatable接口中定义的Equals方法的时候——这个方法第一个参数是object类型的,而第二个参数是IEqualityComparer类型——通过实现IEqualityComparer<T>这个接口你可以定义如何进行比较的。默认的IEqualityComparer实现则由EqualityComparer<T>类进行提供。这个实现检查了类型是否实现了IEquatable接口,并且调用IEquatable.Equals方法。假如类型并未实现IEquatable接口,则会调用基类Object提供的Equals方法来进行比较。

    Person类实现了接口IEqualtable<Person>,实现了对实例的实际内容进行比较,而事实上两个数组的内容是一致的:

    if ((people1 as IStructuralEquatable).Equals(people2, EqualityComparer<Person>.Default))
    {
    	Console.WriteLine("the same content");
    }
    

    7.10 Span

    你可以使用Span<T>结构体来快速地访问托管或者非托管的连续内存。一个例子中的Span<T>用在数组上,在后台(behind the scenes)它存储的内存是连续的。另外一个示例是一个长字符串。第9章将会更加详细的介绍如何为字符串使用Span<T>。

    通过使用Span<T>,你可以直接访问数组元素。数组元素并没有重新复制一份,但它们仍然可以直接使用,而且比拷贝的速度更快。

    在下面的代码片段中,我们首先创建并初始化了一个int数组。然后我们创建了一个Span<int>对象,调用它的构造函数,将int数组传递给Span<int>。Span<T>类型提供了一个索引器,因此Span<T>的元素可以通过索引进行访问。这里,Span<T>的第二个元素的值被修改成了11。因为数组arr1是被span引用的,因此在修改Span<T>的元素的时候,其对应的数组的第二个元素也被修改了:

    private static Span<int> IntroSpans()
    {
    	int[] arr1 = { 1, 4, 5, 11, 13, 18 };
    	var span1 = new Span<int>(arr1);
    	span1[1] = 11;
    	Console.WriteLine($"arr1[1] is changed via span1[1]: {arr1[1]}");
    	return span1;
    }
    

    7.10.1 创建切片

    Span<T>的一个非常强大的功能特性是你可以使用它来访问数组的部分内容,或者分割(slices)它。使用切片的时候(slices),数组元素并非拷贝一份新的值,而是直接访问的Span。

    接下来的代码片段中演示了两种创建切片的方式:

    private static Span<int> CreateSlices(Span<int> span1)
    {
    	Console.WriteLine(nameof(CreateSlices));
    	int[] arr2 = { 3, 5, 7, 9, 11, 13, 15 };
    	var span2 = new Span<int>(arr2);
    	var span3 = new Span<int>(arr2, start: 3, length: 3);
    	var span4 = span1.Slice(start: 2, length: 4);
    	DisplaySpan("content of span3", span3);
    	DisplaySpan("content of span4", span4);
    	Console.WriteLine();
    	return span2;
    }
    

    第一种方式是通过一个构造函数重载,通过传递开始位置和数组长度作为参数来实现的。变量span3引用了新创建的Span<T>,但它只能访问span2中的第4个开始的3个元素。另外一个构造函数的重载版本你可以只传递开始位置作为参数,这种情况下,将从开始位置直到数组结束作为切片。你也可以直接从Span<T>实例中创建切片,通过调用它的Slice方法,这里它的重载版本跟构造函数很类似。通过变量span4,先前创建的span1将会从第3个元素开始,创建连续4个元素的切片。

    DisplaySpan方法用来显示一个Span的内容。这个方法中使用了ReadOnlySpan,当你不需要修改span引用的内容时你可以使用这种span类型,就像DisplaySpan方法这么使用。我们将会在本章的后续部分继续介绍ReadOnlySpan的细节:

    private static void DisplaySpan(string title, ReadOnlySpan<int> span)
    {
    	Console.WriteLine(title);
    	for (int i = 0; i < span.Length; i++)
    	{
    		Console.Write($"{span[i]}.");
    	}
    	Console.WriteLine();
    }
    

    主程序中调用前面提到的IntroSpans方法生成span1,然后调用CreateSlices方法,你将会看到以下的span3和span4内容——它们是arr2和arr1的子集:

    arr1[1] is changed via span1[1]:11
    CreateSlices
    content of span3
    9.11.13.
    content of span4
    5.11.13.18.
    

    注意:Span<T>在跨越边界时是安全的(is safe from crossing the boundaries)。万一你创建了一个超过数组长度的span,编译器将会抛出一个ArgumentOutOfRangeException的异常。你可以阅读第14章了解更多关于异常的处理。

    7.10.2 使用Span 改变值

    你已经了解到如何直接通过Span<T>的索引器来直接修改数组元素。下面的代码片段里演示了更多的操作方式。

    你可以调用Clear方法,将int类型的Span内容全部置为0。你也可以调用Fill方法,来将Span中的内容都设置为你传递给Fill方法的参数值。你还可以拷贝Span<T>的内容到另外一个Span<T>对象。在使用CopyTo方法时,假如目标span不够大的话,将会抛出一个ArgumentException异常。你可以使用TryCopyTo方法避免这种情况的发生,假如拷贝失败的话,该方法会返回一个false:

    private static void ChangeValues(Span<int> span1, Span<int> span2)
    {
    	Console.WriteLine(nameof(ChangeValues));
    	Span<int> span4 = span1.Slice(start: 4);
    	span4.Clear();
    	DisplaySpan("content of span1", span1);
    	Span<int> span5 = span2.Slice(start: 3, length: 3);
    	span5.Fill(42);
    	DisplaySpan("content of span2", span2);
    	span5.CopyTo(span1);
    	DisplaySpan("content of span1", span1);
    	if (!span1.TryCopyTo(span4))
    	{
    		Console.WriteLine("Couldn't copy span1 to span4 because span4 is " + "too small");
    		Console.WriteLine($"length of span4: {span4.Length}, length of " + $"span1: {span1.Length}");
    	}
    	Console.WriteLine();
    }
    

    主程序中的代码为:

    var span1 = IntroSpans();
    var span2 = CreateSlices(span1);
    ChangeValues(span1, span2);
    

    当你运行应用程序时,你将会发现span1的最后两个元素被span4清零了,而span2中的3个元素则由span5进行了值为42的填充,然后span5将自己的内容拷贝到span1的头3个元素上。尝试将span1往span4上拷贝的时候失败了因为span4仅仅只有2位空间,而span1则有6个元素。最终结果显示如下:

    content of span1
    1.11.5.11.0.0.
    content of span2
    3.5.7.42.42.42.15.
    content of span1
    42.42.42.11.0.0.
    Couldn't copy span1 to span4 because span4 is too small
    length of span4: 2, length of span1: 6
    

    7.10.3 只读的Span

    假如你只需要读取数组段的值而不需要修改它,你可以使用ReadOnlySpan<T>,就像在DisplaySpan方法中已经用过的那样。通过ReadOnlySpan<T>,索引器是只读的,并且这种类型无法提供Clear和Fill操作。虽然你仍旧可以调用CopyTo方法,来将ReadOnlySpan<T>的内容拷贝到一个Span<T>上。

    下面的代码段使用ReadOnlySpan<T>的构造函数从一个数组中创建了readOnlySpan1变量。readOnlySpan2和readOnlySpan3则是直接通过Span<int>和int[]赋值进行创建并且隐式地转换成了ReadOnlySpan<T>:

    private static void ReadonlySpan(Span<int> span1)
    {
    	Console.WriteLine(nameof(ReadonlySpan));
    	int[] arr = span1.ToArray();
    	ReadOnlySpan<int> readOnlySpan1 = new ReadOnlySpan<int>(arr);
    	DisplaySpan("readOnlySpan1", readOnlySpan1);
    	ReadOnlySpan<int> readOnlySpan2 = span1;
    	DisplaySpan("readOnlySpan2", readOnlySpan2);
    	ReadOnlySpan<int> readOnlySpan3 = arr;
    	DisplaySpan("readOnlySpan3", readOnlySpan3);
    	Console.WriteLine();
    }
    

    注意:如何实现隐式转换运算符在第6章有详细介绍。本书之前的版本演示的是ArraySegment<T>的使用。虽然它在现在也还可以用,然而你可以使用更加灵活的Span<T>来代替它。假如你之前用的是ArraySegment<T>,你仍然可以保留那些代码并且与Span进行交互。Span<T>的构造函数中也允许传递一个ArraySegment<T>参数来构造Span<T>实例。

    7.11 数组池

    假如你的应用程序中使用了大量的数组,并且频繁地创建和销毁,垃圾回收器将会频繁进行处理。为了减轻GC的工作,你可以使用数组池(Array Pool),也就是ArrayPool类。ArrayPool类中管理了许多数组(a pool of arrays)。你可以从数组池中申请(rent)数组并在使用完毕后归还给它。内存管理由ArrayPool自己负责处理。

    7.11.1 创建数组池

    你可以通过ArrayPool<T>的静态Create方法来创建ArrayPool<T>数组池。出于效率的考虑,数组池将大小接近的数组安排到一起,并分成多个地址进行内存管理(manages memory in multiple buckets for arrays of similar sizes)。通过Create方法,你可以定义数组的最大长度以及一个地址(within a bucket)能管理的最大数组数量:

    ArrayPool<int> customPool = ArrayPool<int>.Create(maxArrayLength: 40000, maxArraysPerBucket: 10);
    

    默认的最大数组长度(maxArrayLength)是1024×1024字节,默认的地址最大数组数量(maxArraysPerBucket)是50。数组池使用多个地址(multiple buckets),以便更快地访问到大量不同的数组。大小接近的数组会尽可能地安排在一起(in the same bucket as long as possible),不超过最大数组数目。

    7.11.2 从数组池中申请内存

    从数组池中请求内存是通过调用Rent方法。它接收一个数组需要的最小数组长度作为参数。假如数组池中的差不多大小的内存已经分配过,就返回该块内存。假如没有可用的内存,那么数组池就负责分配相应的内存并随后返回。在下面的代码片段中,在for循环里,请求了长度为1024,2048,3096等等的数组:

    private static void UseSharedPool()
    {
    	for (int i = 0; i < 10; i++)
    	{
    		int arrayLength = (i + 1) << 10;
    		int[] arr = ArrayPool<int>.Shared.Rent(arrayLength);
    		Console.WriteLine($"requested an array of {arrayLength} " + $"and received {arr.Length}");
    	}
    }
    

    控制台上的输出如下所示:

    requested an array of 1024 and received 1024
    requested an array of 2048 and received 2048
    requested an array of 3072 and received 4096
    requested an array of 4096 and received 4096
    requested an array of 5120 and received 8192
    requested an array of 6144 and received 8192
    requested an array of 7168 and received 8192
    requested an array of 8192 and received 8192
    requested an array of 9216 and received 16384
    requested an array of 10240 and received 16384
    

    Rent方法返回的数组长度是根据请求的长度返回的最小数组。这个数组可能会拥有更多的内存。共享池(shared pool)里保存了至少包含16个元素的数组。数组长度是倍增的——如16,32,64,128,256,512,1024,2048,4096,8192等等。

    7.11.3 将内存返回给数组池

    当你不再需要使用某个数组的时候,你可以将它返回给数组池。当你返回了这些数组,之后你也可以再次申请使用。

    你通过调用数组池的Return方法,将数组作为参数传递给它,来将数组占用的内存返回给数组池。通过一个可选的参数clearArray,你可以指定在将数组返回给数组池前是否需要清空该数组的值。假如不清空,下次从数组池中请求该大小的数组时,你依然可以读取原来的值。清空数据,你可以避免这个问题,但你需要更多的CPU时间去处理:

    ArrayPool<int>.Shared.Return(arr, clearArray: true);
    

    注意:第17章我们将介绍更多有关垃圾回收器和内存地址的信息。

    7.12 小结

    本章,你了解了C#创建使用简单、多维、锯齿数组的语法。Array类是数组后台实际对象,因此你可以通过数组变量调用Array类里的方法和属性。

    你也了解了如何通过实现IComparable和IComparer接口对数组元素进行排序,并且你学习了如何创建和使用枚举器,包括IEnumerable和IEnumerator接口,以及yield语句。

    本章最后一小节向你演示了如何更高效地通过Span<T>和ArrayPool进行数组操作。

    下一章我们将探讨C#中更加重要的功能特性:委托,lambdas表达式和事件。

  • 相关阅读:
    “<”特殊符号写法
    js中,符合属性的js写法是讲下横杆去掉
    Windows 搭建WAMP+Mantis
    Windows server 2012 R2 服务器用户自动锁定
    对域用户设置为本地管理员权限
    windows 域控用户记住最后一次登录用户名
    redhat7.6 配置主从DNS
    redhat7.6 DNS配置正向解析
    redhat7.6 AIDE 系统文件完整性检查工具
    redhat7.6 httpd 匿名目录 目录加密 域名跳转
  • 原文地址:https://www.cnblogs.com/zenronphy/p/ProfessionalCSharp7Chapter7.html
Copyright © 2020-2023  润新知