.NET 基礎拾遺(3): 字串、集合和流

發表於2015-10-14

一、字串處理

1.1 StringBuilder型別有什麼作用?

眾所周知,在.NET中String是引用型別,具有不可變性,當一個String物件被修改、插入、連線、截斷時,新的String物件就將被分配,這會直接影響到效能。但在實際開發中經常碰到的情況是,一個String物件的最終生成需要經過一個組裝的過程,而在這個組裝過程中必將會產生很多臨時的String物件,而這些String物件將會在堆上分配,需要GC來回收,這些動作都會對程式效能產生巨大的影響。事實上,在String的組裝過程中,其臨時產生的String物件例項都不是最終需要的,因此可以說是沒有必要分配的。

鑑於此,在.NET中提供了StringBuilder,其設計思想源於構造器(Builder)設計模式,致力於解決複雜物件的構造問題。對於String物件,正需要這樣的構造器來進行組裝。StringBuilder型別在最終生成String物件之前,將不會產生任何String物件,這很好地解決了字串操作的效能問題。

以下程式碼展示了使用StringBuilder和不適用StringBuilder的效能差異:(這裡的效能檢測工具使用了老趙的CodeTimer類)

上述程式碼的執行結果如下圖所示,可以看出由於StringBuilder不會產生任何的中間字串變數,因此效率上優秀不少!

看到StringBuilder這麼優秀,不禁想發出一句:臥槽,牛逼!

於是,我們拿起我們的錘子(Reflector)撕碎StringBuilder的外套,看看裡面到底裝了什麼?我們發現,在StringBuilder中定義了一個字元陣列m_ChunkChars,它儲存StringBuilder所管理著的字串中的字元。

經過對StringBuilder預設構造方法的分析,系統預設初始化m_ChunkChars的長度為16(0x10),當新追加進來的字串長度與舊有字串長度之和大於該字元陣列容量時,新建立字元陣列的容量會增加到2n+1(假如當前字元陣列容量為2n)。

此外,StringBuilder內部還有一個同為StringBuilder型別的m_ChunkPrevious,它是內部的一個StringBuilder物件,前面提到當追加的字串長度和舊字串長度之合大於字元陣列m_ChunkChars的最大容量時,會根據當前的(this)StringBuilder建立一個新的StringBuilder物件,將m_ChunkPrevious指向新建立的StringBuilder物件。

下面是StringBuilder中實現擴容的核心程式碼:

可以看出,初始化m_ChunkPrevious在前,建立新的字元陣列m_ChunkChars在後,最後才是複製字元到陣列m_ChunkChars中(更新當前的m_ChunkChars)。歸根結底,StringBuilder是在內部以字元陣列m_ChunkChars為基礎維護一個連結串列m_ChunkPrevious,該連結串列如下圖所示:

在最終的ToString方法中,當前的StringBuilder物件會根據這個連結串列以及記錄的長度和偏移變數去生成最終的一個String物件例項,StringBuilder的內部實現中使用了一些指標操作,其內部原理有興趣的園友可以自己去通過反編譯工具檢視原始碼。

1.2 String和Byte[]物件之間如何相互轉換?

在實際開發中,經常會對資料進行處理,不可避免地會遇到字串和位元組陣列相互轉換的需求。字串和位元組陣列的轉換,事實上是代表了現實世界資訊和數字世界資訊之間的轉換,要了解其中的機制,需要先對位元、直接以及編碼這三個概念有所瞭解。

(1)位元:bit是一個位,計算機內物理儲存的最基本單元,一個bit就是一個二進位制位;

(2)位元組:byte由8個bit構成,其值可以由一個0~255的整數表示;

(3)編碼:編碼是數字資訊和現實資訊的轉換機制,一種編碼通常就定義了一種字符集和轉換的原則,常用的編碼方式包括UTF8、GB2312、Unicode等。

下圖直觀地展示了位元、位元組、編碼和字串的關係:

從上圖可以看出,位元組陣列和字串的轉換必然涉及到某種編碼方式,不同的編碼方式由不同的轉換結果。在C#中,可以使用System.Text.Encoding來管理常用的編碼。

