淺談C#字串構建利器StringBuilder

yi念之間發表於2022-03-01

前言

    在日常的開發中StringBuilder大家肯定都有用過,甚至用的很多。畢竟大家都知道一個不成文的規範,當需要高頻的大量的構建字串的時候StringBuilder的效能是要高於直接對字串進行拼接的,因為直接使用++=都會產生一個新的String例項,因為String物件是不可變的物件,這也就意味著每次對字串內容進行操作的時候都會產生一個新的字串例項,這對大量的進行字串拼接的場景是非常不友好的。因此StringBuilder孕育而出。這裡需要注意的是,這並不意味著可以用StringBuilder來代替所有字串拼接的的場景,這裡我們強調一下是頻繁的對同一個字串物件進行拼接的操作。今天我們就來看一下c#中StringBuilder的巧妙實現方式,體會一下底層類庫解決問題的方式。

需要注意的是,這裡的不可變指的是字串物件本身的內容是不可改變的,但是字串變數的引用是可以改變的。

簡單示例

接下來我們們就來簡單的示例一下操作,其實核心操作主要是Append方法ToString方法,原始碼的的角度上來說還有StringBuilder的建構函式。首先是大家最常用的方式,直接各種Append然後最後得到結果。

StringBuilder builder = new StringBuilder();
builder.Append("我和我的祖國");
builder.Append(',');
builder.Append("一刻也不能分割");
builder.Append('。');
builder.Append("無論我走到哪裡,都留下一首讚歌。");
builder.Append("我歌唱每一座高山,我歌唱每一條河。");
builder.Append("裊裊炊煙,小小村落,路上一道轍。");
builder.Append("我永遠緊依著你的心窩,你用你那母親的脈搏,和我訴說。");
string result = builder.ToString();
Console.WriteLine(result);

StringBuilder也是支援通過建構函式初始化一些資料的,有沒有在建構函式傳遞初始化資料,也就意味著不同的初始化邏輯。比如以下操作

StringBuilder builder = new StringBuilder("我和我的祖國");
//或者是指定StringBuilder的容量,這樣的話StringBuilder初始可承載字串的長度是16
builder = new StringBuilder(16);

因為StringBuilder是基礎類庫,因此看著很簡單,用起來也很簡單,而且大家也都經常使用這些操作。

原始碼探究

上面我們們簡單的演示了StringBuilder的使用方式,一般的類似的StringBuilder或者是List這種雖然我沒使用的過程中可以不關注容器本身的長度一直去新增元素,實際上這些容器的本身內部實現邏輯都包含了一些擴容相關的邏輯。上面我們們提到了一下StringBuilder的核心主要是三個操作,也就是通過這三個功能可以呈現出StringBuilder的工作方式和原理。

  • 一個是建構函式,因為建構函式包含了初始化的一些邏輯。
  • 其次是Append方法,這是StringBuilder進行字串拼接的核心操作。
  • 最後是將StringBuilder轉換成字串的操作ToString方法,這是我們得到拼接字串的操作。

接下來我們們就從這三個相關的方法入手來看一下StringBuilder的核心實現,這裡我參考的.net版本為v6.0.2

構造入手

我們上面提到了StringBuilder的建構函式代表了初始化邏輯,大概來看就是預設的建構函式,即預設初始化邏輯和自定義一部分建構函式的邏輯,主要是的邏輯是決定了StringBuilder容器可容納字串的長度。

無參構造

首先來看一下預設的無參建構函式的實現[點選檢視原始碼?]

//可承載字元的最大容量,即可以拼接的字串的長度
internal int m_MaxCapacity;
//承載【拼接字串的char陣列
internal char[] m_ChunkChars;
//預設的容量,即預設初始化m_ChunkChars的長度,也就是首次擴容觸發的長度
internal const int DefaultCapacity = 16;
public StringBuilder()
{
    m_MaxCapacity = int.MaxValue;
    m_ChunkChars = new char[DefaultCapacity];
}

