C#高級編程第11版 - 第十章


導航

第十章 Collections

10.1 概述

在第7章,"數組"里,我們已經介紹了Array類實現的數組和接口。這種數組是定長的(fixed)。假如你想動態的設置元素的個數,你需要使用集合類而非數組。

List<T>是一個集合類,我們可以用來跟數組進行比較。當然我們還有很多其他類型的集合類,如:隊列,棧,鏈表,字典和數據集(set)。這些不同的集合類有着不同的API訪問各自集合中的元素,並且它們在內存中存儲元素的內部結構也往往不同。本章將會介紹這些集合類以及它們的區別,包括性能差異。

10.2 集合接口和類型

大部分的泛型類都位於System.Collections或者System.Collections.Generic命名空間下,其中泛型集合類主要位於后者。專門為某些特定類型服務的集合類位於System.Collections.Specialized命名空間下,如BitVector32、HybridDictionary等。線程安全(Thread-safe)的集合類則是位於System.Collections.Concurrent命名空間下。不可變(ImmutableCollection)的集合類位於System.Collections.Immutable命名空間下。

當然,也有其他的方式來為集合進行歸類。集合類也可以根據其所實現的不同集合類接口而分為列表、集合與字典。

注意:你可以在第七章的時候了解到更多關於IEnumerable和IEnumerator接口的詳細信息。

下面的表格描述了集合和列表實現的大部分重要接口:

接口 描述
IEnumerable<T> foreach語句實現的需要。
定義了GetEumerator方法,返回
IEnumerator類型的迭代器。
ICollection<T> 泛型集合類實現的接口。
接口定義了Count屬性用來獲取集合
的元素數量,定義了Copy方法將集
合拷貝成一個數組,定義了添加、刪
除、清理元素的方法。
IList<T> 列表實現的接口,提供了按位置訪問
元素的能力。接口定義了索引器,可
以很方便地插入和移除元素。接口繼
承自ICollection<T>。
ISet<T> 數據集實現的接口。數據集允許多個
不同的數據集形成一個聯合數據集。
獲取兩個數據集之間的交集,檢車它
們之間是否有重疊的部分。接口也繼
承自ICollection<T>。
IDictionary<TKey,TValue> 接口由帶有Key和Value鍵值對的泛型
集合類實現。通過接口你可以訪問鍵
值對,也可以通過索引器進行訪問,
同樣也可以添加或刪除元素。
ILookup<TKey,TValue> 跟上面的IDictionary<TKey,TValue>
有點相似,本接口也包含鍵值對。然
而不同的是上面的接口是一一對應的
,而本接口一個Key可以對應多個Value。
IComparer<T> 本接口通過實現一個比較器,可以通過
Compare方法對集合類中的元素進行排
序。
IEqualityComparer<T> 實現了一個可以應用字典中鍵值的比較
器,通過本接口你可以比較objects。

10.3 列表

對於允許任意元素個數(resizable)的List,.NET提供了泛型類型List<T>,這個類實現了IList,ICollection,IEnumerable, IList<T>ICollection<T>以及IEnumerable<T>接口。

接下來的例子中,我們將使用Racer類作為成員元素,添加到一個代表一級方程式賽車手(represent a Formula-1 racer)的集合中。Racer類含有5個屬性:Id,FirstName,LastName,Country和Wins(勝利次數)。通過構造函數,我們可以為所有屬性都賦初值。我們將重寫ToString方法來返回賽車手的全名。Racer類還實現了IComparable<T>接口方便后續排序,並且也實現了IFormattable接口。代碼如下所示:

public class Racer: IComparable<Racer>, IFormattable
{
	public int Id { get; }
	public string FirstName { get; }
	public string LastName { get; }
	public string Country { get; }
	public int Wins { get; }
	public Racer(int id, string firstName, string lastName, string country) :this(id, firstName, lastName, country, wins: 0)
	{ }
	public Racer(int id, string firstName, string lastName, string country,int wins)
	{
		Id = id;
		FirstName = firstName;
		LastName = lastName;
		Country = country;
		Wins = wins;
	}
	public override string ToString() => $"{FirstName} {LastName}";
	public string ToString(string format, IFormatProvider formatProvider)
	{	
		if (format == null) format = "N";
		switch (format.ToUpper())
		{
			case "N": // name
				return ToString();
			case "F": // first name
				return FirstName;
			case "L": // last name
				return LastName;
			case "W": // Wins
				return $"{ToString()}, Wins: {Wins}";
			case "C": // Country
				return $"{ToString()}, Country: {Country}";
			case "A": // All
				return $"{ToString()}, Country: {Country} Wins: {Wins}";
			default:
				throw new FormatException(String.Format(formatProvider, $"Format {format} is not supported"));
		}
	}
	public string ToString(string format) => ToString(format, null);
	public int CompareTo(Racer other)
	{
		int compare = LastName?.CompareTo(other?.LastName) ?? -1;
		if (compare == 0)
		{
			return FirstName?.CompareTo(other?.FirstName) ?? -1;
		}
		return compare;
	}
}

10.3.1 創建列表

你可以通過調用默認的構造函數創建List對象。如果是通過泛型類List<T>進行創建的話,你需要事先聲明列表元素的值類型。下面的代碼分別創建了一個int類型和Racer類型的列表。多提一句,ArrayList是一個非泛型的列表,它允許任何類型成為它的元素。

var intList = new List<int>();
var racers = new List<Racer>();

使用默認構造函數將會創建一個空列表。一旦第一個元素開始添加到列表,列表的容量會首先擴展到4個元素。當第5個元素需要添加時,它的容量將會擴展到8個,假如8個還不夠,就接着擴展到16,每次都是翻倍地擴容。

每當列表的容量改變的時候,整個集合將會在內存區重新進行分配。List<T>實際上是創建了一個T類型的數組。每次需要重新分配內存時,一個新的數組就會被創建,並且使用Array.Copy方法從舊的數組將值拷貝到新數組中。為了節省時間,假如你事先能知道列表的元素數量的話,你可以通過構造函數提前設置列表的大小。下面這段代碼就是設置了列表元素數量為10。但當你要添加更多的元素時,它的容量將會先擴成20,然后40,依然是翻倍擴容:

List<int> intList = new List<int>(10);

你可以通過列表的Capacity屬性來得到它當前的容量,當然你也可以修改它:

intList.Capacity = 20;

Capacity屬性並非和集合里當前擁有的元素數量一致,你可以通過Count屬性來獲取集合里當前擁有的元素數量。當然,Capacity的數值永遠大於等於Count值。當集合中未添加任何元素時,Count值為0:

Console.WriteLine(intList.Count);

當你已經完成所有列表元素的添加並且不想再添加新的了,你可以通過調用TrimExcess方法來移除沒有用到的容量;然而,因為移除容量也會造成內存的重新分配,因此當實際元素數量超過集合容量的90%的時候,該方法不起效:

intList.TrimExcess();
10.3.1.1 集合初始化器

你可以在集合初始化的時候直接給它賦值,語法和數組的賦值很像,通過大括號將值括起來進行賦值,如下所示:

var intList = new List<int>() {1, 2};
var stringList = new List<string>() { "one", "two" };

注意:這種賦值方式並不會在程序集中生成特別的IL代碼,編譯器僅僅只是為每一個指定的元素都分別調用了Add方法。

10.3.1.2 新增元素

你可以像下面的例子這樣,通過Add方法為列表新增元素,泛型版本的列表也定義了Add方法:

var intList = new List<int>();
intList.Add(1);
intList.Add(2);
var stringList = new List<string>();
stringList.Add("one");
stringList.Add("two");

下面定義的變量racers是List<Racer>類型的列表,通過new操作符進行創建。因為List<T>類已經以明確的類型Racer進行實例化了,現在只有Racer實例對象才可以通過Add方法添加到racers列表中。在下面的示例代碼里,我們創建了5個一級方程式賽車手的信息並將他們添加到集合里。前面3位選手我們通過初始化直接進行添加,而后兩位選手則顯示地調用了Add方法:

var graham = new Racer(7, "Graham", "Hill", "UK", 14);
var emerson = new Racer(13, "Emerson", "Fittipaldi", "Brazil", 14);
var mario = new Racer(16, "Mario", "Andretti", "USA", 12);
var racers = new List<Racer>(20) {graham, emerson, mario};
racers.Add(new Racer(24, "Michael", "Schumacher", "Germany", 91));
racers.Add(new Racer(27, "Mika", "Hakkinen", "Finland", 20));