下面的程式碼展示瞭如何在位元組陣列和字串之間進行轉換(分別使用UTF8、GB2312以及Unicode三種編碼方式):

上述程式碼的執行結果如下圖所示:

我們也可以從上圖中看出,不同的編碼方式產生的位元組陣列的長度各不相同。

1.3 BASE64編碼的作用以及C#中對其的支援

和傳統的編碼不同,BASE64編碼的設計致力於混淆那些8位位元組的資料流(解決網路傳輸中的明碼問題),在網路傳輸、郵件等系統中被廣泛應用。需要明確的是:BASE64不屬於加密機制,但它卻是把明碼變成了一種很難識別的形式。

BASE64的演算法如下:

BASE64把所有的位分開,並且重新組合成位元組,新的位元組只包含6位,最後在每個位元組前新增兩個0,組成了新的位元組陣列。例如:一個位元組陣列只包含三個位元組(每個位元組又有8位位元),對其進行BASE64編碼時會將其分配到4個新的位元組中(為什麼是4個呢?計算3*8/6=4),其中每個位元組只填充低6位,最後把高2位置為零。

下圖清晰地展示了上面所講到的BASE64的演算法示例:

在.NET中,BASE64編碼的應用也很多,例如在ASP.NET WebForm中,預設為我們生成了一個ViewState來保持狀態,如下圖所示:

(檢視大圖)

這裡的ViewState其實就是伺服器在返回給瀏覽器前進行了一次BASE64編碼,我們可以通過一些解碼工具進行反BASE64編碼檢視其中的奧祕:

那麼,問題來了?在.NET中開發中,怎樣來進行BASE64的編碼和解碼呢,.NET基類庫中提供了一個Convert類,其中有兩個靜態方法提供了BASE64的編碼和解碼,但要注意的是:Convert型別在轉換失敗時會直接丟擲異常,我們需要在開發中注意對潛在異常的處理(比如使用is或as來進行高效的型別轉換)。下面的程式碼展示了其用法:

上面程式碼的執行結果如下圖所示:

1.4 簡述SecureString安全字串的特點和用法

也許很多人都是第一次知道還有SecureString這樣一個型別,我也不例外。SecureString並不是一個常用的型別,但在一些擁有特殊需求的額場合,它就會有很大的作用。顧名思義,SecureString意為安全的字串,它被設計用來儲存一些機密的字串,完成傳統字串所不能做到的工作。

(1)傳統字串以明碼的形式被分配在記憶體中,一個簡單的記憶體讀寫軟體就可以輕易地捕獲這些字串,而在這某些機密系統中是不被允許的。也許我們會覺得對字串加密就可以解決類似問題,But,事實總是殘酷的,對字串加密時字串已經以明碼方式駐留在記憶體中很久了!對於該問題唯一的解決辦法就是在字串的獲得過程中直接進行加密,SecureString的設計初衷就是解決該類問題。

(2)為了保證安全性,SecureString是被分配在非託管記憶體上的(而普通String是被分配在託管記憶體中的),並且SecureString的物件從分配的一開始就以加密的形式存在,我們所有對於SecureString的操作(無論是增刪查改)都是逐字元進行的。

逐字元機制:在進行這些操作時,駐留在非託管記憶體中的字串就會被解密,然後進行具體操作,最後再進行加密。不可否認的是,在具體操作的過程中有小段時間字串是處於明碼狀態的,但逐字元的機制讓這段時間維持在非常短的區間內,以保證破解程式很難有機會讀取明碼的字串。

(3)為了保證資源釋放,SecureString實現了標準的Dispose模式(Finalize+Dispose雙管齊下,因為上面提到它是被分配到非託管記憶體中的),保證每個物件在作用域退出後都可以被釋放掉。

記憶體釋放方式:將其物件記憶體全部置為0,而不是僅僅告訴CLR這一塊記憶體可以分配,當然這樣做仍然是為了確保安全。熟悉C/C++的朋友可能就會很熟悉,這不就是 memset 函式乾的事情嘛!下面這段C程式碼便使用了memset函式將記憶體區域置為0:

看完了SecureString的原理,現在我們通過下面的程式碼來熟悉一下在.NET中的基本用法:

其執行顯示的結果很簡單:

這裡需要注意的是:為了顯示SecureString的內容,程式需要訪問非託管記憶體,因此會用到指標,而要在C#使用指標,則需要使用unsafe關鍵字(前提是你在專案屬性中勾選了允許不安全程式碼,對你沒看錯,指標在C#可以使用,但是被認為是不安全的!)。此外,程式中使用了Marshal.SecureStringToCoTaskMemUnicode方法來把安全字串解密到非託管記憶體中,最後就是就是我們不要忘記在使用非託管資源時需要確保及時被釋放。

1.5 簡述字串駐留池機制

字串具有不可變性,程式中對於同一個字串的大量修改或者多個引用賦值同一字串在理論上會產生大量的臨時字串物件,這會極大地降低系統的效能。對於前者,可以使用StringBuilder型別解決,而後者,.NET則提供了另一種不透明的機制來優化,這就是傳說中的字串駐留池機制。

使用了字串駐留池機制之後,當CLR啟動時,會在內部建立一個容器,該容器內部維持了一個類似於key-value對的資料結構,其中key是字串的內容,而value則是字串在託管堆上的引用(也可以理解為指標或地址)。當一個新的字串物件需要分配時,CLR首先監測內部容器中是否已經存在該字串物件,如果已經包含則直接返回已經存在的字串物件引用;如果不存在,則新分配一個字串物件,同時把其新增到內部容器中取。But,這裡有一個例外,就是當程式設計師用new關鍵字顯示地申明新分配一個字串物件時,該機制將不會起作用。

從上面的描述中,我們可以看到字串駐留池的本質是一個快取,內部維持了一個鍵為字串內容,值為該字串在堆中的引用地址的鍵值對資料結構。我們可以通過下面一段程式碼來加深對於字串駐留池的理解:

在上述程式碼中,由於字串駐留池機制的使用,變數a、b、c都指向了同一個字串例項物件,而d則使用了new關鍵字顯示申明,因此字串駐留池並沒有對其起作用,其執行結果如下圖所示:

字串駐留池的設計本意是為了改善程式的效能,因此在C#中預設是開啟了字串駐留池機制,But,.NET也為我們提供了字串駐留池的開關介面,如果程式集標記了一個System.Runtime.CompilerServices.CompilationRelaxationsAttribute特性,並且指定了一個System.Runtime.CompilerServices.CompilationRelaxations.NoStringInterning標誌,那麼CLR不會採用字串駐留池機制,其程式碼宣告如下所示,但是我新增後一直沒有嘗試成功:

 

二、常用集合和泛型

2.1 int[]是值型別還是引用型別?

在.NET中的陣列型別和C++中區別很大,.NET中無論是儲存值型別物件的陣列還是儲存引用型別的陣列,其本身都是引用型別,其記憶體也都是分配在堆上的。它們的共同特徵在於:所有的陣列型別都繼承自System.Array,而System.Array又實現了多個介面,並且直接繼承自System.Object。不同之處則在於儲存值型別物件的陣列所有的值都已經包含在陣列內,而儲存引用型別物件的陣列,其值則是一個引用,指向位於託管堆中的例項物件。

下圖直觀地展示了二者記憶體分配的差別(假設object[]中儲存都是DateTime型別的物件例項):

在.NET中CLR會檢測所有對陣列的訪問,任何檢視訪問陣列邊界以外的程式碼都會產生一個IndexOutOfRangeException異常。

2.2 陣列之間如何進行轉換?

陣列型別的轉換需要遵循以下兩個原則:

(1)包含值型別的陣列不能被隱式轉換成其他任何型別;

(2)兩個陣列型別能夠相互轉換的一個前提是兩者維數相同;

我們可以通過以下程式碼來看看陣列型別轉換的機制:

