C#.Net築基-集合知識全解

安木夕發表於2024-06-20

image.png

01、集合基礎知識

.Net 中提供了一系列的管理物件集合的型別,陣列、可變列表、字典等。從型別安全上集合分為兩類,泛型集合非泛型集合,傳統的非泛型集合儲存為Object,需要型別轉。而泛型集合提供了更好的效能、編譯時型別安全,推薦使用。

.Net中集合主要集中在下面幾個名稱空間中:

1.1、集合的起源:介面關係

集合介面 特點/說明
IEnumerator、IEnumerator<T> 列舉器(還不是集合),提供foreach列舉項的能力
IEnumerable、IEnumerable<T> 可列舉集合,幾乎所有集合都實現了該介面,屬於集合最基礎的介面。就一個IEnumerator GetEnumerator() 方法,返回一個列舉器。
ICollection、ICollection<T> 提供了基礎集合操作:Count、Add()、Remove()、Clear()、Contains()、CopyTo()
IList、IList<T> 索引器[int index]、IndexOf()、Insert()、RemoveAt()
IDictionary、IDictionary<TKey, TValue> 鍵值集合操作:Keys、Values、索引器[Key]、Add()、Remove()
IReadOnly*** 只讀的集合,包括IReadOnlyCollection、IReadOnlyList、IReadOnlyDictionary等

  • 天賦技能 —— foreach:幾乎所有集合都可以用foreach迴圈操作,是因為他們都繼承自IEnumerable介面,由列舉器(IEnumerator)提供列舉操作。
  • 幾乎所有集合都提供新增、刪除、計數,來自基礎介面 ICollectionICollection<T>
  • IListIList<T> 提供了陣列的索引器、查詢、插入等操作,幾乎所有具體的集合型別都實現了該介面。
  • Array 是一個抽象類,是所有陣列T[]的基類,她是型別安全的。
  • 推薦儘量使用陣列T[]、泛型版的集合,提供了更好的型別安全和效能。

image.png

1.2、非泛型集合—— 還有什麼存在的價值?

  • 非泛型的Hashtable,Key、Value都是Object型別的,Dictionary 是泛型版本的 Hashtable。
  • ArrayList 是非泛型版本的 List<T>,基本很少使用,也儘量不用。

❓既然非泛型版本型別不安全,效能還差,為什麼還存在呢?

主要是歷史原因,泛型是.Net2.0 引入的,因此為了向後相容,依然保留的非泛型版本集合。在介面實現時,非泛型介面一般都是顯示實現的,因此基本不會用到。不過在有些場景下,非泛型介面、集合還是有點用的,如型別不固定的集合,或者用介面作為約束條件或型別判斷。

ArrayList arr = new ArrayList();
arr.Add(1);
arr.Add("sam");
arr.Add(new Point());
if (arr is IList) {}

class User<T> where T :IList {}

1.3、Collection<T>List<T>有何不同?

❓兩者比較相似,他們到底有什麼區別呢?該如何選擇?

  • Collection<T> 作為自定義集合基類,內部提供了一些virtual的實現,便於繼承實現自己的集合型別。其內部集合用的就是List<T>,如下部分原始碼 Collection.cs。
  • List<T> 作為集合使用,是最常用的可變長集合型別了,他最佳化了效能,但是丟失了可擴充套件性,沒有提供任何可以override的成員。
public class Collection<T> 
{
    public Collection()
    {
        items = new List<T>();
    }
    protected virtual void InsertItem(int index, T item)
    {
        items.Insert(index, item);
    }
}

02、列舉器——foreach的秘密!

foreach 用來迴圈迭代可列舉物件,用一種非常簡潔、優雅的姿勢訪問可列舉元素。常用於陣列、集合,當然不僅限於集合,只要符合要求列舉要求的都可以。

foreach 可列舉型別 說明
陣列 包括Array陣列、List、字典等,他們都實現了IEnumerable介面的。
IEnumerable 可列舉介面
IEnumerable<T> 同上,泛型版本
GetEnumerator()方法 包含公共方法“IEnumerator GetEnumerator();”的任意型別
yield迭代器 yield語句實現的迭代器,實際返回的也是IEnumerable、IEnumerator