你也可以使用AddRange方法來一次性為集合添加多個元素,因為該方法允許接收IEnumerable<T>類型的參數,因此你可以像下面代碼這樣直接給它傳遞一個數組:

racers.AddRange(new Racer[] {
	new Racer(14, "Niki", "Lauda", "Austria", 25),
	new Racer(21, "Alain", "Prost", "France", 51)});

注意:集合初始化器(collection initializer)的賦值方式僅僅只能在聲明集合的時候使用。而Add和AddRange方法則是在集合初始化之后再進行調用。萬一你需要根據數據動態地創建集合,你需要調用Add或者AddRange方法。

如果你在實例化列表的時候已經知道某些元素會屬於該列表,你也可以直接向構造函數傳遞IEnumerable<T>類型的參數,這點和AddRange方法非常類似:

var racers = new List<Racer>(
	new Racer[] {
		new Racer(12, "Jochen", "Rindt", "Austria", 6),
		new Racer(22, "Ayrton", "Senna", "Brazil", 41) });
10.3.1.3 插入元素

你可以使用Insert方法在指定位置插入元素:

racers.Insert(3, new Racer(6, "Phil", "Hill", "USA", 3));

而InsertRange方法則提供了插入多個元素的能力,它的使用方式和AddRange方法很像。假如你要插入的位置遠大於集合允許的元素數量,將會引發ArgumentOutOfRangeException異常。

10.3.1.4 訪問元素

所有實現了IList和IList<T>的類都提供了一個索引器,可以通過傳遞元素的序號訪問指定元素。第一個元素的序號為0,因此當你指定racers[3]的時候,實際上你訪問的是列表中的第4個元素:

Racer r1 = racers[3];

當你使用Count屬性來獲取元素數量的時候,你可以通過一個循環來遍歷集合中的所有元素,因為你可以通過索引器訪問每一個元素:

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

注意:索引可以在ArrayList,StringCollections和List<T>中使用。

因為List<T>實現了IEnumerable接口,你也可以通過foreach語句來遍歷集合里的所有元素:

foreach (var r in racers)
{
	Console.WriteLine(r);
}

注意:第7章的時候闡釋了foreach語句在編譯器中實際上是如何轉換成IEnumerable和IEnumerator接口的。

10.3.1.5 移除元素

你可以通過索引號來從隊列里移除元素,例如下面代碼移除的是第4個元素:

racers.RemoveAt(3);

你也可以通過Remove方法,給它直接傳遞一個Racer對象作為參數,來刪除指定的元素。通過索引刪除的速度更快一些,因為在刪除的時候,集合需要對待刪除的元素進行查找。Remove方法首先通過IndexOf方法獲取元素在集合中的索引,然后再進行刪除。IndexOf方法則會首先檢查元素類型是否實現了IEquatable<T>接口,假如它實現了該接口,那么就會遍歷集合中的所有元素,調用該接口的Equals方法進行一一比較,找出相等的元素,返回它的序號。假如元素未實現IEquatable<T>接口,則會調用基類Object的Equals方法進行比較,默認的Equals方法雖然在比較值類型上尚有可取之處,但是在比較對象的時候僅僅只比較它們之間的引用是否指向同一個位置。

注意:第6章,"操作符和強制轉換"中介紹了如何重寫基類的Equals方法。

在下面的例子中,我們將會從集合中移除變量graham引用的racer對象,這個變量是我們前面填充集合的時候創建的。因為Racer類並未實現IEquatable<T>接口,也沒有重寫Object.Equals方法,因此你無法通過創建一個同樣內容的新對象當成參數傳遞給Remove方法來移除它,即使新對象內的屬性都與它一模一樣:

if (!racers.Remove(newGraham)) //原文這里是graham,但這個對象集合里本來就有,可以移除
{
	Console.WriteLine("object not found in collection");
}

RemoveRange方法可以一次性從集合里移除多個元素。該方法第一個參數指定了要開始移除的位置,第二個參數則是待刪除元素的個數:

int index = 3;
int count = 5;
racers.RemoveRange(index, count);

假如你想移除集合里的某些指定特征(specific characteristics)的元素,你可以使用RemoveAll方法,這個方法使用了Predicate<T>類型的參數來查找元素,我們會在下一小節詳細介紹它。如果要移除所有元素,你可以使用ICollection<T>接口中定義的Clear方法。

10.3.2 檢索

在集合中查找指定元素有多種方式。你可以通過索引來查找元素,或者通過元素本身查找索引。你可以使用的方法有很多,譬如:IndexOf,LastIndexOf,FindIndex,FindLastIndex,Find和FindLast。而如果要判斷某個元素是否存在,你可以使用Exists方法。

IndexOf方法接收一個實例對象作為參數,假如能在集合中找到它,則返回該元素在集合中的索引,假如不存在,則返回-1。請記住IndexOf方法使用的是IEquatable<T>接口來比較元素:

int index1 = racers.IndexOf(mario);

IndexOf方法也支持只對部分集合進行搜索,通過指定搜索的起始位置和元素數量,可以只搜索部分元素。

除了IndexOf方法,你也可以使用FindIndex方法根據指定特征來搜索某個元素,它接收一個Predicate<T>類型的參數:

public int FindIndex(Predicate<T> match);

Predicate<T>類型是一個委托,接收T類型的參數,並返回一個布爾值。假如返回的是true,意味着條件匹配,找到了滿足需要的元素,假如返回的是false,則當前元素不符合條件,接着進行查找:

public delegate bool Predicate<T>(T obj);

以上面我們創建的List<Racer>為例子,你可以為FindIndex方法傳遞一個T類型為Racer的委托作為參數,該委托接收一個Racer類型的參數並且返回一個bool值。如下面代碼所示:

public class FindCountry
{
	public FindCountry(string country) => _country = country;
	private string _country;
	public bool FindCountryPredicate(Racer racer) => racer?.Country == _country;
}

該代碼用來查找某個國家的第一個賽車手,FindCountry類里的FindCountryPredicate方法簽名和返回值都滿足Predicate<T>委托類型,通過使用構造函數傳遞過來的變量country來進行查找。

在FindIndex方法中,你可以創建一個FindCountry類的新實例,在構造函數中傳遞你要查詢的國家,然后將FindCountryPredicate方法地址作為參數傳遞給FindIndex,如下所示:

int index2 = racers.FindIndex(new FindCountry("Finland").FindCountryPredicate);

當存在Finland這個國家的賽車手時,index2將會被設置成racers列表中第一個滿足條件的賽車手的索引。

如果你不想大費周章地創建一個帶有委托方法的新類,你也可以在這里使用lambda表達式,結果跟前面也是一致的。如下所示:

int index3 = racers.FindIndex(r => r.Country == "Finland");

與IndexOf方法一樣,你也可以為FindIndex方法指定開始查找的起始位置,你也可以使用FindLastIndex方法來從后往前搜索。

FindIndex方法返回的是滿足搜索條件的元素的序號。你也可以直接通過Find方法獲取集合中的元素而非它的序號。Find方法同樣接收一個Predicate<T>委托類型的參數。考慮下面這個示例:

Racer racer = racers.Find(r => r.FirstName == "Niki");

它查找的是FirstName為Niki的第一個賽車手。當然,你也可以通過FindLast方法來查找滿足搜索條件的最后一個元素。

假如你不僅僅只是為了得到某一個滿足條件的結果,而是需要查找所有符合條件的元素的話,你可以使用FindAll方法,它使用同樣的委托參數,但當找到第一個滿足條件的元素時它不會停下來,它會持續遍歷整個集合,為每一個元素進行匹配並進行輸出。在下面的例子里,我們查找的是所有Wins次數大於20的選手,並且將它們加到bigWinners列表中:

List<Racer> bigWinners = racers.FindAll(r => r.Wins > 20);

通過foreach語句遍歷bigWinners:

foreach (Racer r in bigWinners)
{
	Console.WriteLine($"{r:A}");
}

你可以看到:

Michael Schumacher, Germany Wins: 91
Niki Lauda, Austria Wins: 25
Alain Prost, France Wins: 51

這個結果沒有進行排序,但你會在下一節中看到如何處理它。

注意:格式化字符(Format specifiers)和IFormattable接口的細節我們在第9章,"字符串和正則表達式"中已經介紹過。

10.3.3 排序

List<T>類允許你使用Sort方法來對它包含的元素進行排序。Sort方法使用的是快速排序算法。你可以使用多個Sort方法的重載版本,它的參數可以是泛型委托Comparison<T>、泛型接口IComparer<T>或者帶上指定范圍等等:

public void List<T>.Sort();
public void List<T>.Sort(Comparison<T>);
public void List<T>.Sort(IComparer<T>);
public void List<T>.Sort(Int32, Int32, IComparer<T>);

只有當集合中的元素實現了IComparable接口時你才可以直接使用無參的Sort方法。在這里,Racer類實現了IComparable<T>接口來通過LastName進行排序:

racers.Sort();

假如你想使用默認支持方式以外的排序,你需要使用其他的技巧,譬如傳遞一個實現了IComparer<T>接口的對象。考慮下面的代碼:

public class RacerComparer : IComparer<Racer>
{
	public enum CompareType
	{
		FirstName,
		LastName,
		Country,
		Wins
	}
	private CompareType _compareType;
	public RacerComparer(CompareType compareType)
	{
		_compareType = compareType;
	}
	public int Compare(Racer x, Racer y)
	{
		if (x == null && y == null) return 0;
		if (x == null) return -1;
		if (y == null) return 1;
		int result;
		switch (_compareType)
		{
			case CompareType.FirstName:
				return string.Compare(x.FirstName, y.FirstName);
			case CompareType.LastName:
				return string.Compare(x.LastName, y.LastName);
			case CompareType.Country:
				result = string.Compare(x.Country, y.Country);
				if (result == 0)
					return string.Compare(x.LastName, y.LastName);
				else
					return result;
			case CompareType.Wins:
				return x.Wins.CompareTo(y.Wins);
			default:
				throw new ArgumentException("Invalid Compare Type");
		}
	}
}

RacerComparer類為Racer類實現了IComparer<T>接口,它使得你可以通過姓氏,名字,國家或者勝場來進行排序。這種排序實際上是由內置的枚舉變量CompareType實現的。CompareType則是在RacerComparer類的構造函數里進行設置。IComparer<T>接口定義了在排序過程中實際調用的比較方法Compare。在上面實現的Compare以及內部調用的CompareTo方法中,我們用到了string和int類型。

注意:當傳遞給Compare方法的兩個元素相等的時候,它返回0。如果方法返回的是負數,就意味着第一個參數比第二個參數小,反之則意味着第一個參數比第二個參數大。參數為null的時候也做了處理,我們認為null比任何值都小,除非兩個參數都為null的時候他們才相等。否則僅當第一個參數為null時,則會返回-1;而僅當第二個參數為null時,則返回+1。

你可以在Sort方法里使用RacerComparer的實例,如下所示,我們將RacerComparer.CompareType.Country傳遞給了構造函數,這樣我們的排序就是通過Country屬性來進行的:

racers.Sort(new RacerComparer(RacerComparer.CompareType.Country));

另外一種排序的方式是使用List<T>中重載的Sort方法,它需要一個Comparison<T>委托作為參數:

public void List<T>.Sort(Comparison<T>);

Comparison<T>委托擁有兩個T類型的參數,並返回int類型。假如兩個參數相等,返回值必須是0;假如第一個參數小於第二個參數,應該返回一個負值;反過來則返回一個正數。

public delegate int Comparison(T x, T y);

現在你可以像下面這樣子,通過給Sort方法傳遞一個lambda表達式來完成根據勝場進行的排序:

racers.Sort((r1, r2) => r2.Wins.CompareTo(r1.Wins));

兩個參數是Racer類型的,通過CompareTo方法里對勝場Wins屬性進行比較並返回一個int值來實現整個比較過程。在這個方法實現當中,r2和r1的次序跟參數順序是反過來的,因此最終得到的是一個倒序排序的結果。當這個方法被調用過后,racers列表中就是按照勝場進行排序的了。

你還可以通過調用Reverse方法,來把整個集合調轉,例如上面得到的勝場倒序排列的列表,對它調用Reverse之后,你就得到按勝場正序排列的列表了。

10.3.4 只讀集合

當你創建完集合后,他們通常是可以讀寫的,否則你無法用任何值來填充他們。然而,當你填充完你的集合之后,你可以通過它創建一個只讀的集合。舉個例子,你可以通過調用List<T>的AsReadOnly方法來返回一個ReadOnlyCollection類型的對象。ReadOnlyCollection類跟List<T>實現了同樣的接口,然而任何試圖修改ReadOnlyCollection實例的操作都會導致一個NotSupportedExcception。除了實現List<T>的接口之外,ReadOnlyCollection類還實現了IReadOnlyCollection<T>IReadOnlyList<T>接口,通過這些接口成員,最終實現了ReadOnlyCollection的只讀性。

10.4 隊列

隊列是一種先進先出(FIFO,First In First Out)的元素集合,這意味着第一個進入隊列的元素會第一個被讀取。在我們生活中有很多隊列的例子,如飛機場的航班,HR處理員工申請,打印機里的打印隊列,循環等待CPU響應的線程等等。有時候,隊列里的元素擁有不同的優先級。舉個例子,在一趟航班中,商務艙的旅客可以比經濟艙的旅客更早登機。在這種情況下,我們就需要根據優先級使用不同的隊列。事實上在機場登機的時候,商務艙和經濟艙的旅客有不同的檢票口。同樣地,對於打印任務和線程來說,也可以類似地進行處理。你可以創建不同隊列,根據優先級來將相同優先級的條目(item)置於同一隊列中。只是在每個隊列內部,因為優先級是一致的,它們依然遵循FIFO原則進行處理。

注意:在本章稍后的部分會介紹一種不同的鏈表實現,用來定義一組優先級。

隊列通過定義在System.Collections.Generic命名空間下的Queue<T>來實現。在它內部,實際上是定義了一個T類型的數組,跟前面提到的List<T>實現很像。同樣地,隊列實現了IEnumerable和ICollcection接口,但是它沒有實現ICollection<T>接口,因為其中定義的Add和Remove方法不適合隊列使用。

Queue<T>沒有實現IList<T>接口,所以你無法通過索引來訪問隊列。隊列僅允許你通過Enqueue方法,為其在隊尾增加一個元素,或者通過Dequeue方法移除隊頭第一個元素。Dequeue方法獲取到隊頭第一個元素,並將其從隊列里移除。再次調用Dequeue方法則會接着移除下一個元素。

Queue<T>類的部分成員如下表所示:

部分成員 描述
Count 返回隊列元素的個數。
Enqueue 在隊列末尾增加一個元素。
Dequeue 從隊列隊頭讀取一個元素並將其從隊列里移除。
如果隊列沒有任何元素,將會拋出一個異常。
Peek 從隊列隊頭讀取一個元素,但並不移除。
TrimExcess 調整隊列的容量,雖然Dequeue方法會移除隊列
元素,但它並不會調整隊列的容量。如果要回收
那些空元素占用的內存空間的話,調用本方法。

創建隊列的構造函數,跟List<T>類型很像。你可以通過無參構造函數來創建一個空隊列,也可以在構造函數里直接指定隊列的容量。假如你沒有指定隊列的容量,當新增的元素超過隊列的大小時,那么隊列大小就會按4,8,16,32這樣子依次遞增。跟List<T>類很像,容量每次都是翻倍地增加。非泛型版本的Queue類有一點點不同,它的無參構造函數默認創建的是一個初始容量為32的空數組。通過其它重載的構造函數,你可以將實現了IEnumerable<T>接口的集合直接作為參數進行傳遞,集合的元素會被拷貝到隊列中。

下面這個示例演示了,如何使用Queue<T>類來創建一個文檔管理程序。一個線程用來給隊列添加文檔,而另一個線程則從隊列里讀取文檔並進行相應的處理。

存儲在隊列里的T類型是Document,它定義了title和content屬性,如下所示:

public class Document
{
	public string Title
	{
		get;
	}
	public string Content
	{
		get;
	}
	public Document(string title, string content)
	{
		Title = title;
		Content = content;
	}
}

DocumentManager類則是在Queue<T>上淺淺地封裝了一層。它定義了如何處理文檔:通過AddDocument方法來將文檔添加到隊列中,而從隊列中獲取文檔則是通過GetDocument方法。

在AddDocument方法內部,文檔通過Enqueue方法添加到隊列的隊尾。而GetDocument則是通過Dequeue方法來讀取隊列的第一份文檔。因為有多個線程可能同時訪問DocumentManager類,因此對隊列的訪問我們使用了lock語句。

注意:多線程和lock語句將於第21章,"任務和並行編程"進行介紹。

IsDocumentAvailable是一個只讀的Boolean類型屬性,如果隊列里有文檔,則返回true,否則返回false。整個示例代碼如下所示:

public class DocumentManager
{
	private readonly object _syncQueue = new object();
	private readonly Queue<Document> _documentQueue = new Queue<Document>();
	public void AddDocument(Document doc)
	{
		lock(_syncQueue)
		{
			_documentQueue.Enqueue(doc);
		}
	}
	public Document GetDocument()
	{
		Document doc = null;
		lock(_syncQueue)
		{
			doc = _documentQueue.Dequeue();
		}
		return doc;
	}
	public bool IsDocumentAvailable => _documentQueue.Count > 0;
}

ProcessDocuments類在不同的任務里對隊列里的文檔進行了處理,唯一可以給外部進行調用的方法是Start。在Start方法中,會實例化一個新任務。為了啟動任務,我們創建了一個ProcessDocuments對象實例,並且調用了它的Run方法作為Task的啟動點,代碼如下所示:

public class ProcessDocuments
{
	public static Task Start(DocumentManager dm) => Task.Run(new ProcessDocuments(dm).Run);
	protected ProcessDocuments(DocumentManager dm) => _documentManager = dm ? ?
		throw new ArgumentNullExcption(nameof(dm));
	private DocumentManager _documentManager;
	protected async Task Run()
	{
		while(true)
		{
			if(_documentManager.IsDocumentAvailable)
			{
				Document doc = _documentManager.GetDocument();
				Console.WriteLine("Processing document {0}", doc.Title);
			}
			await Task.Delay(new Random().Next(20));
		}
	}
}

為了能更好地理解書里的意思,這里我們補充一下Task里的Run方法實現:

public static Task<TResult> Run<TResult>(Func<TResult> function) 
    => 
    Task<TResult>.StartNew(null, function
                           , new CancellationToken()
                           , TaskCreationOptions.DenyChildAttach
                           , InternalTaskOptions.None
                           , TaskScheduler.Default);

書中提到了TaskFactory的StartNew方法,我們也一並看看:

public Task<TResult> StartNew<TResult>(Func<TResult> function)
{
	Task internalCurrent = Task.InternalCurrent;
	return Task<TResult>.StartNew(internalCurrent, function, 
                                  this.m_defaultCancellationToken, 
                                  this.m_defaultCreationOptions, 
                                  InternalTaskOptions.None, 
                                  this.GetDefaultScheduler(internalCurrent));
}

拋開我們補充的這倆段代碼,書中是這么說的:TaskFactory(通過Task類的靜態屬性Factory進行訪問)中的StartNew方法需要一個Action委托類型的參數,Run方法的地址傳給它正好合適。TaskFactory中的StartNew方法會馬上啟動整個任務。事實上我在跟蹤調試的時候,並沒有進到TaskFactory中去,我覺着這段可能串了,作者可能想解釋的是Task的Run方法。先不管這里,我們接着往下看。

通過ProcessDocuments類的Run方法,我們構造了一個無限循環(endless loop)。在這個循環中,IsDocumentAvailable屬性用來確定隊列里是否還有文檔。假如有,它就可以被DocumentManager取走並進行處理。盡管在本例中的處理信息都顯示在控制台上,在實際的應用環境中,記錄可能會被存儲在文件,數據庫,抑或是通過網絡進行發送。

在Main方法里,我們實例化了一個DocumentManager對象,並且啟動了文檔處理任務。然后我們向DocumentManager里添加了1,000份文檔:

private static async Task Main() //注意Main方法這里的async和Task
{
	var dm = new DocumentManager();
	Task processDocuments = ProcessDocuments.Start(dm);
	// Create documents and add them to the DocumentManager
	for(int i = 0; i < 1000; i++)
	{
		var doc = new Document($ "Doc {i}", "content");
		dm.AddDocument(doc);
		Console.WriteLine($ "Added document {doc.Title}");
		await Task.Delay(new Random().Next(20));
	}
	await processDocuments;
	Console.ReadLine();
}

當你運行整個示例時,文檔隨機地添加進隊列,又隨機地被取出進行處理,你可能會看到如下所示的輸出:

程序運行結果

注意:在上面的示例中,Main方法被聲明成了返回Task類型,這一特性要求至少C# 7.1支持。你可以在第15章,"異步編程"中了解更多關於異步Main方法的信息。

上面的示例程序使用了Task來模擬整個文檔的管理過程,一個更加真實的應用場景可能是會從Web API服務中獲取到要處理的文檔,而非我們用for循環順序創建的那些。

10.5 棧

棧是另外一種容器,它跟隊列非常的類似。只不過和隊列遵循的原則不同,它是后進先出(LIFO,Last In First Out)的。通過Push方法來為棧頂添加一個元素,Pop方法則是取出棧頂的元素。

Queue<T>類一樣,Stack<T>也實現了IEnumerable<T>和ICollection接口。

Stack<T>類的部分成員如下表所示:

部分成員 描述
Count 返回棧元素的個數。
Push 向棧頂添加一個元素。
Pop 返回棧頂元素,並將其出棧。
假如棧為空,則拋出異常。
Peek 返回棧頂元素,但不出棧。
Contain 棧是否包含某個元素。

在接下來的例子中,我們通過使用Push方法為棧添加了三個元素,並通過foreach方法來遍歷。棧的枚舉器並不會移除包含的元素,它僅僅是挨個返回它們:

var alphabet = new Stack<char>();
alphabet.Push('A');
alphabet.Push('B');
alphabet.Push('C');
foreach (char item in alphabet)
{
	Console.Write(item);
}
Console.WriteLine();

因為讀取順序是從棧頂到棧底,因此輸出是這樣子的:

CBA

而如果你用Pop方法來遍歷的話,最后棧里就不存在元素了,因為Pop方法會將元素從棧里移除:

Console.Write("Second iteration: ");
while (alphabet.Count > 0)
{
	Console.Write(alphabet.Pop());
}
Console.WriteLine();
Console.WriteLine(alphabet.Count.ToString()); //0
Console.WriteLine();

10.6 鏈表

LinkedList<T>是一種雙向鏈表(doubly linked list),通過包含兩個元素引用next和previous來實現。如下圖所示:

雙向鏈表

你可以輕松地向前或者向后遍歷整個列表。鏈表的優勢在於你需要將某個元素插入列表中時,這個操作會非常的快。當你試圖插入元素時,僅僅需要將它前一個元素的Next指針和后一個元素的Previous指針指向新插入的元素即可。而普通的List<T>列表,當你需要插入元素時,你需要將其后的所有元素都挨個往后移動。

當然鏈表也有自身的缺點,它只能通過挨個進行訪問。當你需要訪問集合中或者集合尾的元素時,你需要花費相當長的時間去定位到它們。

鏈表不單止要存儲實際的元素節點,它還需要額外的開銷,用來存儲next和previous指針。這也是為什么LinkedList<T>類型中,包含着LinkedListNode<T>類型的成員。通過LinkedListNode<T>,你可以獲取任意元素節點前一個或者后一個元素,它定義的屬性包括:List,Next,Previous以及Value。List屬性返回的是元素節點所在的鏈表,Next和Previous屬性則是為了在遍歷鏈表過程中提供訪問前后節點的便利性,Value則返回的是當前節點存儲的實際元素值。

LinkedList<T>類本身定義了訪問第一個和最后一個元素的成員,也提供了向指定位置插入元素(AddAfter,AddBefore,AddFirst,AddLast),或者移除元素(Remove,RemoveFirst,RemoveLast),以及從前往后(Find)或者從后往前(FindLast)查找特定元素的方法。

接下來的示例程序將演示鏈表和列表之間是如何配合使用的。鏈表保存了一些我們在前面隊列時定義的文檔對象示例,只不過比前面的文檔多了一個優先級的屬性。然后我們將根據這個優先級把文檔存儲在鏈表中。假如多個文檔擁有相同的優先級,則按照它們插入的先后來決定它們的排序。下面這個圖就是示例程序中的整個集合:

鏈表示例集合

如圖所示,LinkedList<Document>是用來存儲所有Document對象的鏈表,我們可以在途中看見文檔的標題和優先級。標題標識了文檔是何時添加到列表中的:第一個加進來的文檔標題為"One",第二個標題是"Two",並以此類推。你可以看見第一個和第四個文檔都擁有同樣的優先級8,但因為標題為One的文檔是比Four先添加的,因此它位於列表較前一點的位置。

