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)提供列舉操作。 - 幾乎所有集合都提供新增、刪除、計數,來自基礎介面
ICollection
、ICollection<T>
。 IList
、IList<T>
提供了陣列的索引器、查詢、插入等操作,幾乎所有具體的集合型別都實現了該介面。- Array 是一個抽象類,是所有陣列
T[]
的基類,她是型別安全的。 - 推薦儘量使用陣列T[]、泛型版的集合,提供了更好的型別安全和效能。
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 |
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可列舉集合
IEnumerable
、IEnumerable<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()
、Current
、MoveNext()
實現的,因此介面並不是必須的,只要有對應的成員即可。
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
介面的私有類,可以看做是一個高階的語法糖,有一些限制(要求):
- 迭代器的返回型別可以是
IEnumerable
、IEnumerator
或他們的泛型版本。還可以用 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();
}
}
03、集合!裝逼了!
3.1、⭐常用集合型別
集合型別 | 特點/說明 |
---|---|
Array(陣列之父) | 是一個抽象類,是有所有陣列的父類,提供了很多用於陣列操作的靜態方法,詳見下一章節 |
陣列:T[] ⭐ |
定長(記憶體連續)的陣列集合,所有陣列都繼承自 Array,int[] 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,內部用一個紅黑樹儲存,新增刪除慢,因為要維護元素的狀態 |
Queue、Queue<T> |
佇列,先進先出(FIFO),Enqueue(後入)、Dequeue(前出) |
PriorityQueue<T, TP> |
支援優先順序順序的佇列,只能保證優先順序順序,相同優先順序不保證先進先出 |
Stack、Stack<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) | 填充陣列值為value ,Array.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 |
IndexOf、LastIndexOf | 根據元素查詢索引位置,-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的擴充套件來代替。
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] //倒數第一個 |
..展開運算子 | 支援集合、可列舉表示式,展開每個列舉元素到陣列,配合集合表示式使用比較方便。 |
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 型別資料,表示一個索引範圍,常用與集合操作。
- 可省略
a
或b
,預設則表示到邊界。 - 可結合倒數
^
使用。
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);
}
}
很明顯,自動長度的List
速度更慢,也消耗了更多的記憶體。
🚩儘量不建立新陣列,使用一些陣列方法時需要注意儘量不要建立新的陣列,如下面示例程式碼:
var arr = Enumerable.Range(1, 100).ToArray();
// 需求:對arr進行反序操作
var arr2 = arr.Reverse().ToArray(); //用Linq,建立了新陣列
Array.Reverse(arr); //使用Array的靜態方法,原地反序,沒有建立新物件
比較一下上面兩種反序的效能:
參考資料
- 集合和資料結構
- 《C#8.0 In a Nutshell》
©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