image

image.png

2.1、IEnumerator列舉器

列舉可以foreach 列舉的密碼是他們都繼承自IEnumerable介面,而更重要的是其內部的列舉器 —— IEnumerator。列舉器IEnumerator定義了向前遍歷集合元素的基本協議,其申明如下:

public interface IEnumerator
{
	object Current { get; }

	bool MoveNext();

	void Reset();  //這個方法是非必須的,用於重置遊標,可不實現
}
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
	new T Current { get; }
}
  • MoveNext() 移動當前元素到下一個位置,Current獲取當前元素,如果沒有元素了,則MoveNext()返回false。注意MoveNext()會先呼叫,因此首次MoveNext()是把位置移動到第一個位置。
  • Reset()用於重置到起點,主要用於COM互操作,使用很少,可不用實現(直接丟擲 NotSupportedException)。

📢 該介面不是必須的,只要實現了公共的Current、無參MoveNext()成員就可進行列舉操作。

實現一個獲取偶數的列舉器:

void Main()
{
	var evenor = new EvenNumbersEnumerator(1, 10);
	while (evenor.MoveNext())
	{
		Console.WriteLine(evenor.Current); //2 4 6 8 10
	}
}
//獲取偶數的列舉器
public struct EvenNumbersEnumerator : IEnumerator<int> //不繼承IEnumerator介面,效果也是一樣的
{
	private int _start;
	private int _end;
	private int _position = int.MinValue;

	public EvenNumbersEnumerator(int start, int end)
	{
		_start = start;
		_end = end;
	}
	public int Current => _position;
	object IEnumerator.Current => Current;  //顯示實現非泛型介面,然後隱藏起來

	public bool MoveNext()
	{
		if (_position == int.MinValue)
			_position = (int.IsEvenInteger(_start) ? _start : _start + 1) - 2;
		_position += 2;
		return (_position <= _end);
	}
	public void Reset() => throw new NotSupportedException();
	public void Dispose() { } //IEnumerator 是實現了 IDisposable介面的
}

2.2、IEnumerable可列舉集合

IEnumerableIEnumerable<T>是所有集合的基礎介面,其核心方法就是 GetEnumerator() 獲取一個列舉器。

public interface IEnumerable
{
	IEnumerator GetEnumerator();
}
public interface IEnumerable<out T> : IEnumerable
{
	new IEnumerator<T> GetEnumerator();
}

📢 該介面也不是必須的,只要包含public的“GetEnumerator()”方法也是一樣的。

有了 GetEnumerator(),就可以使用foreach來列舉元素了,這裡foreach會被編譯為 while (evenor.MoveNext()){} 形式的程式碼。在上面 偶數列舉器的基礎上實現 一個偶數型別。

void Main()
{
	var evenNumber = new EvenNumbers();
	foreach (var n in evenNumber)
	{
		Console.WriteLine(n); //2 4 6 8 10
	}
}
public class EvenNumbers : IEnumerable<int> //不用必須繼承介面,只要有GetEnumerator()即可
{
	public IEnumerator<int> GetEnumerator()
	{
		return new EvenNumbersEnumerator(1, 10);
	}

	IEnumerator IEnumerable.GetEnumerator() //顯示實現非泛型介面,然後隱藏起來
	{
		return GetEnumerator();
	}
}

foreach 迭代其實就是呼叫其GetEnumerator()CurrentMoveNext()實現的,因此介面並不是必須的,只要有對應的成員即可。

foreach (var n in evenNumber)
{
    Console.WriteLine(n); //2 4 6 8 10
}
/************** 上面程式碼編譯後的效果如下:*****************/
IEnumerator<int> enumerator = evenNumber.GetEnumerator();
try
{
    while (enumerator.MoveNext ())
    {
         int i = enumerator.Current;
         Console.WriteLine (i);
    }
}
finally
{
    if (enumerator != null)
    {
         enumerator.Dispose ();
    }
}

