C#基础知识梳理系列九:StringBuilder
几乎所有的资料都告诉我们,在频繁进行字符串拼接操作的时候推荐使用StringBuilder,因为它提供更优秀的性能,不辞劳苦的我们也写示例验证过,确实如传说的那样!但为什么StringBuilder 在操作字符串会有优异的表示呢?它真是像很多资料中所说“每次新追加进来字符串和旧有字符串总长度超设定容量时,会新键一个数组存放串字符,并且丢弃原有旧数组”吗?这一节我们来看个究竟。
在前面的章节中我们已经知道,字符串是由字符组成,由于字符串具有不可变性,所以每一次对字符串的变动都会重新分配内存、创建一个字符串对象、丢弃旧对象,在重新分配内存过程可能会导致垃圾回收,这一系列的操作,会大大损伤性能。为了解决这个问题,FCL提供了一个System.Text.StringBuilder类来构造管理字符串。就像它的名字一样,它是一个构造器,提供了对字符串的追加、移除和替换等功能,向StringBuilder对象追加字符串时,实际上在内部是转为追加字符。System.Text.StringBuilder类有几个重要的字段、属性和方法,我们一个一个来看。
(1) internal char[] m_ChunkChars
保存StringBuilder所管理着的字符串中的字符。系统默认初始化它的长度为16,当新追加进来的字符串长度与旧有字符串长度之和大于该字符数组容量时,新创建字符数组容量会增加到“2的(n+1)次幂”(假如当前字符数组容量为2的n次幂)。
(2) internal int m_ChunkLength;
字符数组m_ChunkChars内的实际字符个数,系统默认m_ChunkChars的容量是16。
(3) internal int m_ChunkOffset;
字符定位的偏移量
(4) internal StringBuilder m_ChunkPrevious;
内部的一个StringBuilder对象,追加的字符串长度和旧字符串长度之合大于字符数组m_ChunkChars的最大容量时,会根据当前的(this)StringBuilder创建一个新的StringBuilder对象,将m_ChunkPrevious指向新创建的StringBuilder对象。这个是关键。
(5) public int Length;
当前StringBuilder对象实际管理的字符串长度。Length = m_ChunkLength + m_ChunkOffset
(6) public int Capacity;
设置或获取字符数组m_ChunkChars的最大容量。
创建一个新的StringBuilder对象后,字符数组m_ChunkChars最大容量被初始化为16,向StringBuilder对象追加字符串,如果追加前后的字符串总长度小于等于16,则将新追加的字符串的字符复制到m_ChunkChars数组;如果追加前后的字符串总长度大于16,则先用新字符将当前m_ChunkChars填满,再以当前对象(this)为基础构造一个StringBuilder对象,并且将m_ChunkPrevious指向这个新创建的StringBuilder对象,然后将Capacity设置为2的(n+1)次幂32,重新初始化字符数组m_ChunkChars且容量为2的(n+1)次幂32(注意:这个不一定),然后将刚才剩余的字符复制到最新的字符数组m_ChunkChars。每一次追加字符串都会执行上面类似的步骤。下面我们来看一下这个过程,为了方便演示,我们在创建一个StringBuilder对象后,先设置容量为2。如下代码:
StringBuilder strBuilder = new StringBuilder(); strBuilder.Capacity = 2;
看一下初始化后的结果:
可以看到最大容量Capacity为2,由于未向其追加字符串,所以字符数组m_ChunkChars的元素为空,m_ChunkPrevious是null。
A)接着我们向其追加一个字符串”a”:
可以看到,新添加的字符a被放到了字符数组的0号位置,字符数组内元素个数为1。
B)接着追加一个字符b:
此时是将新字符b放到了字符数组1号位置,很显然字符数组的有效长度m_ChunkLength增加1后值为2,此时的m_chunkPrevious依然保持着null。
C)接着我们再添加一个字符c:
可以看到,strBuilder的字符容量Capacity已经变成2的(1+1)次幂4。因为原先长度为2的数组m_ChunkChars已经无法装载长度为3的字符串,所以要创新创建一个数组来扩容,但是这里使用旧有容量(值为2)创建的数组已经中以容纳新加进来的字符串 c ,所以m_ChunkChars数组依然被初始化为容量为2的数组。由于strBuilder内已经有3个字符,Length=m_ChunkLength+m_ChunkOffset,所以Length为3,最新的字符c已经放到了新数组m_ChunkChars的0号位。最主要的是字段m_ChunkPrevious已经不空null了,它已经指向截止到B)步骤的strBuilder对象,这个指向可以通过StringBuilder内部代码看的出来:
private void ExpandByABlock(int minBlockCharCount) { if ((minBlockCharCount + this.Length) > this.m_MaxCapacity) { throw new ArgumentOutOfRangeException("requiredLength", Environment.GetResourceString("ArgumentOutOfRange_SmallCapacity")); } 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; if ((this.m_ChunkOffset + num) < num) { this.m_ChunkChars = null; throw new OutOfMemoryException(); } this.m_ChunkChars = new char[num]; }
事实上初始化m_ChunkPrevious在前,创建新的字符数组m_ChunkChars在后,最后才是复制字符到数组m_ChunkChars中。
D)接着我们连续添加两个字符d和e:
在上一步骤C)的时候容量Capacity是4,字符数组还有一个空位置,所以当我们添加字符d时还可以用该数组,并不需要迁移对象和重建数组。但是在添加字符e的时候,由于总字符个数为5(abcde)已经超出了Capacity的4,所以此时会执行类似C)的步骤,最关键的两行代码:
this.m_ChunkPrevious = new StringBuilder(this); this.m_ChunkChars = new char[num];
需要说明,为了节省内存,StringBuilder内部并不一定是每次扩容m_ChunkChars真的按照2的(n+1)次幂进行计算,它是根据旧有字符串和新追加字符串的总长度和上一次容量的差来进行扩容:
int num = Math.Max(minBlockCharCount, Math.Min(this.Length, 0x1f40)); this.m_ChunkChars = new char[num];
归根结底,StringBuilder是在内部以字符数组m_ChunkChars为基础维护一个链表m_ChunkPreviou。如图:
(1) Append方法及重载
public unsafe StringBuilder Append(string value)
向StringBuilder追加新元素,由于在内部使用了指针,所以这里用了unsafe。它有18个重载,无论哪个重载方法,最终都是将新值转为字符进行添加。类似的还有AppendFormat系列方法。
(2) Insert方法及重载
public unsafe StringBuilder Insert(int index, string value)
向指定位置插入字符串。
(3) Replace方法及重载
public StringBuilder Replace(string oldValue, string newValue);
使用新字符串替换与oldValue匹配的字符串,它有3个重载。
(4) Remove方法
public StringBuilder Remove(int startIndex, int length);
从指定索引位移除指定数量的字符,它没有重载。方法Insert、Replace和Remove都是对内部字符数组m_ChunkChar和链表中m_ChunkPrevious内的字符数组m_ChunkChar操作,StringBuilder内部实现有点“绕”,感兴趣的可以自行去研究研究。
(5) ToString方法
public override string ToString();
StringBuilder重写了基类的ToString()方法用来获取StringBuilder对象的字符串表示,它是将链表m_ChunkPrevious中的字符数组m_ChunkChars及当前StringBuilder对象的字符数组m_ChunkChar中的字符转成String对象返回,这一步是创建一个新的String对象,所以对这个String对象(ToString()的结果)的操作不会影响到StringBuilder对象内部的字符。
还有一个方法与ToString()方法类似:
public string ToString(int startIndex, int length);
将指定位置及指定长度的字符转为字符串。