通過預設的無參建構函式,我們可以瞭解到兩點資訊

  • 首先是StringBuilder核心儲存字串的容器是char[]字元陣列。
  • 預設容器的char[]字元陣列宣告的長度是16,即如果首次StringBuilder容納的字元個數超過16則觸發擴容機制。
帶引數的構造

StringBuilder的有引數的建構函式有好幾個,如下所示

//宣告初始化容量,即首次擴容觸發的長度條件
public StringBuilder(int capacity)
//宣告初始化容量,和最大容量即可以動態構建字串的總長度
public StringBuilder(int capacity, int maxCapacity)
//用給定字串初始化
public StringBuilder(string? value)
//用給定字串初始化,並宣告容量
public StringBuilder(string? value, int capacity)
//用一個字串擷取指定長度初始化,並宣告最大容量
public StringBuilder(string? value, int startIndex, int length, int capacity)

雖然建構函式有很多,但是大部分都是在呼叫呼叫自己的過載方法,核心的有引數的建構函式其實就兩個,我們們分別來看一下,首先是指定容量的初始化建構函式[點選檢視原始碼?]

//可承載字元的最大容量,即可以拼接的字串的長度
internal int m_MaxCapacity;
//承載【拼接字串的char陣列
internal char[] m_ChunkChars;
//預設的容量,即預設初始化m_ChunkChars的長度,也就是首次擴容觸發的長度
internal const int DefaultCapacity = 16;
public StringBuilder(int capacity, int maxCapacity)
{
    //指定容量不能大於最大容量
    if (capacity > maxCapacity)
    {
        throw new ArgumentOutOfRangeException(nameof(capacity), SR.ArgumentOutOfRange_Capacity);
    }
    //最大容量不能小於1
    if (maxCapacity < 1)
    {
        throw new ArgumentOutOfRangeException(nameof(maxCapacity), SR.ArgumentOutOfRange_SmallMaxCapacity);
    }
    //初始化容量不能小於0
    if (capacity < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(capacity), SR.Format(SR.ArgumentOutOfRange_MustBePositive, nameof(capacity)));
    }
    //如果指定容量等於0,則使用預設的容量
    if (capacity == 0)
    {
        capacity = Math.Min(DefaultCapacity, maxCapacity);
    }
    //最大容量賦值
    m_MaxCapacity = maxCapacity;
    //分配指定容量的陣列
    m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);
}

主要就是對最大容量和初始化容量進行判斷和賦值,如果制定了初始容量和最大容量則以傳遞進來的為主。接下來再看一下根據指定字串來初始化StringBuilder的主要操作[點選檢視原始碼?]

//可承載字元的最大容量,即可以拼接的字串的長度
internal int m_MaxCapacity;
//承載【拼接字串的char陣列
internal char[] m_ChunkChars;
//預設的容量,即預設初始化m_ChunkChars的長度,也就是首次擴容觸發的長度
internal const int DefaultCapacity = 16;
//當前m_ChunkChars字元陣列中已經使用的長度
internal int m_ChunkLength;
public StringBuilder(string? value, int startIndex, int length, int capacity)
{
    if (capacity < 0)
    {
        throw new ArgumentOutOfRangeException();
    }
    if (length < 0)
    {
        throw new ArgumentOutOfRangeException();
    }
    if (startIndex < 0)
    {
        throw new ArgumentOutOfRangeException();
    }
    //初始化的字串可以為null,如果為null則只用空字串即""
    if (value == null)
    {
        value = string.Empty;
    }
    //基礎長度判斷,這個邏輯其實已經包含了針對字串擷取的起始位置和接要擷取的長度進行判斷了
    if (startIndex > value.Length - length)
    {
        throw new ArgumentOutOfRangeException();
    }
    //最大容量是int的最大值,即2^31-1
    m_MaxCapacity = int.MaxValue;
    if (capacity == 0)
    {
        capacity = DefaultCapacity;
    }
    //雖然傳遞了預設容量,但是這裡依然做了判斷,在傳遞的預設容量和需要儲存的字串容量總取最大值
    capacity = Math.Max(capacity, length);
    //分配指定容量的陣列
    m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);
    //這裡記錄了m_ChunkChars固定長度的快中已經被使用的長度
    m_ChunkLength = length;
    //把傳遞的字串指定位置指定長度(即擷取操作)copy到m_ChunkChars中
    value.AsSpan(startIndex, length).CopyTo(m_ChunkChars);
}

這個初始化操作主要是擷取給定字串的指定長度,存放到ChunkChars用於初始化StringBuilder,其中初始化的容量取決於可以擷取的長度是否大於指定容量,實質是以能夠存放擷取長度的字串為主。

構造小結

通過StringBuilder的建構函式中的邏輯我們可以看到StringBuilder本質儲存是在char[],這個字元陣列的初始化長度是16,這個長度主要的作用是擴容機制,即首次需要進行擴容的時機是當m_ChunkChars長度超過16的時候,這個時候原有的m_ChunkChars已經不能承載需要構建的字串的時候觸發擴容。

核心方法

我們上面看到了StringBuilder相關的初始化程式碼,通過初始化操作,我們可以瞭解到StringBuilder本身的資料結構,但是想了解StringBuilder的擴容機制,還需要從它的Append方法入手,因為只有Append的時候才有機會去判斷原有的m_ChunkChars陣列長度是否滿足儲存Append進來的字串。關於StringBuilder的Append方法有許多過載,這裡我們們就不逐個列舉了,但是本質都是一樣的。因此我們們就選取我們們最熟悉的和最常用的Append(string? value)方法進行講解,直接找到原始碼位置[點選檢視原始碼?]

//承載【拼接字串的char陣列
internal char[] m_ChunkChars;
//當前m_ChunkChars字元陣列中已經使用的長度
internal int m_ChunkLength;
public StringBuilder Append(string? value)
{
    if (value != null)
    {
        // 獲取當前儲存塊
        char[] chunkChars = m_ChunkChars;
        // 獲取當前塊已使用的長度
        int chunkLength = m_ChunkLength;
        // 獲取傳進來的字元的長度
        int valueLen = value.Length;

        //當前使用的長度 + 需要Append的長度 < 當前塊的長度 則不需要擴容
        if (((uint)chunkLength + (uint)valueLen) < (uint)chunkChars.Length)
        {
            //判斷傳進來的字串長度是否<=2
            //如果小於2則只用直接訪問位置的方式操作
            if (valueLen <= 2)
            {
                //判斷字串長度>0的場景
                if (valueLen > 0)
                {
                    //m_ChunkChars的已使用長度其實就是可以Append新元素的起始位置
                    //直接取value得第0個元素放入m_ChunkChars[可儲存的起始位置]
                    chunkChars[chunkLength] = value[0];
                }
                //其實是判斷字串長度==2的場景
                if (valueLen > 1)
                {
                    //因為上面已經取了value第0個元素放入了m_ChunkChars中
                    //現在則取value得第1個元素繼續放入chunkLength的下一位置
                    chunkChars[chunkLength + 1] = value[1];
                }
            }
            else
            {
                //如果value的長度大於2則通過操作記憶體去追加value
                //獲取m_ChunkChars的引用位置,偏移到m_ChunkLength的位置追加value
                Buffer.Memmove(
                    ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(chunkChars), chunkLength),
                    ref value.GetRawStringData(),
                    (nuint)valueLen);
            }
            //更新以使用長度的值,新的使用長度是當前已使用長度+追加進來的字串長度
            m_ChunkLength = chunkLength + valueLen;
        }
        else
        {
            //走到這裡說明進入了擴容邏輯
            AppendHelper(value);
        }
    }
    return this;
}

這一部分邏輯主要展示了未達到擴容條件時候的邏輯,其本質就是將Append進來的字串追加到m_ChunkChars陣列裡去,其中m_ChunkLength代表了當前m_ChunkChars已經使用的長度,另一個含義也是代表了下一次Append進來元素儲存到m_ChunkLength的起始位置。而擴容的需要的邏輯則進入到了AppendHelper方法中,我們們看一下AppendHelper方法的實現[點選檢視原始碼?]

private void AppendHelper(string value)
{
    unsafe
    {
        //防止垃圾收集器重新定位value變數。
        //指標操作,string本身是不可變的char陣列,所以它的指標是char* 
        fixed (char* valueChars = value)
        {
            //呼叫了另一個append
            Append(valueChars, value.Length);
        }
    }
}

這裡是獲取了傳遞進來的value指標然後呼叫了另一個過載的Append方法,不過從這段程式碼中可以得到一個資訊這個操作是非執行緒安全的。我們繼續找到另一個Append方法[點選檢視原始碼?]

public unsafe StringBuilder Append(char* value, int valueCount)
{
    // value必須有值
    if (valueCount < 0)
    {
        throw new ArgumentOutOfRangeException();
    }

    //新的長度=StringBuilder的長度+需要追加的字串長度
    int newLength = Length + valueCount;
    //新的長度不能大於最大容量
    if (newLength > m_MaxCapacity || newLength < valueCount)
    {
        throw new ArgumentOutOfRangeException();
    }

    // 新的起始位置=需要追加的長度+當前使用的長度
    int newIndex = valueCount + m_ChunkLength;
    // 判斷當前m_ChunkChars的容量是否夠用
    if (newIndex <= m_ChunkChars.Length)
    {
        //夠用的話則直接將追加的元素新增到m_ChunkChars中去
        new ReadOnlySpan<char>(value, valueCount).CopyTo(m_ChunkChars.AsSpan(m_ChunkLength));
        //更新已使用的長度為新的長度
        m_ChunkLength = newIndex;
    }
    //當前m_ChunkChars不滿足儲存則需要擴容
    else
    {
        // 判斷當前儲存塊m_ChunkChars還有多少未儲存的位置
        int firstLength = m_ChunkChars.Length - m_ChunkLength;
        if (firstLength > 0)
        {
            //把需要追加的value中的前firstLength位字元copy到m_ChunkChars中剩餘的位置
            //合理的利用儲存空間,擷取需要追加的value到m_ChunkChars剩餘的位置
            new ReadOnlySpan<char>(value, firstLength).CopyTo(m_ChunkChars.AsSpan(m_ChunkLength));
            //更新已使用的位置,這個時候當前存塊m_ChunkChars已經儲存滿了
            m_ChunkLength = m_ChunkChars.Length;
        }

        // 獲取value中未放入到m_ChunkChars(因為當前塊已經放滿)剩餘部分起始位置
        int restLength = valueCount - firstLength;
        //擴充套件當前儲存塊即擴容操作
        ExpandByABlock(restLength);
        //判斷新的儲存塊是否建立成功
        Debug.Assert(m_ChunkLength == 0, "A new block was not created.");
        // 將value中未放入到m_ChunkChars的剩餘部放入擴容後的m_ChunkChars中去
        new ReadOnlySpan<char>(value + firstLength, restLength).CopyTo(m_ChunkChars);
        // 更新當前已使用長度
        m_ChunkLength = restLength;
    }
    //一些針對當前StringBuilder的校驗操作,和相關邏輯無關不做詳細介紹
    //類似的Debug.Assert(m_ChunkOffset + m_ChunkChars.Length >= m_ChunkOffset, "The length of the string is greater than int.MaxValue.");
    AssertInvariants();
    return this;
}

這裡的原始碼涉及到了一個StringBuilder的長度問題,Length代表著當前StringBuilder物件實際存放的字元長度,它的定義如下所示

public int Length
{
    //StringBuilder已儲存的長度=塊的偏移量+當前塊使用的長度
    get => m_ChunkOffset + m_ChunkLength;
    set
    {
        //注意這裡是有程式碼的只是我們暫時省略set邏輯
    }
}

上面原始碼的這個Append方法其實是另一個過載方法,只是Append(string? value)呼叫了這個邏輯,這裡可以清晰的看到,如果當前儲存塊滿足儲存,則直接使用。如果當前儲存位置不滿足儲存,那麼儲存空間也不會浪費,按照當前儲存塊的可用儲存長度去擷取需要Append的字串的長度,放入到這個儲存塊的剩餘位置,剩下的儲存不下的字元則儲存到擴容的新的儲存塊m_ChunkChars中去,這個做法就是為了不浪費儲存空間。

這一點考慮的非常周到,即使要發生擴容,那麼我當前節點的儲存塊也一定要填充滿,保證了儲存空間的最大利用。

通過上面的Append原始碼我們自然可看出擴容的邏輯自然也就在ExpandByABlock方法中[點選檢視原始碼?]

//當前StringBuilder實際儲存的總長度
public int Length
{
    //StringBuilder已儲存的長度=塊的偏移量+當前塊使用的長度
    get => m_ChunkOffset + m_ChunkLength;
    set
    {
        //注意這裡是有程式碼的只是我們暫時省略set邏輯
    }
}
//當前StringBuilder的總容量
public int Capacity
{
    get => m_ChunkChars.Length + m_ChunkOffset;
    set
    {
        //注意這裡是有程式碼的只是我們暫時省略set邏輯
    }
}

//可承載字元的最大容量,即可以拼接的字串的長度
internal int m_MaxCapacity;
//承載【拼接字串的char陣列
internal char[] m_ChunkChars;
//當前塊的最大長度
internal const int MaxChunkSize = 8000;
//當前m_ChunkChars字元陣列中已經使用的長度
internal int m_ChunkLength;
//儲存塊的偏移量,用於計算總長度
internal int m_ChunkOffset;
//前一個儲存塊
internal StringBuilder? m_ChunkPrevious;
private void ExpandByABlock(int minBlockCharCount)
{
    //當前塊m_ChunkChars儲存滿才進行擴容操作
    Debug.Assert(Capacity == Length, nameof(ExpandByABlock) + " should only be called when there is no space left.");
    //minBlockCharCount指的是剩下的需要儲存的長度
    Debug.Assert(minBlockCharCount > 0);
    AssertInvariants();

    //StringBuilder的總長度不能大於StringBuilder的m_MaxCapacity
    if ((minBlockCharCount + Length) > m_MaxCapacity || minBlockCharCount + Length < minBlockCharCount)
    {
        throw new ArgumentOutOfRangeException();
    }

    //!!!需要擴容塊的新長度=max(當前追加字元的剩餘長度,min(當前StringBuilder長度,8000))
    int newBlockLength = Math.Max(minBlockCharCount, Math.Min(Length, MaxChunkSize));

    //判斷長度是否越界
    if (m_ChunkOffset + m_ChunkLength + newBlockLength < newBlockLength)
    {
        throw new OutOfMemoryException();
    }

    // 申請一個新的存塊長度為newBlockLength
    char[] chunkChars = GC.AllocateUninitializedArray<char>(newBlockLength);

    //!!!把當前StringBuilder中的儲存塊存放到一個新的StringBuilder例項中,當前例項的m_ChunkPrevious指向上一個StringBuilder
    //這裡可以看出來擴容的本質是構建節點為StringBuilder的連結串列
    m_ChunkPrevious = new StringBuilder(this);
    //偏移量是每次擴容的時候去修改,它的長度就是記錄了已使用塊的長度,但是不包含當前StringBuilder的儲存塊
    //可以理解為偏移量=長度-已經存放擴容塊的長度
    m_ChunkOffset += m_ChunkLength;
    //因為已經擴容了新的容器所以重置已使用長度
    m_ChunkLength = 0;
    //把新的塊重新賦值給當前儲存塊m_ChunkChars陣列
    m_ChunkChars = chunkChars;
    AssertInvariants();
}

這段程式碼是擴容的核心操作,通過這個我們可以清晰的瞭解到StringBuilder的儲存本質

  • 首先StringBuilder的資料儲存在m_ChunkChars字元陣列中,但是擴容本質是單向連結串列操作,StringBuilder本身包含了m_ChunkPrevious指向的是上一個擴容時儲存的資料。
  • 然後StringBuilder每次擴容的長度是不固定的,實際的擴容長度是max(當前追加字元的剩餘長度,min(當前StringBuilder長度,8000)),由此我們可以以得知,一個塊m_ChunkChars陣列的大小最大是8000

StringBuilder還包含了一個通過StringBuilder構建例項的方法,這個建構函式就是給擴容時候構建單向連結串列使用的,它的實現也很簡單

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;
    AssertInvariants();
}

其目的就是把擴容之前的儲存相關的各種資料傳遞給新的StringBuilder例項。好了到目前為止Append的核心邏輯就說完了,我們大致捋一下Append的核心邏輯我們先大致羅列一下,舉個例子

  • 1.預設情況m_ChunkChars[16],m_ChunkOffset=0,m_ChunkPrevious=null,Length=0
  • 2.第一次擴容m_ChunkChars[16],m_ChunkOffset=16,m_ChunkPrevious=指向最原始的StringBuilder,m_ChunkLength=16
  • 3.第二次擴容m_ChunkChars[32],m_ChunkOffset=32,m_ChunkPrevious=擴容之前的m_ChunkChars[16]的StringBuilder,m_ChunkLength=32
  • 4.第三次擴容m_ChunkChars[64],m_ChunkOffset=64,m_ChunkPrevious=擴容之前的m_ChunkChars[64]的StringBuilder,m_ChunkLength=64

大概花了一張圖,不知道能不能輔助理解一下StringBuilder的資料結構,StringBuilder的連結串列結構是當前節點指向上一個StringBuilder,即當前擴容之前的StringBuilder的例項

c# StringBuilder整體的資料結構來說是一個單向連結串列,但是連結串列的每一個節點儲存塊是m_ChunkChars是char[]。擴容的本質就是給這個連結串列新增一個節點,每次擴容新增的節點儲存塊的容量都會增加。大部分使用時遇到的情況是首次為16、二次為16、三次為32、四次為64以此類推。

轉換成字串

通過上面StringBuilder的資料結構我們瞭解到StringBuilder本質的資料結構是單向連結串列,這個單向連結串列包含m_ChunkPrevious指向上一個StringBuilder例項,也就是一個倒序的連結串列。我們最終拿到StringBuilder的構建結果是通過StringBuilder的ToString()方法進行的,得到最終的一個結果字串,接下來我們就來看一下ToString的實現[點選檢視原始碼?]

//當前StringBuilder實際儲存的總長度
public int Length
{
    //StringBuilder已儲存的長度=塊的偏移量+當前塊使用的長度
    get => m_ChunkOffset + m_ChunkLength;
    set
    {
        //注意這裡是有程式碼的只是我們暫時省略set邏輯
    }
}
public override string ToString()
{
    AssertInvariants();
    //當前StringBuilder長度為0則直接返回空字串
    if (Length == 0)
    {
        return string.Empty;
    }
    //FastAllocateString函式負責分配長度為StringBuilder長度的字串
    //這個字串就是ToString最終返回的結果,所以長度等於StringBuilder的長度
    string result = string.FastAllocateString(Length);
    //當前StringBuilder是遍歷的第一個連結串列節點
    StringBuilder? chunk = this;
    do
    {
        //當前使用長度必須大於0,也就是說當前塊的m_ChunkChars必須使用過,才需要遍歷當前節點
        if (chunk.m_ChunkLength > 0)
        {
            // 取出當前遍歷的StringBuilder的相關資料
            // 當前遍歷StringBuilder的m_ChunkChars
            char[] sourceArray = chunk.m_ChunkChars;
            int chunkOffset = chunk.m_ChunkOffset;
            int chunkLength = chunk.m_ChunkLength;

            // 檢查是否越界
            if ((uint)(chunkLength + chunkOffset) > (uint)result.Length || (uint)chunkLength > (uint)sourceArray.Length)
            {
                throw new ArgumentOutOfRangeException();
            }
            //把當前遍歷項StringBuilder的m_ChunkChars逐步新增到result中當前結果的前端
            Buffer.Memmove(
                ref Unsafe.Add(ref result.GetRawStringData(), chunkOffset),
                ref MemoryMarshal.GetArrayDataReference(sourceArray),
                (nuint)chunkLength);
        }
        //獲取當前StringBuilder的前一個節點,迴圈遍歷連結串列操作
        chunk = chunk.m_ChunkPrevious;
    }
    //如果m_ChunkPrevious==null則代表是第一個節點
    while (chunk != null);

    return result;
}

關於這個ToString操作本質就是一個倒序連結串列的遍歷操作,每一次遍歷都獲取當前StringBuilder的m_ChunkPrevious字元陣列獲取資料拼接完成之後,獲取當前StringBuilder的上一個StringBuilder節點,即m_ChunkPrevious的指向,結束的條件就是m_ChunkPrevious==null說明該節點是首節點,最終拼接成一個string字串返回。關於這個執行的遍歷過程大概可以理解為這麼一個過程,比如我們們的StringBuilder裡存放的是我和我的祖國一刻也不能分割,無論我走到哪裡都留下一首讚歌。,那麼針對ToString遍歷StringBuilder的遍歷過程則是大致如下的效果

//初始化一個等於StringBuilder長度的字串
string result = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
//第一次遍歷後
result = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0無論我走到哪裡都留下一首讚歌。";
//第二次遍歷後
result = "\0\0\0\0\0\0\0一刻也不能分割,無論我走到哪裡都留下一首讚歌。";
//第三次遍歷後
result = "\0\0\0我的祖國一刻也不能分割,無論我走到哪裡都留下一首讚歌。";
//第三次遍歷後
result = "我和我的祖國一刻也不能分割,無論我走到哪裡都留下一首讚歌。";

畢竟StringBuilder只能記錄上一個StringBuilder的資料,因此這是一個倒序遍歷StringBuilder連結串列的操作,每次遍歷都是向前新增m_ChunkPrevious中記錄的資料,直到m_ChunkPrevious==null則遍歷完成直接返回結果。

c# StringBuilder類的ToString本質就是倒序遍歷單向連結串列,連結串列的的每一個node都是StringBuilder例項,獲取裡面的儲存塊m_ChunkChars字元陣列進行拼裝,迴圈玩所有的節點之後把結果組裝成一個字串返回。

對比java實現

我們可以看到在C#上StringBuilder的實現,本質是一個連結串列。那麼和C#語言類似的Java實現思路是否一致的,我們們大致看一下Java中StringBuilder的實現思路如何,我本地的jdk版本為1.8.0_191,首先也是初始化邏輯

//儲存塊也就是承載Append資料的容器
char[] value;
//StringBuilder的總長度
int count;
public StringBuilder() {
    //預設的容量也是16
    super(16);
}

public StringBuilder(String str) {
    //這個地方有差異如果通過指定字串初始化StringBuilder
    //則初始化的長度則是當前傳遞的str的長度+16
    super(str.length() + 16);
    append(str);
}

// AbstractStringBuilder.java
AbstractStringBuilder(int capacity) {
    value = new char[capacity];
}

在這裡可以看到java的初始化容量的邏輯和c#有點不同,c#預設的初始化長度取決於能儲存初始化字串的長度為主,而java的實現則是在當前長度上+16的長度,也就是無論如何這個初始化的16的長度必須要有。那麼我們再來看一下append的實現原始碼

// AbstractStringBuilder.java
public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
    int len = str.length();
    // 這裡是擴容操作
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    //每次append之後重新設定長度
    count += len;
    return this;
} 

核心的是擴容ensureCapacityInternal的方法,我們們簡單的看下它的實現

private void ensureCapacityInternal(int minimumCapacity) {
    //當前需要的長度>char[]的長度則需要擴容
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}