2.3、yield 迭代器

yield return 是一個用於實現迭代器的專用語句,它允許你一次返回一個元素,而不是一次性返回整個集合。常來用來實現自定義的簡單迭代器,非常方便,無需實現IEnumerator介面。
🔸惰性執行:元素是按需生成的,這可以提高效能並減少記憶體佔用(當然這個要看具體情況),特別是在處理大型集合或複雜的計算時。迭代器方法在被呼叫時,不會立即執行,而是在MoveNext()時,才會執行對應yield return的語句,並返回該語句的結果。📢Linq裡的很多操作也是惰性的。
🔸簡化程式碼:使用yield return可以避免手動編寫迭代器的繁瑣過程。
🔸狀態保持yield return自動處理狀態保持,使得在每次迭代中儲存當前狀態變得非常簡單。每一條yield return語句執行完後,程式碼的控制權會交還給呼叫者,由呼叫者控制繼續。

yield迭代器方法會被會被編譯為一個實現了IEnumerator 介面的私有類,可以看做是一個高階的語法糖,有一些限制(要求):

  • 迭代器的返回型別可以是IEnumerableIEnumerator或他們的泛型版本。還可以用 IAsyncEnumerable<T> 來實現非同步的迭代器。
  • yield break 語句提前退出迭代器,不可直接用return,是非法的。
  • yield語句不能和try...catch一起使用。
void Main()
{
    var us = new User();
    foreach (string name in us)
    {
        Console.WriteLine(name); //sam kwong
    }
    foreach (string name in us.GetEnumerator1())
    {
        Console.WriteLine(name); //1  sam  2
    }
    foreach (string name in us.GetEnumerator2())
    {
        Console.WriteLine(name);//KWONG
    }
}
public class User
{
    private string firstName = "sam";
    private string lastName = "Kwong";
    public IEnumerator GetEnumerator()
    {
        yield return firstName;
        yield return lastName;
    }
    public IEnumerable GetEnumerator1() //返回IEnumerable
    {
        Console.WriteLine("1");
		yield return firstName;  //第一次執行到這裡
		Console.WriteLine("2");
		yield break;             //第二次執行到這裡,也是最後一次了
		yield return lastName;
    }
    public IEnumerable<string> GetEnumerator2() //返回IEnumerable<string>
    {
        yield return lastName.ToUpper();
    }
}

image.png

03、集合!裝逼了!

3.1、⭐常用集合型別