除了型別上的轉換,我們平時還可能會遇到內容轉換的需求。例如,在一系列的使用者介面操作之後,系統的後臺可能會得到一個DateTime的陣列,而現在的任務則是將它們儲存到資料庫中,而資料庫訪問層提供的介面只接受String[]引數,這時我們要做的就是把DateTime[]從內容上轉換為String[]物件。當然,慣常做法是遍歷整個源陣列,逐一地轉換每個物件並且將其放入一個目標陣列型別容器中,最後再生成目標陣列。But,這裡我們推薦使用Array.ConvertAll方法,它提供了一個簡便的轉換陣列間內容的介面,我們只需指定源陣列的型別、物件陣列的型別和具體的轉換演算法,該方法就能高效地完成轉換工作。

下面的程式碼清楚地展示了普通的陣列內容轉換方式和使用Array.ConvertAll的陣列內容轉換方式的區別:

從上述程式碼可以看出,二者實現了相同的功能,但是Array.ConvertAll不需要我們手動地遍歷陣列,也不需要生成一個臨時的容器物件,更突出的優勢是它可以接受一個動態的演算法作為具體的轉換邏輯。當然,明眼人一看就知道,它是以一個委託的形式作為引數傳入,這樣的機制保證了Array.ConvertAll具有較高的靈活性。

2.3 簡述泛型的基本原理

泛型的語法和概念類似於C++中的template(模板),它是.NET 2.0中推出的眾多特性中最為重要的一個,方便我們設計更加通用的型別,也避免了容器操作中的裝箱和拆箱操作。

假如我們要實現一個排序演算法,要求能夠針對各種型別進行排序。按照以前的做法,我們需要對int、double、float等型別都實現一次,但是我們發現除了資料型別,其他的處理邏輯完全一致。這時,我們便可以考慮使用泛型來進行實現:

Tips:Microsoft在產品文件中建議所有的泛型引數名稱都以T開頭,作為一箇中編碼的通用規範,建議大家都能遵守這樣的規範,類似的規範還有所有的介面都以I開頭。

泛型型別和普通型別有一定的區別,通常泛型型別被稱為開放式型別,.NET中規定開放式型別不能例項化,這樣也就確保了開放式型別的泛型引數在被指定前,不會被例項化成任何物件(事實上,.NET也沒有辦法確定到底要分配多少記憶體給開放式型別)。為開放式的型別提供泛型的例項導致了一個新的封閉型別的生成,但這並不代表新的封閉型別和開放型別有任何繼承關係,它們在類結構圖上是處於同一層次,並且兩者之間沒有任何關係。下圖展示了這一概念:

此外,在.NET中的System.Collections.Generic名稱空間下提供了諸如List<T>、Dictionary<T>、LinkedList<T>等泛型資料結構,並且在System.Array中定義了一些靜態的泛型方法,我們應該在編碼實踐時充分使用這些泛型容器,以提高我們的開發和系統的執行效率。

2.4 泛型的主要約束和次要約束是什麼?

當一個泛型引數沒有任何約束時,它可以進行的操作和運算是非常有限的,因為不能對實參進行任何型別上的保證,這時候就需要用到泛型約束。泛型的約束分為:主要約束和次要約束,它們都使實參必須滿足一定的規範,C#編譯器在編譯的過程中可以根據約束來檢查所有泛型型別的實參並確保其滿足約束條件。

(1)主要約束

一個泛型引數至多擁有一個主要約束,主要約束可以是一個引用型別、class或者struct。如果指定一個引用型別(class),那麼實參必須是該型別或者該型別的派生型別。相反,struct則規定了實參必須是一個值型別。下面的程式碼展示了泛型引數主要約束:

泛型引數有了主要約束後,也就能夠在型別中對其進行一定的操作了。

(2)次要約束

次要約束主要是指實參實現的介面的限定。對於一個泛型,可以有0到無限的次要約束,次要約束規定了實參必須實現所有的次要約束中規定的介面。次要約束與主要約束的語法基本一致,區別僅在於提供的不是一個引用型別而是一個或多個介面。例如我們為上面程式碼中的ClassT3增加一個次要約束:

 

三、流和序列化

3.1 流的概念以及.NET中有哪些常見的流?

流是一種針對位元組流的操作,它類似於記憶體與檔案之間的一個管道。在對一個檔案進行處理時,本質上需要經過藉助OS提供的API來進行開啟檔案,讀取檔案中的位元組流,再關閉檔案等操作,其中讀取檔案的過程就可以看作是位元組流的一個過程。

常見的流型別包括:檔案流、終端操作流以及網路Socket等,在.NET中,System.IO.Stream型別被設計為作為所有流型別的虛基類,所有的常見流型別都繼承自System.IO.Stream型別,當我們需要自定義一種流型別時,也應該直接或者間接地繼承自Stream型別。下圖展示了在.NET中常見的流型別以及它們的型別結構:

從上圖中可以發現,Stream型別繼承自MarshalByRefObject型別,這保證了流型別可以跨越應用程式域進行互動。所有常用的流型別都繼承自System.IO.Stream型別,這保證了流型別的同一性,並且遮蔽了底層的一些複雜操作,使用起來非常方便。

下面的程式碼中展示瞭如何在.NET中使用FileStream檔案流進行簡單的檔案讀寫操作:

上述程式碼的執行結果如下圖所示:

在實際開發中,我們經常會遇到需要傳遞一個比較大的檔案,或者事先無法得知檔案大小(Length屬性丟擲異常),因此也就不能建立一個尺寸正好合適的Byte[]陣列,此時只能分批讀取和寫入,每次只讀取部分位元組,直到檔案尾。例如我們需要複製G盤中一個大小為4.4MB的mp3檔案到C盤中去,假設我們對大小超過2MB的檔案都採用分批讀取寫入機制,可以通過如下程式碼實現:

上述程式碼中,設定了快取buffer大小為10K,即每次只讀取10K的內容長度到buffer中,通過迴圈的多次讀寫和寫入完成整個複製操作。

3.2 如何使用壓縮流?

由於網路頻寬的限制、硬碟記憶體空間的限制等原因,檔案和資料的壓縮是我們經常會遇到的一個需求。因此,.NET中提供了對於壓縮和解壓的支援:GZipStream型別和DeflateStream型別,它們位於System.IO.Compression名稱空間下,且都繼承於Stream型別(對檔案壓縮的本質其實是針對位元組的操作,也屬於一種流的操作),實現了基本一致的功能。

下面的程式碼展示了GZipStream的使用方法,DeflateStream和GZipStream的使用方法幾乎完全一致:

上述程式碼的執行結果如下圖所示:

需要注意的是:使用 GZipStream 類壓縮大於 4 GB 的檔案時將會引發異常。

通過GZipStream的構造方法可以看出,它是一個典型的Decorator裝飾者模式的應用,所謂裝飾者模式,就是動態地給一個物件新增一些額外的職責。對於增加新功能這個方面,裝飾者模式比新增一個之類更為靈活。就拿上面程式碼中的GZipStream來說,它擴充套件的是MemoryStream,為Write方法增加了壓縮的功能,從而實現了壓縮的應用。

擴充套件:許多資料表明.NET提供的GZipStream和DeflateStream型別的壓縮演算法並不出色,也不能調整壓縮率,有些第三方的元件例如SharpZipLib實現了更高效的壓縮和解壓演算法,我們可以在nuget中為專案新增該元件。

3.3 Serializable特性有什麼作用?

通過上面的流型別可以方便地操作各種位元組流,但是如何把現有的例項物件轉換為方便傳輸的位元組流,就需要使用序列化技術。物件例項的序列化,是指將例項物件轉換為可方便儲存、傳輸和互動的流。在.NET中,通過Serializable特性提供了序列化物件例項的機制,當一個型別被申明為Serializable後,它就能被諸如BinaryFormatter等實現了IFormatter介面的型別進行序列化和反序列化。

但是,在實際開發中我們會遇到對於一些特殊的不希望被序列化的成員,這時我們可以為某些成員新增NonSerialized特性。例如,有如下程式碼所示的一個Person類,其中number代表學號,name代表姓名,我們不希望name被序列化,於是可以為name新增NonSerialized特性:

上述程式碼的執行結果如下圖所示:

注意:當一個基類使用了Serializable特性後,並不意味著其所有子類都能被序列化。事實上,我們必須為每個子類都新增Serializable特性才能保證其能被正確地序列化。

3.4 .NET提供了哪幾種可進行序列化操作的型別?

我們已經理解了如何把一個型別宣告為可序列化的型別,但是萬里長征只走了第一步,具體完成序列化和反序列化的操作還需要一個執行這些操作的型別。為了序列化具體例項到某種專用的格式,.NET中提供了三種物件序列格式化型別:BinaryFormatter、SoapFormatter和XmlSerializer。

(1)BinaryFormatter

顧名思義,BinaryFormatter可用於將可序列化的物件序列化成二進位制的位元組流,在前面Serializable特性的程式碼示例中已經展示過,這裡不再重複展示。

(2)SoapFormatter

SoapFormatter致力於將可序列化的型別序列化成符合SOAP規範的XML文件以供使用。在.NET中,要使用SoapFormatter需要先新增對於SoapFormatter的引用:

Tips:SOAP是一種位於應用層的網路協議,它基於XML,並且是Web Service的基本協議。

(3)XmlSerializer

XmlSerializer並不僅僅針對那些標記了Serializable特性的型別,更為需要注意的是,Serializable和NonSerialized特性在XmlSerializer型別物件的操作中完全不起作用,取而代之的是XmlIgnore屬性。XmlSerializer可以對沒有標記Serializable特性的型別物件進行序列化,但是它仍然有一定的限制:

① 使用XmlSerializer序列化的物件必須顯示地擁有一個無引數的公共構造方法;

因此,我們需要修改前面程式碼示例中的Person類,新增一個無引數的公共構造方法:

② XmlSerializer只能序列化公共成員變數;

因此,Person類中的私有成員_number便不能被XmlSerializer進行序列化:

(4)綜合演示SoapFormatter和XmlSerializer的使用方法:

①重新改寫Person類

②新增SoapFormatter和XmlSerializer的序列化和反序列化方法

③改寫Main方法進行測試

示例執行結果如下圖所示:

3.5 如何自定義序列化和反序列化的過程?

對於某些型別,序列化和反序列化往往有一些特殊的操作或邏輯檢查需求,這時就需要我們能夠主動地控制序列化和反序列化的過程。.NET中提供的Serializable特性幫助我們非常快捷地申明瞭一個可序列化的型別(因此也就缺乏了靈活性),但很多時候由於業務邏輯的要求,我們需要主動地控制序列化和反序列化的過程。因此,.NET提供了ISerializable介面來滿足自定義序列化需求。

下面的程式碼展示了自定義序列化和反序列化的型別模板:

如上程式碼所示,GetObjectData和特殊構造方法都接收兩個引數:SerializationInfo 型別引數的作用類似於一個雜湊表,通過key/value對來儲存整個物件的內容,而StreamingContext 型別引數則包含了流的當前狀態,我們可以根據此引數來判斷是否需要序列化和反序列化型別獨享。

如果基類實現了ISerializable介面,則派生類需要針對自己的成員實現反序列化構造方法,並且重寫基類中的GetObjectData方法。

下面通過一個具體的程式碼示例,來了解如何在.NET程式中自定義序列化和反序列化的過程:

①首先我們需要一個需要被序列化和反序列化的型別,該型別有可能被其他型別繼承

②隨後編寫一個繼承自MyObject的子類,並新增一個私有的成員變數。需要注意的是:子類必須負責序列化和反序列化自己新增的成員變數。

③最後編寫Main方法,測試自定義的序列化和反序列化

上述程式碼的執行結果如下圖所示:

從結果圖中可以看出,由於實現了自定義的序列化和反序列化,從而原先使用Serializable特性的預設序列化和反序列化演算法沒有起作用,MyObject型別的所有成員經過序列化和反序列化之後均被完整地還原了,包括申明瞭NonSerialized特性的成員。

 

參考資料

(1)朱毅,《進入IT企業必讀的200個.NET面試題》

(2)張子陽,《.NET之美:.NET關鍵技術深入解析》

(3)王濤,《你必須知道的.NET》

(4)solan300,《C#基礎知識梳理之StringBuilder》

(5)周旭龍,《ASP.NET WebForm溫故知新

(6)陸敏技,《C#中機密文字的儲存方案

相關文章