1. 程式人生 > >原始碼上看 .NET 中 StringBuilder 拼接字串的實現

原始碼上看 .NET 中 StringBuilder 拼接字串的實現

前幾天寫了一篇`StringBuilder`與`TextWriter`二者之間區別的文章([連結](https://www.cnblogs.com/iskcal/p/difference_between_stringbuilder_and_textwriter.html))。當時提了一句沒有找到相關原始碼,於是隨後有很多熱心人士給出了相關的原始碼連結([連結](https://github.com/dotnet/runtime/blob/de6380e65a8d201900e7983a30a5c870d229ca3d/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs)),感謝大家。這幾天抽了點時間查看了下`StringBuilder`是如何動態構造字串的,發現在`.NET Core`中字串的構建似乎和我原先猜想的並不完全一樣,故此寫了這篇文章,如有錯誤,歡迎指出。 ## `StringBuilder`欄位和屬性 ### 字元陣列 明確一點的是,`StringBuilder`的內部確實使用字元陣列來管理字串資訊的,這一點上和我當時的猜測是差不多的。相較於字串在大多數情況下的不變性而言,字元陣列有其優點,即修改字元陣列內部的資料不會全部重新建立字元陣列(字串的不變性)。下面是`StringBuilder`的部分原始碼,可以看到,內部採用`m_ChunkChars`欄位儲存字元陣列資訊。 ```csharp public sealed class StringBuilder { internal char[] m_ChunkChars; ... } ``` 然而,採用字元陣列並不是沒有缺點,陣列最大的缺點就是在在使用前就需要指定它的空間大小,這種固定大小的陣列空間不可能有能力處理多次的字串拼接,總有某次,陣列中的空餘部分塞不下所要拼接的字串。如果某次拼接的字串超過陣列的空閒空間時,一種易想到做到的方法就是開闢一個更大的空間,並將原先的資料複製過去。這種方法能夠保證陣列始終是連續的,然而,它的問題在於,複製是一個非常耗時的操作,如非必要,儘可能地降低複製的頻率。在`.NET Core`中,`StringBuilder`採用了一個新方法避免了複製操作。 ### 單鏈表 為了能夠有效地提高效能,`StringBuilder`採用連結串列的形式規避了兩個字元陣列之間的複製操作。在其原始碼中,可以發現每個`StringBuilder`內部保留對了另一個`StringBuilder`的引用。 ```csharp public sealed class StringBuilder { internal StringBuilder? m_ChunkPrevious; ... } ``` 在`StringBuilder`中,每個物件都維護了一個`m_ChunkPrevious`引用,按欄位命名的意思來說,就是每個類物件都維護指向前一個類物件的引用。這一點和我們常見的單鏈表結構有點一點不太一樣,常見的單鏈表結構中每個節點維護的是指向下一個節點的引用,這和`StringBuilder`所使用的模式剛好相反,挺奇怪的。整理下,這部分有兩個問題: 1. 為什麼說採用單鏈表能避免複製操作? 2. 為什麼採用逆向連結串列,即每個節點保留指向前一個節點的引用? 對於第一個問題,試想下,如果又有新的字串需要拼接且其長度超過字元陣列空閒的容量時,可以考慮新開闢一個新空間專門儲存**超額**部分的資料。這樣,先前部分的資料就不需要進行復制了,但這又有一個新問題,整個資料被儲存在兩個不相連的部分,怎麼關聯他們,採用連結串列的形式將其關聯是一個可行的措施。以上就是`StringBuilder`拼接字串最為核心的部分了。 那麼,對於第二個問題,採用逆向連結串列對的好處是什麼?這裡我給出的原因屬於我個人的主觀意見,不一定對。從我平時使用上以及一些開源類庫中來看,對`StringBuilder`使用最廣泛的功能就是拼接字串了,即向尾部新增新資料。在這個基礎上,如果採用正向連結串列(每個節點保留下一個節點的引用),那麼多次拼接字串在陣列容量不夠的情況下,勢必需要每次迴圈找到最後一個節點並新增新節點,時間複雜度為O(n)。而採用逆向連結串列,因為使用者所持有的就是最後一個節點,只需要在當前節點上做些處理就可以新增新節點,時間複雜度為O(1)。因此,`StringBuilder`內的字元陣列可以說是字串的一個部分,也被稱為Chunk。 > 舉個例子,如果型別為`Stringbuilder`變數`sb`內已經儲存了`HELLO`字串,再新增`WORLD`時,如果字元陣列滿了,再新增就會構造一個新`StringBuilder`節點。注意的是呼叫類方法不會改變當前變數`sb`指向的物件,因此,它會移動內部的字元陣列引用,並將當前變數的字元陣列引用指向`WORLD`。下圖中的左右兩圖是新增前後的說明圖,其中黃色`StringBuilder`是同一個物件。 ![`StringBuilder`連結串列](https://img2020.cnblogs.com/blog/1077681/202009/1077681-20200921001848991-908186406.png) 當然,採用連結串列並非沒有代價。因為連結串列沒有隨機讀取的功能。因此,如果向指定位置新增新資料,這反而比只使用一個字元陣列來得慢。但是,如果前面的假設沒錯的話,也就是最頻繁使用的是尾部拼接的話,那麼使用連結串列的形式是被允許的。根據使用場景頻率的不同,提供不同的實現邏輯。 ### 各種各樣的長度 剩下來的部分,就是描述各種各樣的長度及其他資料。主要如下: ```csharp public sealed class StringBuilder { internal int m_ChunkLength; internal int m_ChunkOffset; internal int m_MaxCapacity; internal const int DefaultCapacity = 16; internal const int MaxChunkSize = 8000; public int Length { get => m_ChunkOffset + m_ChunkLength; } ... } ``` - `m_ChunkLength`描述當前Chunk儲存資訊的長度。也就是儲存了字元資料的長度,不一定等於字元陣列的長度。 - `m_ChunkOffset`描述當前Chunk在整體字串中的起始位置,方便定位。 - `m_MaxCapacity`描述構建字串的最大長度,通常設定為`int`最大值。 - `DefaultCapacity`描述預設設定的空間大小,這裡設定的是16。 - `MaxChunkSize`描述Chunk的最大長度,也就是Chunk的容量。 - `Length`屬性描述的是內部儲存整體字串的長度。 ## 建構函式 上述講述的是`StringBuilder`的各個欄位和屬性的意義,這裡就深入看下具體函式的實現。首先是建構函式,這裡僅列舉本文所涉及到的幾個建構函式。 ```csharp public StringBuilder() { m_MaxCapacity = int.MaxValue; m_ChunkChars = new char[DefaultCapacity]; } public StringBuilder(string? value, int startIndex, int length, int capacity) { ... m_MaxCapacity = int.MaxValue; if (capacity == 0) { capacity = DefaultCapacity; } capacity = Math.Max(capacity, length); m_ChunkChars = GC.AllocateUninitializedArray(capacity); m_ChunkLength = length; unsafe { fixed (char* sourcePtr = value) { ThreadSafeCopy(sourcePtr + startIndex, m_ChunkChars, 0, length); } } } private StringBuilder(StringBuilder from) { m_ChunkLength = from.m_ChunkLength; m_ChunkOffset = from.m_ChunkOffset; m_ChunkChars = from.m_ChunkChars; m_ChunkPrevious = from.m_ChunkPrevious; m_MaxCapacity = from.m_MaxCapacity; ... } ``` 這裡選出了三個和本文關係較為緊密的建構函式,一個個分析。 1. 首先是預設建構函式,該函式沒有任何的輸入引數。程式碼中可以發現,其分配的長度就是16。也就是說不對其做任何指定的話,預設初始長度為16個Char型資料,即32位元組。 2. 第二個建構函式是當建構函式傳入為字串時所呼叫的,這裡我省略了在開始最前面的防禦性程式碼。這裡的構造過程也很簡單,比較傳入字串的大小和預設容量`DefaultCapacity`的大小,並開闢二者之間最大值的長度,最後將字串複製到陣列中。可以發現的是,這種情況下,初始字元陣列的長度並不總是16,畢竟如果字串長度超過16,肯定按照更長的來。 3. 第三個建構函式專門用來構造`StringBuilder`的節點的,或者說是`StringBuilder`的複製,即原型模式。它主要用在容量不夠構造新的節點,本質上就是將內部資料全部賦值過去。 > 從前兩個建構函式可以看出,如果第一次待拼接的字串長度超過16,那麼直接將該字串以建構函式的引數傳入比構建預設`StringBuilder`物件再使用`Append`方法更加高效,畢竟預設建構函式只開闢了16個char型空間。 ## `Append`方法 這裡主要看`StringBuilder Append(char value, int repeatCount)`這個方法(位於第710行)。該方法主要是向尾部新增char型字元`value`,一共新增`repeatCount`個。 ```csharp public StringBuilder Append(char value, int repeatCount) { ... int index = m_ChunkLength; while (repeatCount > 0) { if (index < m_ChunkChars.Length) { m_ChunkChars[index++] = value; --repeatCount; } else { m_ChunkLength = index; ExpandByABlock(repeatCount); Debug.Assert(m_ChunkLength == 0); index = 0; } } m_ChunkLength = index; AssertInvariants(); return this; } ``` 這裡僅列舉出部分程式碼,起始的防禦性程式碼以及驗證程式碼略過。看下其執行邏輯: 1. 依次迴圈當前字元`repeatCount`次,對每一次執行以下邏輯。(while大迴圈) 2. 如果當前字元陣列還有空位時,則直接向內部進行新增新資料。(if語句命中部分) 3. 如果當前字元陣列已經被塞滿了,首先更新`m_ChunkLength`值,因為陣列被塞滿了,因此需要下一個陣列來繼續放資料,當前的Chunk長度也就是整個字元陣列的長度,需要更新。其次,呼叫了`ExpandByABlock(repeatCount)`函式,輸入引數為更新後的`repeatCount`資料,其做的就是構建新的節點,並將其掛載到連結串列上。 4. 更新`m_ChunkLength`值,記錄當前Chunk的長度,最後將本身返回。 接下來就是`ExpandByABlock`方法的實現。 ```csharp private void ExpandByABlock(int minBlockCharCount) { ... int newBlockLength = Math.Max(minBlockCharCount, Math.Min(Length, MaxChunkSize)); ... // Allocate the array before updating any state to avoid leaving inconsistent state behind in case of out of memory exception char[] chunkChars = GC.AllocateUninitializedArray(newBlockLength); // Move all of the data from this chunk to a new one, via a few O(1) pointer adjustments. // Then, have this chunk point to the new one as its predecessor. m_ChunkPrevious = new StringBuilder(this); m_ChunkOffset += m_ChunkLength; m_ChunkLength = 0; m_ChunkChars = chunkChars; AssertInvariants(); } ``` 和上面一樣,僅列舉出核心功能程式碼。 1. 設定新空間的大小,該大小取決於三個值,從當前字串長度和Chunk最大容量取較小值,然後從較小值和輸入引數長度中取最大值作為新Chunk的大小。值得注意的是,這裡當前字串長度通常是Chunk已經被塞滿的情況下,可以理解成所有Chunk的長度之和。 2. 開闢新空間。 3. 通過上述最後一個建構函式,構造向前的節點。當前節點仍然為最後一個節點,更新其他值,即偏移量應該是原先偏移量加上一個Chunk的長度。清空當前Chunk的長度以及將新開闢空間給Chunk引用。 對於`Append(string? value)`這個函式的實現功能和上述說明是差不多的,基本都是新資料先往當前的字元陣列內塞,如果塞滿了就新增新節點並重新整理當前字元陣列資料再塞。詳細的功能可以從L802開始看。這裡不做過多說明。 # 驗證 當然,以上只是閱讀程式碼的流程,具體是否正確還可以做點測試來驗證。這裡我做了一個小測試demo。 ```csharp var sb = new StringBuilder(); sb.Append('1', 10); sb.Append('2', 6); sb.Append('3', 24); sb.Append('4', 15); sb.Append("hello world"); sb.Append("nice to meet you"); Console.WriteLine($"結果:{sb.ToString()}"); var p = sb; char[] data; Type type = sb.GetType(); int count = 0; while (p != null) { count++; data = (char[])type.GetField("m_ChunkChars", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(p); Console.WriteLine($"倒數第{count}個StringBuilder內容:{new string(data)}"); p = (StringBuilder)type.GetField("m_ChunkPrevious", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(p); } ``` 這裡主要做的是利用`Append`方法新增不同的資料並將最終結果輸出。考慮到內部的細節並沒有對外公開,只能通過反射的操作來獲取,通過遍歷每一個`StringBuilder`的節點,反射獲取內部的字元陣列並將其輸出。最終的結果如下。 ![測試結果](https://img2020.cnblogs.com/blog/1077681/202009/1077681-20200919224921847-2071294157.jpg) 這裡分析下具體的過程: 1. 第一句`sb = new StringBuilder()`。從之前的建構函式程式碼內可以得知,無參建構函式會生成一個16長度的字元陣列。 2. 第二句`sb.Append('1', 10)`。這句話意思是向`sb`內新增10個`1`字元,因為新增的長度小於給定的預設值16,因此直接將其新增即可。 3. 第三句`sb.Append('2', 6)`。在經過上面新增操作後,當前字元陣列還剩6個空間,剛好夠塞,因此直接將6個`2`字元直接塞進去。 4. 第四句`sb.Append('3', 24)`。在新增字元`3`之前,`StringBuilder`內部的字元陣列就已經沒有空間了。為此,需要構造新的`StringBuilder`物件,並將當前物件內的資料傳過去。對於當前物件,需要建立新的字元陣列,按照之前給出的規則,當前Chunk之和(16)和Chunk長度(8000)取最小值(16),最小值(16)和輸入字串長度(24)取最大值(24)。因此,直接建立24個字元空間並存下來。此時,`sb`物件有一個前置節點。 5. 第五句`sb.Append('4', 15)`。上一句程式碼只建立了長度為24的字元陣列,因此,新資料依然無法再次塞入。此時,依舊需要建立新的`StringBuilder`節點,按照同樣的規則,取當前所有Chunk之和(16+24=40)。因此,新字元陣列長度為40,內部存了15個字元資料`4`。`sb`物件有兩個前置節點。 6. 第六句`sb.Append("hello world")`。這個字串長度為11,當前字元陣列能完全放下,則直接放下。此時字元陣列還空餘14個空間。 7. 第七句`sb.Append("nice to meet you")`。這個字串長度為16,可以發現超過了剩餘空間,首先先填充14個字元。之後多出的2個,則按照之前的規則再構造新的節點,新節點的長度為所有Chunk之和(16+24+40=80),即有80個儲存空間。當前Chunk只儲存最後兩個字元`ou`。`sb`物件有3個前置節點。符合最終的輸出結果。 ## 總結 總的來說,採用定長的字元陣列來儲存不定長的字串,不可能完全避免所新增的資料超出剩餘空間這樣的情況,重新開闢新空間並複製原始資料過於耗時。`StringBuilder`採用連結串列的形式取消了資料的複製操作,提高了字串連線的效率。對於`StringBuilder`來說,大部分的操作都在尾部新增,採用逆向連結串列是一個不錯的形式。當然`StringBuilder`這個類本身有很多複雜的實現,本篇只是介紹了`Append`方法是如何進行字串拼