集合型別 特點/說明
Array(陣列之父) 是一個抽象類,是有所有陣列的父類,提供了很多用於陣列操作的靜態方法,詳見下一章節
陣列:T[] 定長(記憶體連續)的陣列集合,所有陣列都繼承自 Arrayint[] arr = {1,2,3};
ArrayList 可變長陣列,存放Object物件,內部會自動擴容,很少使用。
List<T> 泛型版的ArrayList,可變長集合,很常用。
HashTable 儲存Key、Value結構的雜湊表,可根據Key快速獲取Value值。Key不可重複,都是Object型別。
Dictionary<TK, TV> 泛型版本的雜湊表,代替HashTable
HashSet<T> 只有Key的Dictionary,Key不可重複,適用於與不可重複的集合
SortedSet<T> 支援排序的HashSet,內部用一個紅黑樹儲存,新增刪除慢,因為要維護元素的狀態
QueueQueue<T> 佇列,先進先出(FIFO),Enqueue(後入)、Dequeue(前出)
PriorityQueue<T, TP> 支援優先順序順序的佇列,只能保證優先順序順序,相同優先順序不保證先進先出
StackStack<T> 棧表,後進先出(LIFO),Push(前入)、Pop(前出)
SortedList<TKey,TValue> 按照Key排序的列表,內部為陣列,支援索引器,在插入時按照順序儲存(效能較差)
SortedDictionary<TK,TV> 同SortedList,內部為紅黑樹儲存鍵值對,大資料量時效能更好,不支援索引器。
LinkedList<T> 雙向連結串列,每個節點包含一個值、前節點指標、後節點指標,不支援索引器。插入刪除很快O(1),不會移動元素,查詢O(n)較慢,會遍歷整個集合。
ListDictionary 單向連結串列字典,輕量級的字典,它適用於小型集合(小於10個)。
HybridDictionary Hashtable(高查詢效率)和ListDictionary(記憶體少)的雜交集合,根據數量內部切換容器。
ReadOnlyCollection<T>‌ 只讀集合,接收一個IList集合,可以看做是對普通集合的只讀包裝,修改會丟擲異常。
Immutable*** 不可變集合,透過靜態方法Create()建立並初始化,任何修改都會建立新的集合。
Collection<TItem> 專為自定義擴充套件(繼承)用的集合基類
KeyedCollection<TK, TV> 同上,專為自定義擴充套件(繼承)用的字典集合基類,繼承自Collection<TItem>
BitArray 位陣列(不是位元組,只有一位),用來儲存bool值,支援位操作。
Concurrent*** 執行緒安全的集合,各種型別的集合都有執行緒安全的版本,可多執行緒訪問
ConcurrentQueue<T> 執行緒安全的佇列 Queue
ConcurrentStack<T> 執行緒安全的棧表 Stack
ConcurrentDictionary<T,K> 執行緒安全的字典 Dictionary
BlockingCollection<T> 提供阻塞功能的執行緒安全集合,適合用於生產者-消費者場景,消費者會自動阻塞等待生產者訊息
ConcurrentBag<T> 執行緒安全的集合List<T>
Channel<T> 專用於生產、消費場景的現代非同步訊息佇列,比 BlockingCollection更強大、靈活。
ArrayList arr2 = new ArrayList();
arr2.Add(null);
arr2.Add("sam");
arr2.Add(1);
Console.WriteLine(arr2[1]);

3.2、⭐陣列Array[]

Array 陣列是一種有序的集合,透過唯一索引編號進行訪問。陣列T[]是最常用的資料集合了,幾乎支援建立任意型別的陣列。Array是所有的陣列T[]的(隱式)基類,包括一維、多維陣列。CLR會將陣列隱式轉換為 Array 的子類,生成一個偽型別。

  • 索引從0開始。
  • 定長:陣列在申明時必須指定長度,超出長度訪問會丟擲IndexOutOfRangeException異常。
  • 記憶體連續:為了高效訪問,陣列元素在記憶體中總是連續儲存的。如果是值型別陣列,值和陣列是儲存在一起的;如果是引用型別陣列,則陣列值儲存其引用物件的(堆記憶體)地址。因此陣列的訪問是非常高效的!
  • 多維陣列:矩陣陣列 用逗號隔開,int[,] arr = {{1,2},{3,4}};
  • 多維陣列:鋸齒形陣列(陣列的陣列),int[][] arr =new int[3][];
int[] arr = new int[100];      //申請長度100的int陣列
int[] arr2 = new int[]{1,2,3}; //申請並賦值,長度為3
int[] arr3 = {1,2,3};          //同上,前面已制定型別,後面可省略
arr[1] = 1;
Console.WriteLine(arr[2]);     //未賦值,預設為0

📢 幾乎大部分程式語言的陣列索引都是從0開始的,如C、Java、Python、JavaScript等。當然也有從1開始的,如MATLAB、R、Lua。