我們每次往鏈表中新增文檔的時候,它們必須添加到相同優先級的最后一個文檔之后。LinkedList<Document>集合包含着LinkedListNode<Document>類型的元素。LinkedListNode<Document>中含有Next和Previous屬性以便你從一個節點訪問到另一個。為了方便引用這些這些LinkedListNode<Document>,我們新建了一個List<LinkedListNode<Document>>類型的列表。為了快速訪問到每個優先級的最后一個文檔節點,List<LinkedListNode>列表中包含了10個元素,每個元素依次存儲的是相應優先級的最后一個節點。為了方便后續的討論,這些每個優先級的最后一個節點我們簡稱之為"優先級節點"。

沿用前面隊列的示例,我們為Document類添加了一個Priority屬性:

public class Document
{
	public string Title
	{
		get;
	}
	public string Content
	{
		get;
	}
	public byte Priority
	{
		get;
	}
	public Document(string title, string content, byte priority)
	{
		Title = title;
		Content = content;
		Priority = priority;
	}
}

示例程序的核心是PriorityDocumentManager類,代碼如下所示:

public class PriorityDocumentManager
{
	private readonly LinkedList < Document > _documentList;
	// priorities 0.9
	private readonly List < LinkedListNode < Document >> _priorityNodes;
	public PriorityDocumentManager()
	{
		_documentList = new LinkedList < Document > ();
		_priorityNodes = new List < LinkedListNode < Document >> (10);
		for(int i = 0; i < 10; i++)
		{
			_priorityNodes.Add(new LinkedListNode < Document > (null));
		}
	}
    
	public void DisplayAllNodes()
	{
		foreach(Document doc in _documentList)
		{
			Console.WriteLine($ "priority: {doc.Priority}, title {doc.Title}");
		}
	}
}

這個類用起來非常的簡單,我們可以輕易的創建一個Document實例並通過公共接口的方法將其添加到鏈表中。第一個document節點可以被檢索(can be retrieved),並且出於測試的目的,它還擁有一個用來顯示鏈表中所有元素的方法。

PriorityDocumentManager類中包含兩個集合。其中LinkedList<Document>包含了所有的文檔,而List<LinkedListNode<Document>>則包含了根據指定優先級添加新文檔入口點的10個元素引用。集合變量都在構造函數里進行了初始化,其中列表部分使用null進行初始化。

公共接口部分一個很重要的方法是AddDocument,它做的事情很簡單,僅僅是調用了私有方法AddDocumentToPriorityNode,特意將這部分提取成AddDocumentToPriorityNode方法是因為后續需要進行遞歸調用,如下所示:

public void AddDocument(Document d)
{
	if(d == null) throw new ArgumentNullException(nameof(d));
	AddDocumentToPriorityNode(d, d.Priority);
}

// returns the document with the highest priority
// (that's first in the linked list)
public Document GetDocument()
{
	Document doc = _documentList.First.Value;
	_documentList.RemoveFirst();
	return doc;
}

private void AddDocumentToPriorityNode(Document doc, int priority)
{
	if(priority > 9 || priority < 0) throw new ArgumentException("Priority must be between 0 and 9");
	if(_priorityNodes[priority].Value == null)
	{
		--priority;
		if(priority >= 0) //書中這里是priority<=0,一看就知道錯了,去找了源代碼才發現應該是>=0
		{
			// check for the next lower priority
			AddDocumentToPriorityNode(doc, priority);
		}
		else // now no priority node exists with the same priority or lower
		// add the new document to the end
		{
			_documentList.AddLast(doc);
			_priorityNodes[doc.Priority] = _documentList.Last;
		}
		return;
	}
	else // a priority node exists
	{
		LinkedListNode < Document > prioNode = _priorityNodes[priority];
		if(priority == doc.Priority)
		// priority node with the same priority exists
		{
			_documentList.AddAfter(prioNode, doc);
			// set the priority node to the last document with the same priority
			_priorityNodes[doc.Priority] = prioNode.Next;
		}
		else // only priority node with a lower priority exists
		{
			// get the first node of the lower priority
			LinkedListNode < Document > firstPrioNode = prioNode;
			while(firstPrioNode.Previous != null && firstPrioNode.Previous.Value.Priority == prioNode.Value.Priority)
			{
				firstPrioNode = prioNode.Previous;
				prioNode = firstPrioNode;
			}
			_documentList.AddBefore(firstPrioNode, doc);
			// set the priority node to the new value
			_priorityNodes[doc.Priority] = firstPrioNode.Previous;
		}
	}
}

AddDocumentToPriorityNode方法首先檢查了傳入的priority是否滿足限定的取值范圍。這里我們指定的有效范圍是[0,9],假如超出限制的數據范圍,則直接拋出一個異常。

接下來,你將根據傳入的優先級參數進行查找,看看相同的優先級是否已經存在一個優先級節點。假如在列表集合中尚未存在這樣的節點,則將priority自減1,並遞歸調用AddDocumentToPriorityNode方法,來循環檢查前一優先級是否已經存在優先級節點了。

假如在當前優先級以及前面的任何優先級都找不到任何優先級節點,我們就可以很安逸地調用AddLast方法,將文檔添加到鏈表的末尾(個人覺得此時鏈表應該是空的,否則必然能找到一個節點)。並且,我們認為該優先級的優先級節點就是我們剛添加的這個文檔,並將優先級節點的引用存儲到列表中。

假如能找到一個前置存在的優先級節點,那么你將得到它在鏈表中的位置引用,這樣你就可以選擇將你的文檔插入到它附近。在示例程序里,你可以確認你找到的,究竟是相同優先級的節點,還是優先級略低一些的節點。如果是前者,你可以將你的文檔插入到它之后,並將列表中存儲的優先級節點指向我們這個新的節點,因為我們前面規定了,相同優先級的文檔按先來后到的順序插入到前一個文檔之后。而當你找到的是優先級稍低一些的優先級節點的話(這意味着當前優先級,新增的這個文檔是獨一份),情況要略微復雜一些。在這里,我們選擇的是,將新增的文檔,插入到前一優先級節點首節點(因為你得到的優先級節點,是那個優先級最后一個節點)之前。為了得到首節點,我們通過while循環和節點的Previous屬性來遍歷鏈表,直到Previous的優先級發生改變之前。這樣我們就得到了我們需要的首節點。

剩下的DisplayAllNodes和GetDocument方法淺顯易懂,我們就一筆帶過了。

在Main方法里,我們聲明了PriorityDocumentManager對象實例,並通過它的方法添加了不同優先級的8個文檔添加到鏈表中,並在控制台上輸出鏈表的實際存儲情況:

 var pdm = new PriorityDocumentManager();
 pdm.AddDocument(new Document("one", "Sample", 8));
 pdm.AddDocument(new Document("two", "Sample", 3));
 pdm.AddDocument(new Document("three", "Sample", 4));
 pdm.AddDocument(new Document("four", "Sample", 8));
 pdm.AddDocument(new Document("five", "Sample", 1));
 pdm.AddDocument(new Document("six", "Sample", 9));
 pdm.AddDocument(new Document("seven", "Sample", 1));
 pdm.AddDocument(new Document("eight", "Sample", 1));
 pdm.DisplayAllNodes();

控制台的輸出如下所示:

priority: 9, title six
priority: 8, title one
priority: 8, title four
priority: 4, title three
priority: 3, title two
priority: 1, title five
priority: 1, title seven
priority: 1, title eight

10.7 有序列表

假如你需要根據Key值進行自動排序的集合,你可以使用SortedList<TKey, TValue>,這里的Key和Value可以是任意類型。下面的例子中,我們創建了Key和Value均為string類型的有序列表。首先通過默認的構造函數創建了一個空列表,然后用Add方法在其中添加了兩本書。你也可以用重載的構造函數,直接指定列表的容量,又或者將實現了IComparer<TKey>的類作為參數進行構建:

var books = new SortedList < string, string > ();
books.Add("Professional WPF Programming", "978–0–470–04180–2");
books.Add("Professional ASP.NET MVC 5", "978–1–118-79475–3");
books["Beginning C# 6 Programming"] = "978-1-119-09668-9";
books["Professional C# 6 and .NET Core 1.0"] = "978-1-119-09660-3";

Add方法的第一個參數是列表的鍵(書名),而第二個參數則是值(書的ISBN編號)。除了使用Add方法之外,你也可以通過索引器為有序列表添加元素,它以鍵作為索引,如果某本書名已經存在,使用Add方法的時候將會拋出一個異常,而使用索引的時候,新的值將會替代原有的值。

注意:SortedList<TKey, TValue>對於每個Key僅允許保存一個值,假如你需要為某個Key保存復數的值,你可以使用Lookup<TKey, TValue>