void expandCapacity(int minimumCapacity) {
    //新擴容的長度是當前塊char[]的長度的2倍+2
    int newCapacity = value.length * 2 + 2;
    if (newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    if (newCapacity < 0) {
        if (minimumCapacity < 0)
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    //把當前的char[]複製到新擴容的字元陣列中
    value = Arrays.copyOf(value, newCapacity);
}

// Arrays.java copy的邏輯
public static char[] copyOf(char[] original, int newLength) {
    //宣告一個新的陣列,把original的資料copy到新的char陣列中
    char[] copy = new char[newLength];
    System.arraycopy(original, 0, copy, 0,
                     Math.min(original.length, newLength));
    return copy;
}

最後要展示的則是得到StringBuilder結果的操作,同樣是toString方法,我們們看一下java中這個邏輯的實現

@Override
public String toString() {
    // 這裡建立了一個新的String物件返回,通過當前char[]初始化這個字串
    return new String(value, 0, count);
}

到了這裡關於java中StringBuilder的實現邏輯相信大家都看的非常清楚了,這裡和c#的實現邏輯確實是不太一樣,本質的底層資料結構都是不一樣的,這裡我們們簡單的羅列一下它們實現方式的不同

  • c#中StringBuilder的雖然真正資料儲存在m_ChunkChars字元陣列,但整體的資料結構是單向連結串列,java中則完全是char[]字元陣列。
  • c#中StringBuilder的初始長度是可容納當前初始化字串的長度,java的初始化長度則是當前傳遞的字串長度+16。
  • c#中StringBuilder的擴容是生成一個新的StringBuilder例項,容量和上一個StringBuilder長度有關。java則是生成一個是原來char[]陣列長度*2+2長度的新陣列。
  • c#中ToString的實現是遍歷倒序連結串列組裝一個新的字串返回,java上則是用當前StringBuilder的char[]初始化一個新的字串返回。

關於c#和java的StringBuilder實現方式差異如此之大,到底哪種實現方式更優一點呢?這個沒辦法評價,畢竟每一門語言的底層類庫實現都是經過深思熟慮的,整合了很多人的思想。在樓主的角度來看StringBuilder本身的核心功能在於構建的過程,所以構建過程的效能非常重要,所以類似陣列擴容再copy的邏輯沒有連結串列的方式高效。但是在最後的ToString得到結果的時候,陣列的優勢是非常明顯的,畢竟string本質就是一個char[]陣列

對於StringBuilder來說append是頻繁操作大部分情況可能多次進行append操作,而ToString操作對於StringBuilder來說基本上只有一次,那就是得到StringBuilder構建結果的時候。所以樓主覺得提升append的效能是關鍵。

總結

    本文我們主要講解了c# StringBuilder的大致的實現方式,同時也對比了c#和java關於實現方式的StringBuilder的不同,主要差異是c#實現的底層資料結構為單向連結串列,但是每一個節點的資料儲存在char[]中,java實現的方式則整體都是陣列。這也為我們提供了不同的思路,在這裡我們也再次總結一下它的實現方式

  • c# StringBuilder的本質是單向連結串列操作,StringBuilder本身包含了m_ChunkPrevious指向的是上一個擴容時儲存的資料,擴容的本質就是給這個連結串列新增一個節點。
  • c# StringBuilder每次擴容的長度是不固定的,實際的擴容長度是max(當前追加字元的剩餘長度,min(當前StringBuilder長度,8000)),每次擴容新增的節點儲存塊的容量都會增加。大部分使用時遇到的情況是首次為16、二次為16、三次為32、四次為64以此類推。
  • c# StringBuilder類的ToString本質就是倒序遍歷單向連結串列,每一次遍歷都獲取當前StringBuilder的m_ChunkPrevious字元陣列獲取資料拼接完成之後,然後獲取m_ChunkPrevious指向的上一個StringBuilder例項,最終把結果組裝成一個字串返回。
  • 關於c#和java實現StringBuilder存在很大差異,主要差異是c#實現的整體底層資料結構為單向連結串列,但是每個StringBuilder例項中資料本身儲存在char[]中,這種資料結構有點像redis的quicklist。java實現的整體方式則都是char[]字元陣列。

雖然大家都說越努力越幸運,有時候我們努力是為了讓自己更幸運。但是我更喜歡的是,我們努力不僅僅是為了幸運,而是讓我們的心裡更踏實,結果固然重要,然而許多時候努力過了也就問心無愧了。

?歡迎掃碼關注我的公眾號? 淺談C#字串構建利器StringBuilder

相關文章