屬性 特點/說明
Length 陣列的長度、元素的數量
Rank 獲取陣列的維度,一維陣列就是1
[int index] 索引器,這是方法陣列元素的最常用方式,沒有之一!
🔸方法 特點/說明
AsReadOnly<T>(T[]) 獲取一個只讀的陣列 ReadOnlyCollection<T>
CopyTo(array, index) 複製陣列元素到目標陣列array,引數index為目標array的索引位置
object? GetValue(Int32) 獲取制定索引位置的值,對應的還有SetValue(obj, index),這兩個方法都會裝箱,慎用!
🔸靜態方法 特點/說明
BinarySearch(Array) 二分查詢,返回找到的元素的索引,負數表示沒找到。前提是陣列必須是有序的。
Clear(Array) 清除陣列的內容
Clone() 建立 Array 的淺表副本
Copy(array1, array2) 將一個array1中的元素複製到陣列array2
CreateInstance(Type, len) 建立陣列,指定型別和長度,Array.CreateInstance(typeof(int),10)
Exists<T>(arr, Predicate) 根據謂詞條件判斷是否存在的元素,返回bool,Array.Exists(arr,s=>s>5)
Array.Fill(arr, value) 填充陣列值為valueArray.Fill(arr,1);
Find<T>(arr, Predicate) 根據條件查詢元素,返回第一個匹配的元素,Array.Find(arr,s=>s>5)
FindLast(arr, Predicate) 同上,返回最後一個匹配的元素
FindAll(arr, Predicate) 查詢所有匹配的元素,返回陣列。
FindIndex(T[], Predicate) 查詢第一個匹配元素的索引,對應的還有 FindLastIndex
ForEach(T[], Action<T>) 迴圈遍歷元素執行action
IndexOfLastIndexOf 根據元素查詢索引位置,-1表示沒找到
Reverse<T>(T[]) 反轉元素順序,Array.Reverse(arr),不會建立新陣列。‼️Linq的Reverse會建立新物件
Resize<T>(ref T[], Int) 更改陣列長度,會建立一個新陣列,所以用了ref
Sort(Array) 對陣列元素排序,Array.Sort(arr),不會建立新陣列。
TrueForAll(T[], Predicate) 判斷是否所有元素都符合維詞條件,返回bool,Array.TrueForAll(arr,s=>s>1)
🔸擴充套件方法 特點/說明
IEnumerable Cast<T>() 強制轉換為指定型別的陣列,延遲實現+會裝箱,var arr2 = arr.Cast<uint>()
AsSpan() 建立陣列的Span<T>物件
AsMemory() 建立陣列的Memory<T>物件

📢 透過上表發現,Array 的很多方法都是靜態方法,而不是例項方法,這一點有點困惑,造成了使用不便。而且大部分方法都可以用Linq的擴充套件來代替。

image.png

3.3、Linq擴充套件

LINQ to Objects (C#) 提供了大量的對集合操作的擴充套件,可以使用 LINQ 來查詢任何可列舉的集合(IEnumerable)。擴充套件實現主要集中在 程式碼 Enumerable 類(原始碼 Enumerable.cs),涵蓋了查詢、排序、分組、統計等各種功能,非常強大。

  • 簡潔、易讀,可以鏈式操作,簡單的程式碼即可實現豐富的篩選、排序和分組功能。
  • 延遲執行,只有在ToList、ToArray時才會正式執行,和yeild一樣的效果。
var arr = Enumerable.Range(1, 100).ToArray(); //生成一個陣列
var evens = arr.Where(n => int.IsEvenInteger(n)); //並沒有執行
var arr2 = arr.GroupBy(n => n % 10).ToArray();

04、集合的一些小技巧

技巧 說明
集合初始化器{} 省略new和型別,用{}初始化值,int[] arr = {1,2,3};
集合表示式[] C#12,簡化集合賦值,比上面更簡化,int[] arr = [1,2,3]
範圍運算子 a..b C#8,表示a到b的範圍(不含b),可獲取集合中指定範圍的子集var sub =arr[1..3]
^n倒數 C#8,索引倒數,arr[^1]//倒數第一個
..展開運算子 支援集合、可列舉表示式,展開每個列舉元素到陣列,配合集合表示式使用比較方便。

image.png

4.1、集合初始化器{}

同類的初始化器類似,用{}來初始化設定集合值,支援陣列、字典。

//陣列
int[] arr1 = new int[3] { 1, 2, 3 };
int[] arr2 = new int[] { 1, 2, 3 };
int[] arr4 = { 1, 2, 3 };
//字典
Dictionary<int, string> dict1 = new() { { 1, "sam" }, { 2, "william" } };
Dictionary<int, string> dict2 = new() { [5] = "sam", [6] = "zhangsan" }; //索引器寫法
var dict3 = new Dictionary<int, string> { { 1, "sam" }, { 2, "william" } };

4.2、集合表示式[]

集合表示式 簡化了集合的申明和賦值,直接用[]賦值,比初始化器更簡潔,語法形式和JavaScript差不多了。可用於陣列、Sapn、List,還可以自定義集合生成器

int[] iarr1 = new int[] { 1, 2, 3, 4 }; //完整的申明方式
int[] iarr2 = { 1, 2, 3, 4 }; //前面宣告有型別int[],可省略new
int[] iarr3 = [1, 2, 3, 4];   //簡化版的集合表示式

List<string> list = ["a1", "b1", "c1"];
Span<char> sc = ['a', 'b', 'c'];
HashSet<string> set = ["a2", "b2", "c2"];

//..展開運算子,把集合中的元素展開
List<string> list2 = [.. list,..set, "ccc"]; //a1 b1 c1 a2 b2 c2 ccc

4.3、範圍運算子..

a..b表示a到b的範圍(不含b),其本質是 System.Range 型別資料,表示一個索引範圍,常用與集合操作。

  • 可省略ab,預設則表示到邊界。
  • 可結合倒數^使用。
int[] arr =  new[] { 0, 1, 2, 3, 4, 5 };
Console.WriteLine(arr[1..3]); //1 2  //索引1、2
Console.WriteLine(arr[3..]); //3 4 5 //索引3到結尾
Console.WriteLine(arr[..]);  //全部
Console.WriteLine(arr[^2..]);  //4 5 //倒數到2到結尾

var r = 1..3;
Console.WriteLine(r.GetType()); //System.Range

自定義的索引器也可用用範圍Range作為範圍引數。

05、提高集合效能的一些實踐

技巧 說明
使用泛型版本集合 儘量不使用非泛型版本的集合(如ArrayList、Hashtable),避免裝箱。
初始化合適的容量 建立集合時,根據實際情況儘量給定一個合適的初始容量,避免頻繁的擴容
使用Span 使用Span<T>Memory<T>進行高效記憶體訪問,更多參考《高效能的Span、Memory
使用ArrayPool<T> 對於頻繁獲取集合資料的場景,採用池化技術複用陣列物件,減少陣列物件的建立

🚩儘量給集合一個合適的“容量”( capacity),幾乎所有可變長集合的“動態變長”其實都是有代價的。他們內部會有一個定長的“陣列”,當新增元素較多(大於容量)時,就會自動擴容(如倍增),然後把原有“陣列”資料複製(搬運)到新“陣列“中。

  • 因此在使用可變長集合時,儘量給一個合適的大小,可減少頻繁擴容帶來的效能影響。當然也不可盲目設定一個比較大的容量,這就很浪費記憶體空間了。stringBuilder也是一樣的道理。
  • 可變長集合的插入、刪除效率都不高,因為會移動其後續元素。

下面測試一下List<T>,當建立一個長度為1000的List時,設定容量(1000)和不設定容量(預設4)的對比。

int max = 10000;
public void List_AutoLength(){
    List<int> arr = new List<int>();
    for (int i = 0; i < max; i++)
    {
        arr.Add(i);
    }
}
public void List_FixedLength()
{	
    List<int> arr = new List<int>(max);
    for (int i = 0; i < max; i++)
    {
        arr.Add(i);
    }
}

image.png

很明顯,自動長度的List速度更慢,也消耗了更多的記憶體。

image.png

🚩儘量不建立新陣列,使用一些陣列方法時需要注意儘量不要建立新的陣列,如下面示例程式碼:

var arr = Enumerable.Range(1, 100).ToArray();
// 需求:對arr進行反序操作
var arr2 = arr.Reverse().ToArray(); //用Linq,建立了新陣列	
Array.Reverse(arr);                 //使用Array的靜態方法,原地反序,沒有建立新物件

比較一下上面兩種反序的效能:

image.png


參考資料

  • 集合和資料結構
  • 《C#8.0 In a Nutshell》

©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀

相關文章