你可以通過foreach語句來遍歷有序列表,在枚舉器中,元素返回的是鍵值對的類型KeyValuePair<TKey, TValue>,你可以通過Key和Value兩個屬性來分別對它們進行訪問:

foreach (KeyValuePair<string, string> book in books)
{
	Console.WriteLine($"{book.Key}, {book.Value}");
}

運行結果如下所示:

Beginning C# 6 Programming, 978-1-119-09668-9
Professional ASP.NET MVC 5, 978-1-118-79475-3
Professional C# 6 and .NET Core 1.0, 978-1-119-09660-3
Professional WPF Programming, 978-0-470-04180-2

你也可以通過列表的Values和Keys屬性進行遍歷,這倆屬性返回的是IList<T>類型的集合,所以你可以像這樣子:

foreach (string isbn in books.Values)
{
	Console.WriteLine(isbn);
}
foreach (string title in books.Keys)
{
	Console.WriteLine(title);
}

結果如下所示:

978-1-119-09668-9
978-1-118-79475-3
978-1-119-09660-3
978-0-470-04180-2
Beginning C# 6 Programming
Professional ASP.NET MVC 5
Professional C# 6 and .NET Core 1.0
Professional WPF Programming

假如你試圖通過索引訪問一個不存在的Key,則會拋出一個異常。為了避免這個問題,你可以使用ContainsKey方法來判斷列表中是否存在Key值,或者你可以使用TryGetValue方法,它可以嘗試訪問而不會拋出異常:

string title = "Professional C# 8";
if (!books.TryGetValue(title, out string isbn))
{
	Console.WriteLine($"{title} not found");
}

10.8 字典

字典是一個復雜的數據結構,你可以通過Key來訪問其中的數據。字典通常也被當成哈希表(Hash table)或者散列表(Hashmap)。字典的主要特性是快速地通過Key查找到相應的Value。你可以往字典里添加和移除元素,有點像List<T>,但是字典有額外的性能開銷,因為它需要隨時調整在內存中鍵值的存儲位置。請看下圖:

字典的簡化操作

這里簡單地演示了字典添加鍵值時的處理過程。假定此時我們准備往字典里添加員工ID為B4711的數據,首先B4711將會進行哈希轉換,然后通過得到的哈希值關聯上員工名稱Jimmie Johnson。如圖所示,索引31包含了指向實際值的鏈接。圖中的過程是被簡化了的,因為可能存在一個索引需要關聯多個值的情況,並且索引可能是以樹結構進行存儲的。

.NET提供了一些字典類,最常用的可能是Dictionary<TKey, TValue>

10.8.1 字典初始化器

C#提供了字典聲明時候就進行賦值初始化的語法。如下所示,我們創建了Key為int類型,Value為string類型的字典:

var dict = new Dictionary<int, string>()
{
	[3] = "three",
	[7] = "seven"
};

並且,我們為這個字典添加了兩個元素,第一個元素的Key值為3,Value值為three,而第二個元素的Key是7,Value是seven。這個初始化器的語法很好理解,並且我們也是用類似的語法來訪問字典元素的。

10.8.2 鍵的類型

字典中的Key類型需要實現GetHashCode,你可以根據需求重寫Object類的GetHashCode方法。因為字典類時刻需要決定某個鍵值應該存儲的位置,而它是通過調用GetHashCode來確定的。上面例子中的int類型同樣由GetHashCode返回相應的哈希值,因此字典可以計算索引,確定元素實際存儲的位置。這里我們不會深入講解這塊的算法,你只需要知道它涉及到質數,並且字典的容量也是一個質數(prime number)即可。

GetHashCode方法的實現必須滿足以下要求:

  • 同樣的對象必須返回相同的值。
  • 不同的對象可以返回相同的值。
  • 不能拋出異常。
  • 至少要用到一個對象實例的變量(at least one instance field)。
  • 在對象的生命周期內,哈希值不能發生改變。

除了上面必要的限制外,如果GetHashCode的實現滿足了以下條件,會讓人更加滿意:

  • 計算起來越快越好,消耗的資源越低越好。
  • 計算出來的哈希值在整個int類型取值范圍內,最好是均勻分布的。

注意:字典的性能基本上取決於GetHashCode的實現。

為什么說哈希值最好是均勻分布的呢?假如兩個Key返回的哈希值,通過字典計算后,擁有相同的索引,那么字典類就需要去查找能存儲第二個元素的,最近的空閑內存空間,這個查找的過程需要花費一定的時間。這明顯對性能有所損耗。進一步講,不過不止兩個Key而是有大量的Key值返回的都是同一個索引的話,那么沖突的可能性就更大了。只不過,微軟內置的算法盡量地避免了這一風險,它使得計算出來的哈希值盡量均勻分布在int取值范圍的整個區間中。(從文中看不出來為啥要均勻分布,個人感覺大概是因為哈希碼均勻分布的話,計算出來的索引沖突的可能性會小一些。)

除了實現GetHashCode方法之外,想成為Key的類型還需要實現IEquatable<T>接口的Equals方法,或者重寫Object類的Equals方法。這是因為不同的對象實例可能會返回相同的哈希碼,這個時候字典就需要調用Equals方法來對Key值進行比較,比如兩個實例A和B,字典通過調用A.Equals(B)來判斷他倆是否為相同的Key。這意味着,當A.Equals(B)為true的時候,他倆計算出來的哈希值必須是一致的。

這一點看起來很微妙,但它至關重要。假如你在人為實現這些方法的過程中無法保證這一點,那么字典在將A和B這倆實例作為Key的時候,就會判斷出錯。你將會發現一些很滑稽的事情,譬如當你存入為某個Key存入相應的值之后你再也無法獲取它,又或者同樣的Key你會得到不一樣的值。

注意:出於這一點考慮,當你嘗試重寫Equals方法卻沒有重寫GetHashCode的時候,C#編譯器會提示一個編譯警告。

對於System.Object類來說,上面的條件也是成立的。因為它的Equals方法僅僅比較的是引用,而GetHashCode方法返回的哈希值則基於對象的內存地址。這意味着就算不重寫這些方法,以對象為Key的哈希表們理論上也能正常運行。然而,這種方式的問題在於,只有當這些對象的引用是完全相同的時候,Equals方法才會把它們當成是同樣的對象,也就說是相同的Key。這意味着,當你為某個Key存入對象數據的時候,你必須保留這個Key的引用,就算你后面new了一個內部值完全一致的新實例,但因為引用不同,它們就不是相同的key,所以取不出來需要的值。因此,如果你不重寫Equals和GetHashCode方法的話,你會發現字典用起來也不是那么方便。

順帶一提,System.String類實現了IEquatable接口並且適當地重寫了GetHashCode方法。它的Equals方法中提供了值的比較,而GetHashCode返回的哈希值也是基於字符串值的。因此String類型可以很方便的作為字典的Key。

數值類型,比如Int32,也實現了IEquatable接口和GetHashCode方法的重寫。然而,數值類型返回的哈希碼,僅僅只是數值的簡單映射(simply maps to the value)。假如你將數值類型作為字典的Key的話,它自身並不是均勻分布的,因此不符合上面提及的提高性能建議。Int32並不太適合用來作為字典的Key類型。

假如你需要使用某種類型作為Key,但是它既沒有實現IEquatable接口又沒有重寫GetHashCode方法,此時你可以創建一個實現了IEqualityComparer<T>接口的比較器。這個接口如下所示:

public interface IEqualityComparer < in T >
{
	bool Equals(T x, T y);
	int GetHashCode(T obj);
}

它定義了GetHashCode和Equals方法,用來為傳入類型的實例進行比較。在創建字典的時候,你可以給字典的構造函數傳遞這個比較器作為參數。這樣當該類型作為字典的Key時,字典也知道如何正確地計算該類型的哈希碼以及對它進行比較。

10.8.3 字典示例

本小節中的字典示例將創建一個雇員詳細信息的字典,它將EmployeeId對象來作為索引,而相應的值存儲的則是Employee對象。如下所示:

public class EmployeeIdException: Exception
{
	public EmployeeIdException(string message): base(message)
	{}
}
public struct EmployeeId: IEquatable < EmployeeId >
{
	private readonly char _prefix;
	private readonly int _number;
    //這倆屬性是自己添加的,書中並沒有
	public int Number { get => _number; }
	public char Prefix { get => _prefix; }
	public EmployeeId(string id)
	{
		if(id == null) throw new ArgumentNullException(nameof(id));
		_prefix = (id.ToUpper())[0];
		int numLength = id.Length - 1;
		try
		{
			_number = int.Parse(id.Substring(1, numLength > 6 ? 6 : numLength));
		}
		catch(FormatException)
		{
			throw new EmployeeIdException("Invalid EmployeeId format");
		}
	}
	public override string ToString() => _prefix.ToString() + $"{_number,6:000000}";
	public override int GetHashCode() => (_number ^ _number << 16) * 0x15051505;
	public bool Equals(EmployeeId other) => (_prefix == other.Prefix && _number == other.Number); //書中這一塊大概是偽代碼
	public override bool Equals(object obj) => Equals((EmployeeId) obj);
	public static bool operator == (EmployeeId left, EmployeeId right) => left.Equals(right);
	public static bool operator != (EmployeeId left, EmployeeId right) => !(left == right);
}

EmployeeId結構體是用來定義字典中的Key值的,成員包括一個前綴字符和一串員工編號。這些變量都是只讀的,並且只能在構造函數里進行初始化來確保它作為字典Key值是絕對不會改變的。這里重寫了ToString方法來獲取完整的員工ID。根據Key類型的基本要求,EmployeeId同樣實現了IEquatable接口和GetHashCode的重寫。

Equals方法是接口IEquatable中定義的方法,用來比較兩個EmployeeId對象,當它們的成員值都相同的時候則返回true。如果你不想實現IEquatable接口,你也可以直接重寫Object類的Equals方法:

public bool Equals(EmployeeId other) => _prefix == other.Prefix && _number == other.Number;

從現實的角度考慮,員工編碼不會是負數,因此number變量不可能囊括整個int的范圍。因此為了讓數據分布得更均勻一些,GetHashCode里,將number往左按位移動了16位,並與原始值做異或操作,並且最終與一個16進制數0x15051505相乘。

注意:在互聯網上,你還可以找到更多更好的算法來實現哈希碼均勻分布。你也可以直接使用字符串的GetHashCode方法來返回哈希碼。

Employee類則是一個簡單的實體類,包含了雇員的姓名,薪資和員工編號,如下所示:

public class Employee
{
	private string _name;
	private decimal _salary;
	private readonly EmployeeId _id;
	public Employee(EmployeeId id, string name, decimal salary)
	{
		_id = id;
		_name = name;
		_salary = salary;
	}
	public override string ToString() => $"{_id.ToString()}: {_name,-20} {_salary:C}";
}

構造函數里初始化了所有字段的值,而ToString方法則返回整個實例的詳細信息,並且為了顯示得更工整一些,對不同字段指定了格式。

在Main方法中,我們創建了一個Dictionary<TKey,TValue>的實例,其中TKey是EmployeeId類型,而Value則是Employee類型。如下所示:

var idJimmie = new EmployeeId("C48");
var jimmie = new Employee(idJimmie, "Jimmie Johnson", 150926.00 m);
var idJoey = new EmployeeId("F22");
var joey = new Employee(idJoey, "Joey Logano", 45125.00 m);
var idKyle = new EmployeeId("T18");
var kyle = new Employee(idKyle, "Kyle Bush", 78728.00 m);
var idCarl = new EmployeeId("T19");
var carl = new Employee(idCarl, "Carl Edwards", 80473.00 m);
var idMatt = new EmployeeId("T20");
var matt = new Employee(idMatt, "Matt Kenseth", 113970.00 m);
var employees = new Dictionary < EmployeeId, Employee > (31)
	{
		[idJimmie] = jimmie, [idJoey] = joey, 
		[idKyle] = kyle, [idCarl] = carl, [idMatt] = matt
	};
foreach(var employee in employees.Values)
{
	Console.WriteLine(employee);
}

字典的構造函數中我們指定了字典容量為31。請記住容量最好是質數,不過,就算你忘了這一點也沒有太大的關系,字典類會自動找一個比你設置的容量略大一點的質數來作為字典容量。當我們創建好所有的雇員對象之后,通過字典初始化器的語法將它們都添加到字典中。當然,你也可以用Add方法來向字典中添加元素。

當所有的鍵值對(Entry)都加到字典之后,我們在一個while循環中來讀取字典中的值。控制台將會等待你的輸入,並將你輸入的值賦給userInput變量,並且通過字典的TryGetValue方法,來根據你輸入的內容查找員工信息,假如能找到相應的員工信息,將找到的值賦給employee變量,並輸出到控制台上。當你輸入大寫的X的時候,則結束這個應用程序。代碼如下所示:

while(true)
{
	Console.Write("Enter employee id (X to exit)> ");
	var userInput = Console.ReadLine();
	userInput = userInput.ToUpper();
	if(userInput == "X") break;
	EmployeeId id;
	try
	{
		id = new EmployeeId(userInput);
		if(!employees.TryGetValue(id, out Employee employee))
		{
			Console.WriteLine($ "Employee with id {id} does not exist");
		}
		else
		{
			Console.WriteLine(employee);
		}
	}
	catch(EmployeeIdException ex)
	{
		Console.WriteLine(ex.Message);
	}
}

注意:你也可以選擇不使用TryGetValue方法而是通過索引的方式來。只不過當你輸入的Key不存在字典當中時,將會拋出一個KeyNotFoundException。

運行程序,輸出可能如下所示:

C000048: Jimmie Johnson $150,926.00
F000022: Joey Logano $45,125.00
T000018: Kyle Bush $78,728.00
T000019: Carl Edwards $80,473.00
T000020: Matt Kenseth $113,970.00
Enter employee id (X to exit)> T18
T000018: Kyle Bush $78,728.00
Enter employee id (X to exit)> C48
C000048: Jimmie Johnson $150,926.00
Enter employee id (X to exit)> X

10.8.4 Lookup 類

Dictionary<TKey, TValue>中,一個Key僅能對應一個Value。Lookup<TKey, TElement>和Dictionary類很像,只不過它一個Key可以對應一個集合的值。Lookup類的命名空間是System.Linq,在程序集System.Core中實現。

Lookup類無法像普通字典那樣進行創建,你必須調用ToLookup方法才能得到Lookup對象。ToLookup是一個擴展方法,只要實現了IEnumerable<T>的類都可以調用這個方法。在下面的例子當中,我們創建了一個Racer類的列表,並為其添加了一些數據。因為List<T>實現了IEnumerable<T>接口,因此我們可以在Racer列表上調用ToLookup方法。這個方法需要一個Func類型的委托作為參數以便確定Key的選擇器,這里我們選擇的是按參賽者的國籍進行分類。然后我們使用索引的方式獲取國籍為Australia的參賽者集合,並通過foreach循環來打印到控制台上:

var racers = new List < Racer > ();
//書中代碼少寫了ID值
racers.Add(new Racer(1, "Jacques", "Villeneuve", "Canada", 11));
racers.Add(new Racer(2, "Alan", "Jones", "Australia", 12));
racers.Add(new Racer(3, "Jackie", "Stewart", "United Kingdom", 27));
racers.Add(new Racer(4, "James", "Hunt", "United Kingdom", 10));
racers.Add(new Racer(5, "Jack", "Brabham", "Australia", 14));
var lookupRacers = racers.ToLookup(r => r.Country);
foreach(Racer r in lookupRacers["Australia"])
{
	Console.WriteLine(r);
}

注意:你可以在第十二章,"LINQ"中了解更多的擴展方法。而lambda表達式則是在第8章,"委托,Lambda和事件"中已經詳細介紹過了。

運行程序,輸出如下所示:

Alan Jones
Jack Brabham

10.8.5 有序字典

SortedDictionary<TKey, TValue>是一顆二叉樹(binary search tree),基於Key值進行排序。作為Key的類型必須實現IComparable<TKey>接口。假如Key類無法進行擴展,你也可以創建一個實現了IComparer<TKey>類型的比較器,並在使用構造函數創建有序字典時,將其作為參數傳遞。

在前面的章節中,你已經了解了有序列表的相關知識。有序字典和有序列表有一些類似的功能,但因為有序列表是基於Array實現,而有序字典則是基於字典實現的,它們也有不一樣的地方:

  • 有序列表占用的內存比有序字典要少。
  • 有序字典插入和移除元素的時候更快。
  • 當用已經排好序的數據填充新集合時,如果不需要擴容的話,有序列表的速度會非常快。

10.9 集

一個只擁有不同元素的集合我們稱之為集(Set)。.NET Core中包含兩種集,HashSet<T>SortedSet<T>,它們都實現了ISet<T>接口,前者中的元素是亂序的,而后者則是有序的。ISet接口的定義如下所示:

