飘遥的Blog

C/C++/.NET
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

彻底研究StringBuilder

Posted on 2008-09-18 13:24  Zzx飘遥  阅读(13568)  评论(7编辑  收藏  举报
上一篇《彻底研究String》介绍了String类型的一些性质。.NET对String的优化,能高效安全的完成一些操作,但正是这些优化导致了在进行某些操作时会占用大量的资源,如拼接字符串、修改字符串等等,高效地完成这些操作的替代类型是StringBuilder。(每次都想"彻底研究",但每次都发现研究的不彻底,写出来的更不彻底,以至于不敢用"彻底研究"等字眼了,但为与上一篇对应,暂且再亵渎一次"彻底研究"!) 

上篇勘误
上一篇《彻底研究String》中"在与不安全代码互操作是会不会破坏字符串的不变性?"这个问题的答案的分析验证是错误的,在这里向大家道歉!已经更新。

public sealed class StringBuilder : ISerializable
位于:System.Text命名空间中。
StringBuilder仅实现ISerializable接口,直接派生自Object,相对于String类型其功能不太完善,如ToUpper、SubString、foreach遍历每个字符等等,后面介绍如何扩展其功能。它是密封类型,不能通过派生它的子类来改变其行为。
StringBuilder能够动态高效的构建字符串、修改字符串,不能保证所有实例成员都是线程安全的,尽管在类型定义中加入了很多线程安全的控制,如果要确保其线程安全,须手工实现线程同步机制。
StringBuilder内部维护的是一个String对象,在String类型所在的程序外部,String表现出不变性,但在String类型的内部,定义了一些改变String对象的方法,声明为internal,这些方法供StringBuilder等调用。

最大容量(MaxCapacity):
默认的最大容量等于int.MaxValue。
StringBuilder sb = new StringBuilder();
Console.WriteLine(sb.MaxCapacity);
// 2147483647
MaxCapacity是只读属性,其取值范围为1~int.MaxValue,如果想设定自己的最大容量,StringBuild提供了一个构造方法:
public StringBuilder(int capacity, int maxCapacity)
在构造StringBuilder对象时设定最大容量值,最大值一旦设定,将不可改变,如果Append或其它操作使Length大于MaxCapacity将抛出异常。

容量(Capacity):返回StringBuilder的当前容量,其取值范围为:0~MaxCapacity,这个属性是可读写的,若设置的值小于Length将抛出异常。

长度(Length):
返回StringBuilder内部维护的当前String对象的长度,取值范围:0~MaxCapacity,可变属性。
int.MaxValue >= MaxCapacity >= Capacity >= Length >=0 (MaxCapacity >= 1)
MaxCapacity只是一个Capacity和Length的范围约束,Capacity是StringBuilder内部字符串实际分配内存的大小,Length是StringBuilder内部字符串的有效字符的数量。

Capacity的变化规律

StringBuilder的Capacity属性的取值范围是:0~MaxCapacity;默认大小为:16。
StringBuilder sb = new StringBuilder();
Console.WriteLine(sb.Capacity);
// 16
当以构造方法StringBuilder(String str) 创建StringBuilder对象时,Capacity的值可用下面的伪代码表示:
IF str.Length < 16 THEN sb.Capacity = 16
ELSE
    BEGIN
    sb.Capacity
= 16
    
WHILE str.Length < sb.Capacity
        sb.Capacity
*= 2
    END
示例:
StringBuilder sb = new StringBuilder(new string('a', 16));
Console.WriteLine(sb.Capacity);
// 16

StringBuilder sb1
= new StringBuilder(new string('a', 18));
Console.WriteLine(sb1.Capacity);
// 32

Append等扩充字符串操作时,如果结果字符串的长度大于Capacity,则Capacity加倍;如果加倍后的Capacity还不足以容纳结果字符串,则Capacity的值等于结果字符串的长度。
StringBuilder sb1 = new StringBuilder(new string('a', 18));
Console.WriteLine(sb1.Capacity);                              
// 32
Console.WriteLine(sb1.Append(new string('b', 18)).Capacity);  // 64  (32 * 2)
Console.WriteLine(sb1.Append(new string('c', 100)).Capacity); // 136 (result.Length)
Console.WriteLine(sb1.Append(new string('d', 100)).Capacity); // 272 (136 * 2)

实现原理:StringBuilder维护一个长度等于Capacity的字符串(可以看作字符数组),当Capacity长度的字符串不足以容纳结果字符串时,StringBuilder开辟新的长度为经过上面的规则计算好的Capacity的内存区域,将原字符串复制到新的内存区域再进行操作,原字符串区域交给GC回收。因此这里也涉及到内存的分配与回收,使用StringBuilder时最好估算一下所需容量,用这个容量初始化Capacity,提高性能。

StringBuilder内字符串的垃圾数据

字符串在内存中是顺序存储的。StringBuilder内部字符串的可用长度是Capacity,有效字符数是Length。刚刚构造StringBuilder后Length到Capacity的范围,都保留内存中的原垃圾数据。
初始化后显式增大Capacity大小后,增大的部分内存保留原垃圾数据。
系统自动扩大容量,Length到Capacity的部分将清空内存中原垃圾数据为'\0'。

设置StringBuilder长度时,若新设置的Lenght小于原Length,字符串将被截断,新Length到原Length的部分填充空字符'\0';若新设置的Lenght大于原Length,原Lenght到新Length的部分填充空字符'\0'。
由于字符串在内存中是顺序存储的,可用下面的方法查看StringBuilder内部字符串内存中的数据:
unsafe static void ShowContent(StringBuilder sb)
{
    
fixed(char* ch = sb.ToString())
    {
        
for(int i = 0; i < sb.Capacity; i++)
        {
            Console.Write((
int)ch[i] + " ");
        }
    }
}

ToString方法
由下面的示例代码可以看出,每调用一次ToString(),获得的String对象引用都会变化。
static void Main(string[] args)
{
    StringBuilder sb
= new StringBuilder("Hello StringBuilder!");
    ShowAddress(sb.ToString());
// 20656316
    ShowAddress(sb.ToString()); // 20666276
    ShowAddress(sb.ToString()); // 20666372
    ShowAddress(sb.ToString()); // 20666468
    ShowAddress(sb.ToString()); // 20666564
    ShowAddress(sb.ToString()); // 20666660
    ShowAddress(sb.ToString()); // 20666756
    ShowAddress(sb.ToString()); // 20666852
    ShowAddress(sb.ToString()); // 20666948
    ShowAddress(sb.ToString()); // 20667044
}

public unsafe static void ShowAddress(string s)
{
    
fixed (char* p = s)
    {
        Console.WriteLine((
int)p);
    }
}

为得出原因,Reflector查看,是如下代码:
public override string ToString()
{
    
string stringValue = this.m_StringValue;
    
if (this.m_currentThread != Thread.InternalGetCurrentThread())
    {
        
return string.InternalCopy(stringValue);
    }
    
if ((2 * stringValue.Length) < stringValue.ArrayLength)
    {
        
return string.InternalCopy(stringValue);
    }
    stringValue.ClearPostNullChar();
    
this.m_currentThread = IntPtr.Zero;
    
return stringValue;
}

1.看来ToString()方法对线程安全进行控制了,如果不是当前线程访问,返回字符串的拷贝。
2.ArrayLength应该就是Capacity了,如果长度小于Capacity的1/2,为优化性能,返回字符串的新的拷贝。
3.以上条件不满足,返回StringBuilder内部字符串。但调用一次ToString()后,执行了this.m_currentThread = IntPtr.Zero; ,如果再紧接着执行ToString()会返回新的字符串。
要返回StringBuilder内部字符串的真实地址,可用反射或序列化取得StringBuilder内的字符串的引用,再取字符串的地址,StringBuilder内字符串声明为:
internal volatile string m_StringValue;

StringBuilder sb = new StringBuilder("Hello StringBuilder!");

ShowAddress(sb.ToString());
// 21075152
ShowAddress(sb.ToString()); // 21117944
ShowAddress(sb.ToString()); // 21118040
ShowAddress(sb.ToString()); // 21075152

SerializationInfo info
= new SerializationInfo(
    
typeof(StringBuilder), new FormatterConverter());

((ISerializable)sb).GetObjectData(
    info,
new StreamingContext());

String s
= info.GetString("m_StringValue");

ShowAddress(s);
// 21075152

可见,第一次调用ToString()方法非常高效,直接返回StringBuilder内字符串的引用。如果没有重新取得m_currentThread,接下来的调用会拷贝构造新的字符串。
CLR会记录该StringBuilder维护的String已被引用,如果试图对其修改,StringBuilder会重新分配内存区域,将原字符串拷贝到新的内存区域然后进行修改。

ToString重载的另一个版本原型为:public string ToString(int startIndex, int length)会构造新的字符串,新字符串值为:起始索引为startIndex,长度为length的子字符串,这个重载能实现String中的SubString的功能。

EnsureCapacity
确保最小的容量不小于给定的数值。如果给定的数值值小于目前的Capacity,则忽略给定的数值;如果给定的数值大于Capacity,则设置Capacity为给定的数值。

AppendFormat
有时感觉StringBuilder连接字符串不如String连接方便,如果用AppendFormat会方便很多,使用方法跟String.Format相似,这个方法保证了字符串连接的优雅与高效。

不知道大家有没有注意到,Append、AppendFormat、AppendLine、Insert、Remove、Replace等方法对StringBuild对象操作完成后都返回StringBuilder自身,这样的设计便于进行一连串的操作。
如:
StringBuilder sb0 = new StringBuilder("abc",10);
string s = sb0.Append("abc").
    Replace(
"ca", "--").
    Insert(
0, "String:").
    ToString();

扩展StringBuilder的操作
StringBuilder与String相比,非常多的操作没有实现,可以调用ToString()后再进行操作,但这样会影响效率;也可以用扩展方法来扩展其操作;既然上面能取得StringBuilder成员m_StringValue的值,可以直接用所有String的方法来处理判断,但这样会造成一些问题,不推荐这样做。

扩展StringBuilder,如foreach遍历所有字符、每个字符字符转换为其对应的大写字符示例:
static class Program
{
    
static void Main(string[] args)
    {
        StringBuilder sb
= new StringBuilder("Hello StringBuilder!");

        
foreach (var c in sb.GetEnumerator())
        {
            Console.WriteLine(c);
        }

        Console.WriteLine(sb.ToUpper());

        Console.ReadKey();
    }

    
static IEnumerable<Char> GetEnumerator(this StringBuilder sb)
    {
        
for (var i = 0; i < sb.Length; i++)
        {
            
yield return sb[i];
        }
    }

    
static StringBuilder ToUpper(this StringBuilder sb)
    {
        
for (var i = 0; i < sb.Length; i++)
        {
            sb[i]
= Char.ToUpper(sb[i]);
        }

        
return sb;
    }
}


在输出托管String起始地址时,刚开始想到的是托管String封送到非托管代码,用VC实现了一个类,但输出结果不是预期的,在查资料时发现了一篇很不错的文章,推荐给大家。
CLR 完全介绍—托管代码与非托管代码之间的封送处理( http://msdn.microsoft.com/zh-cn/magazine/cc164193.aspx )
后来发现没必要用非托管调用实现,不安全代码就可实现输出String对象的起始地址。
还有一篇介绍StringBuilder的ToString() 执行效率的:StringBuilder.ToString() vs Reflection( http://blogs.msdn.com/miah/archive/2007/10/20/stringbuilder-tostring-vs-reflection.aspx )

参考资料
MSDN
Applied Microsoft .NET Framework Programming

Author: 飘遥(周振兴)
Blog: http://Zxjay.cnblogs.com/
URL: http://www.cnblogs.com/zxjay/archive/2008/09/18/1293262.html