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表達式和事件。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM