.NET基礎知識快速通關(5)

語言: CN / TW / HK

【.NET 總結 /Edison Zhou

此係列文章為我在2015年釋出於部落格園的 .NET基礎拾遺系列 ,它十分適合初中級.NET開發工程師在面試前進行一個系統的複習,因此我將其搬到公眾號分享與你。

本文為第五篇,我們會對.NET的字串相關考點進行基礎複習,全文會以Q/A的形式展現,即以面試題的形式來描述。

1 StringBuilder有何作用?

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

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

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

public class Program
{
private const String item = "一個專案";
private const String split = ";";


static void Main(string[] args)
{
int number = 10000;
// 使用StringBuilder
CodeTimer.Time("使用StringBuilder: ", 1, () =>
{
UseStringBuilder(number);
});
// 不使用StringBuilder
CodeTimer.Time("使用不使用StringBuilder: : ", 1, () =>
{
NotUseStringBuilder(number);
});


Console.ReadKey();
}


static String UseStringBuilder(int number)
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
for (int i = 0; i < number; i++)
{
sb.Append(item);
sb.Append(split);
}
sb.Remove(sb.Length - 1, 1);
return sb.ToString();
}


static String NotUseStringBuilder(int number)
{
String result = "";
for (int i = 0; i < number; i++)
{
result += item;
result += split;
}
return result;
}
}

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

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

於是,我們拿起我們的錘子(Reflector)撕碎StringBuilder的外套,看看裡面到底裝了什麼?

我們發現,在StringBuilder中定義了一個字元陣列m_ChunkChars,它儲存StringBuilder所管理著的字串中的字元。

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

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

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

private void ExpandByABlock(int minBlockCharCount)
{
......
int num = Math.Max(minBlockCharCount, Math.Min(this.Length, 0x1f40));
this.m_ChunkPrevious = new StringBuilder(this);
this.m_ChunkOffset += this.m_ChunkLength;
this.m_ChunkLength = 0;
......
this.m_ChunkChars = new char[num];
}

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

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

2 String和Byte[]物件之間如何轉換?

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

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

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

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

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

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

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

public class Program
{
public static void Main(string[] args)
{
string s = "我是字串,I am a string!";
// 位元組陣列 -> 字串
Byte[] utf8 = StringToByte(s, Encoding.UTF8);
Byte[] gb2312 = StringToByte(s, Encoding.GetEncoding("GB2312"));
Byte[] unicode = StringToByte(s, Encoding.Unicode);


Console.WriteLine(utf8.Length);
Console.WriteLine(gb2312.Length);
Console.WriteLine(unicode.Length);
// 字串 -> 字元陣列
Console.WriteLine(ByteToString(utf8, Encoding.UTF8));
Console.WriteLine(ByteToString(gb2312, Encoding.GetEncoding("GB2312")));
Console.WriteLine(ByteToString(unicode, Encoding.Unicode));


Console.ReadKey();
}


// 字串 -> 位元組陣列
static Byte[] StringToByte(string str, Encoding encoding)
{
if (string.IsNullOrEmpty(str))
{
return null;
}
return encoding.GetBytes(str);
}


// 位元組陣列 -> 字串
static string ByteToString(Byte[] bytes, Encoding encoding)
{
if (bytes == null || bytes.Length <= 0)
{
return string.Empty;
}


return encoding.GetString(bytes);
}
}

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

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

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來進行高效的型別轉換)。下面的程式碼展示了其用法:

public class Program
{
public static void Main(string[] args)
{
string test = "abcde ";
// 生成UTF8位元組陣列
byte[] bytes = Encoding.UTF8.GetBytes(test);
// 轉換成Base64字串
string base64 = BytesToBase64(bytes);
Console.WriteLine(base64);
// 轉換回UTF8位元組陣列
bytes = Base64ToBytes(base64);
Console.WriteLine(Encoding.UTF8.GetString(bytes));


Console.ReadKey();
}


// Bytes to Base64
static string BytesToBase64(byte[] bytes)
{
try
{
return Convert.ToBase64String(bytes);
}
catch
{
return null;
}
}


// Base64 to Bytes
static Byte[] Base64ToBytes(string base64)
{
try
{
return Convert.FromBase64String(base64);
}
catch
{
return null;
}
}
}

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

4 瞭解SecureString型別嗎?

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

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

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

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

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

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

// 下面申請的20個位元組的記憶體有可能被別人用過
char chs[20];
// memset記憶體初始化:memset(void *,要填充的資料,要填充的位元組個數)
memset(chs,0,sizeof(chs));

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

using System;
using System.Runtime.InteropServices;
using System.Security;


namespace UseSecureString
{
class Program
{
static void Main(string[] args)
{
// 使用using語句保證Dispose方法被及時呼叫
using (SecureString ss = new SecureString())
{
// 只能逐字元地操作SecureString物件
ss.AppendChar('e');
ss.AppendChar('i');
ss.AppendChar('s');
ss.AppendChar('o');
ss.AppendChar('n');
ss.InsertAt(1, 'd');
// 列印SecureStrign物件
PrintSecureString(ss);
}


Console.ReadKey();
}


// 列印SecureString物件
public unsafe static void PrintSecureString(SecureString ss)
{
char* buffer = null;


try
{
// 只能逐字元地訪問SecureString物件
buffer = (char*)Marshal.SecureStringToCoTaskMemUnicode(ss);
for (int i = 0; *(buffer + i) != '\0'; i++)
{
Console.Write(*(buffer + i));
}
}
finally
{
// 釋放記憶體物件
if (buffer != null)
{
Marshal.ZeroFreeCoTaskMemUnicode((System.IntPtr)buffer);
}
}
}
}
}

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

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

5 瞭解字串駐留池嗎?

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

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

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

public class Program
{
public static void Main(string[] args)
{
// 01.兩個字串物件,理論上引用應該不相等
// 但是由於字串池機制,二者指向了同一物件
string a = "abcde";
string b = "abcde";
Console.WriteLine(object.ReferenceEquals(a, b));
// 02.由於編譯器的優化,所以下面這個c仍然指向了同一引用地址
string c = "a" + "bc" + "de";
Console.WriteLine(object.ReferenceEquals(a, c));
// 03.顯示地使用new來分配記憶體,這時候字串池不起作用
char[] arr = { 'a', 'b', 'c', 'd', 'e' };
string d = new string(arr);
Console.WriteLine(object.ReferenceEquals(a, d));


Console.ReadKey();
}
}

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

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

[assembly: System.Runtime.CompilerServices.CompilationRelaxations(System.Runtime.CompilerServices.CompilationRelaxations.NoStringInterning)]

End 總結

本文總結複習了.NET的字串處理相關的重要知識點,下一篇會總結.NET中集合與泛型相關的重要知識點,歡迎繼續關注!

參考資料(全是經典)

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

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

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

:point_down:掃碼關注EdisonTalk