public interface ISet<T> : ICollection<T> , IEnumerable<T> , IEnumerable
{
	bool Add(T item);
	void ExceptWith(IEnumerable<T> other);
	void IntersectWith(IEnumerable<T> other);
	bool IsProperSubsetOf(IEnumerable<T> other);
	bool IsProperSupersetOf(IEnumerable<T> other);
	bool IsSubsetOf(IEnumerable<T> other);
	bool IsSupersetOf(IEnumerable<T> other);
	bool Overlaps(IEnumerable<T> other);
	bool SetEquals(IEnumerable<T> other);
	void SymmetricExceptWith(IEnumerable<T> other);
	void UnionWith(IEnumerable<T> other);
}

在多個集之間,它提供了創建並集和交集的方法,還可以判斷一個集合是否為另外一個集合的超集或者子集。

在下面的示例代碼中,我們創建了三個string類型的集,並且用F1方程式賽車的信息填充它們。

var companyTeams = new HashSet < string > ()
{
	"Ferrari", "McLaren", "Mercedes"
};
var traditionalTeams = new HashSet < string > ()
{
	"Ferrari", "McLaren"
};
var privateTeams = new HashSet < string > ()
{
	"Red Bull", "Toro Rosso", "Force India", "Sauber"
};
if(privateTeams.Add("Williams"))
{
	Console.WriteLine("Williams added");
}
if(!companyTeams.Add("McLaren"))
{
	Console.WriteLine("McLaren was already in this set");
}

運行程序,輸出如下所示:

Williams added
McLaren was already in this set

除了ISet<T>接口之外,HashSet<T>還實現了ICollection<T>接口。這倆接口中都定義了Add方法,類為此提供了不同的Add方法實現並根據Add方法的返回值進行了區分。這里我們調用的是返回bool值的Add方法,它可以用來判斷元素是否被添加到集之中。假如集之中已經存在該元素了,就會返回一個false,表示不用添加。我們補充一下HashSet<T>的聲明:

[NullableContext(1), Nullable((byte) 0)]
public class HashSet<T> : ICollection<T> , IEnumerable<T> , IEnumerable, IReadOnlyCollection<T> , ISet<T> , IDeserializationCallback, ISerializable
{
    //...其它成員和方法
    //外部能調用的其實只有返回值為bool的Add方法
	public bool Add(T item);
	void ICollection<T>.Add(T item);
}

IsSubsetOf和IsSupersetOf方法用來比較兩個實現了IEnumerable<T>接口的集,返回結果為Boolean值。下面是示例代碼:

if (traditionalTeams.IsSubsetOf(companyTeams))
{
	Console.WriteLine("traditionalTeams is subset of companyTeams");
}
if (companyTeams.IsSupersetOf(traditionalTeams))
{
	Console.WriteLine("companyTeams is a superset of traditionalTeams");
}

在這里,IsSubsetOf方法驗證了traditionalTeams里的每個元素是否都包含在companyTeams中,而IsSupersetOf則檢查traditionalTeams是否有任何別的元素沒有包含在companyTeams中。輸出結果如下所示:

traditionalTeams is a subset of companyTeams
companyTeams is a superset of traditionalTeams

Williams也屬於traditionalTeams,我們調用Add方法將它加到traditionalTeams中:

traditionalTeams.Add("Williams");
if(privateTeams.Overlaps(traditionalTeams))
{
	Console.WriteLine("At least one team is the same with traditional " 
                      + "and private teams");
}

因為privateTeams也包含了Williams,因此兩者確實有交集(Overlap),所以控制台會輸出:

At least one team is the same with traditional and private teams.

變量allTeams是一個新創建的SortedSet<string>實例,我們通過UnionWith方法,將companyTeams, privateTeams, 以及traditionalTeams合並到allTeams中:

var allTeams = new SortedSet<string>(companyTeams);
allTeams.UnionWith(privateTeams);
allTeams.UnionWith(traditionalTeams);
Console.WriteLine();
Console.WriteLine("all teams");
foreach (var team in allTeams)
{
	Console.WriteLine(team);
}

因為集里面元素都具有唯一性,因此不同組之間重復的元素在合並后的allTeams里也只會出現一次,結果如下所示:

Ferrari
Force India
Lotus
McLaren
Mercedes
Red Bull
Sauber
Toro Rosso
Williams

下面的例子中,ExceptWith方法將privateTeams中所有的元素都從allTeams中移除:

allTeams.ExceptWith(privateTeams);
Console.WriteLine();
Console.WriteLine("no private team left");
foreach (var team in allTeams)
{
	Console.WriteLine(team);
}

結果如下:

Ferrari
McLaren
Mercedes

10.10 性能

大部分集合提供的功能大同小異。舉個例子,有序列表提供的特性幾乎就跟有序字典一模一樣。盡管如此,它們在性能上還是有很大差異的。有些類占用的內存很少,而有些類在檢索方面更快一些。MSDN文檔中提供了不同集合方法的性能提示,通過大O函數來告訴你不同操作所需的時間復雜度,常見的復雜度有O(1),O(log n)和O(n)等。

O(1)意味着某項操作花費的時間是常數級別,不會隨着集合中元素個數而發生變化。舉個例子,ArrayList類添加元素的操作就是O(1)復雜度的,不管列表中有多少個元素,往列表末尾新增一個元素所需的時間都不會有太大的變化。Count屬性記錄了列表元素的個數,因此很輕松就能找到列表的末尾。

O(n)意味着某項操作最壞情況下將花費的時間為N。前一段例子中,ArrayList的Add方法在列表容量不足,需要重新請求內存時,花費的時間將可能會達到N。修改列表的容量將申請一塊新的更大的內存,並將列表原有的元素復制到新內存當中,耗費的時間將會根據元素的個數而呈線性增長。

O(log n)意味着某項操作花費的時間仍然受集合中元素個數的影響,只不過它耗時的增長不是線性的而是對數級的。在有序字典中執行插入操作的時間復雜度就是O(log n),有序列表也不例外。而有序字典比有序列表插入要快一些,因為往樹里插入新節點比往列表中插入新元素要高效一些。

下面的表格列舉了集合類部分操作(如新增,插入和刪除元素)的時間復雜度。通過這個表格,你可以根據你的實際需求選擇最佳的集合類。最左側的列是集合類的名稱,接下來的ADD列顯示的是不同集合類增加元素所需的時間復雜度。有些集合使用的是Add方法,而有些集合,如棧,用的則是Push,隊列用的是Enqueue。所以表頭僅代表操作而非具體方法名。

如果你在一個單元格中看到多個時間復雜度函數,通常是因為它們在容量充裕和需要申請新的內存時情況不同。舉個例子,對於List類來說,正常情況下新增一個元素需要O(1)復雜度,但是當它容量不足需要申請新內存時,這個操作就要花費O(n)的時間,原來的集合越大,花費的時間就越多。你可以通過為集合設置一個足夠大的初始容量來避免操作過程中引起的容量調整。

假如某個單元格的值為N/A,意味着這項操作對該類型的集合來說不可用。

集合 ADD INSERT REMOVE ITEM SORT FIND
List O(1)/O(n) O(n) O(n) O(1) O(nlogn)/O(n^2) O(n)
Stack O(1)/O(n) N/A O(1) N/A N/A N/A
Queue O(1)/O(n) N/A O(1) N/A N/A N/A
HashSet O(1)/O(n) O(1)/O(n) O(1) N/A N/A N/A
SortedSet O(1)/O(n) O(1)/O(n) O(1) N/A N/A N/A
LinkedList O(logn) O(1) O(1) N/A N/A O(n)
Dictionary O(1)/O(n) N/A O(1) O(1) N/A N/A
SortedDictionary O(logn) N/A O(logn) O(logn) N/A N/A
SortedList O(1)/O(logn)/O(n) N/A O(n) O(logn)/O(n) N/A N/A

10.11 小結

本章致力於講解如何使用不同類型的泛型集合。數組是定長的,但你可以使用列表來處理需要動態增長的應用場景。隊列可以用來處理先進先出元素的訪問,而棧則可以用來處理后進先出。鏈表在插入和移除元素方面很快,不過在查詢方面較慢。通過鍵Key和值Value,你可以使用字典,它對於插入和查找的效率都很高。集對於保證集合中元素的唯一性很有用,並且還提供了能自動排序的集。

在下一章,"特殊集合"中,我們將為你介紹一些特殊集合類的細節。


免責聲明!

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



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