详述字符串之.NET Framework String 类
写在前面
在经过了一个昏天黑地的假日之后,终于决定要做点儿什么了。这次决定说说属于地球上的程序员的共同话题--字符串。
为什么这么说呢?其实很简单,字符串是人机交互的重要元素。用户通过键盘,向计算机传入的绝大部分内容都是字符串,而计算机的计算结果,也有相当一部分要转换为字符串向用户显示。字符串是一个程序员必须要处理,而且要处理好的问题。记得上学时,谭浩强的《C程序设计》中就用很大的篇幅介绍了字符串数组。
这第一篇,就以.NET Framework 中的字符串,String类入手。今后还要继续介绍我所接触过的String类,比如CString类、string(STL)类、BSTR 等等……
获得String 类代码
以前是用Reflector 现在可以通过Visual Studio 2008的源代码调试的方式获得String类的源代码。具体方法可以参考横刀天笑 的文章。要Load System.dll 的符号表文件,然后再将调试断点设定在一行字符串操作代码上,最后按F11键,就可以看到全部的String 类代码了。
“字符串是不可变的”以及字符串内存管理
这句话有点儿难理解。其实,这句话是指字符串占用的空间。字符串的内存空间一旦申请了,就没法随意的伸缩了。如果需要修改字符串内容的大小,比如添加几个字符,就需要重新向内存申请空间。在字符串使用者看来,这没什么。但是对于计算机和OS的内存管理来说,就是一个比较大的挑战。下面列举一种情况:假设你有一篇小诗要放入内存,大概占用了318个字节。你还有一篇文章要保存,占用了内存5K的空间。
此时,如果将318个字节释放掉,然后要放入一个800字节的字符串。那么对于内存管理来说可能就是一个噩梦。因为虽然6K的内存有1K的剩余空间,但是地址并不连续。其实,有足够的空间并不意味着就可以存放对象,还要看存放对象的空间内存地址是不是连续的。当然,对于上述情况,绝大多数情况下OS的内存管理是不会弹出一个“Out of memory”了事儿的。OS内存管理模块会依据算法将某些内存页调出,保存到磁盘,再修改内存映射表…… 总之,会让计算机忙上一阵子。
如果我们在堆内存中频繁的申请、释放一个或者几个字节,那么要不了多久,在可用内存总数不变的情况下,内存空间就会“千疮百孔”也就是我们常说的内存碎片。好在.NET CLR 的GC可以对内存碎片进行整理,但这也是一个非常耗时的工作。
那么对于这么头疼的问题有什么好办法么? 一种解决办法是,给内存设定阀值,比如,设定最小内存申请空间为:512个字节。当申请318个字符的存储空间时,OS实际上分配了512个字节。这样保证内存的申请和释放都是一些相对“完整”的内存“块”以减少内存碎片的产生。 还有一种办法就是尽量遵循“不可变”的原则,减少字符串在创建后的伸长和缩短的操作。
说了这么多,只是让看客们了解一些基础知识,以便更好的理解string类的代码。言归正传……
String 类概述
String 类源代码一共有3257行。作为容器(容纳字符的容器),String 类继承了IComparable、ICloneable、IConvertible、IEnumerable 接口,以便调用者枚举、拷贝、转换容器中的字符。在声明中还有下面的代码:
#if GENERICS_WORK
, IComparable<String>, IEnumerable<char>, IEquatable<String>
#endif
这表示源代码的编写者考虑到了对泛型的支持。如果使用C# 2.0 标准的编译器编译代码,那么String 类还需要继承这三个泛型接口。String 类的方法约有80个(不包括重载形式)。我大概给他们分了分类:
1) 内存申请函数,FastAllocateString。
2) 构造函数,提供了8种构造的重载形式用来构建字符串对象。
3) 字符串操作函数,例如Insert、Join、Substring等,可以对字符串的内容进行修改。
4) 字符串特征函数,例如IsNullOrEmpty、IsNormalize等,向调用者返回当前字符串内容的特征。
5) 格式化字符串函数,Format。
6) 接口实现函数,如String.Iconvertible.ToBoolean() 等。
7) 内部辅助函数。
内存申请函数
FastAllocateString(int iLength) 这个函数不在String.cs中实现,其声明为:
private extern static String FastAllocateString(int length); 具体实现不详(为此苦恼了很久……)。但从该函数的声明可以看出,该函数可以在托管堆上申请一片指定长度的内存,并返回一个String类型的引用地址。在String类中,凡是需要申请内存的地方,都是使用这个函数。
构造函数
在8种构造函数中,最基本的构造函数的声明:
[CLSCompliant(false), MethodImplAttribute(MethodImplOptions.InternalCall)]
unsafe public extern String(sbyte *value, int startIndex, int length, Encoding enc);
也就是说,要给定一个指向字符串首地址的指针(sbyte *value),要创建的字符串在给定的字符串中的起始索引位置(int startIndex),字符串长度(int length),以及编码对象(Encoding enc)。
其实,不论那种构造,最终都是调用CreateString静态函数创建的字符串对象。
字符串操作函数
这部分包含的函数比较多,着重的说几个。Join函数,这个函数是用来进行字符串合并的函数。从这个函数里面,我们仍然可以看到字符串“不可变”的一面。代码如下:
和其他函数一样,首先要进行必要的参数检查。然后,根据参数,计算出未来合并出来的大字符串的总长度:
int jointLength = 0;
//Figure out the total length of the strings in value
int endIndex = startIndex + count - 1;
for (int stringToJoinIndex = startIndex; stringToJoinIndex <= endIndex; stringToJoinIndex++) {
if (value[stringToJoinIndex] != null) {
jointLength += value[stringToJoinIndex].Length;
}
}
//Add enough room for the separator.
jointLength += (count - 1) * separator.Length;
再然后,就是重新申请一个刚刚计算好长度的空间,用来存放新创建的字符串:
string jointString = FastAllocateString( jointLength );
看到了吧?新字符串不是以第一个小字符串,也不是以最后一个小字符串为基础,叠加出来的。而是在内存中重新申请了空间。换句话说,Join函数没有“改变”任何一个小字符串。证明了字符串声明了之后是“不可变的”。紧接着53-61行代码,就是通过指针把每个小字符串中的数据放到新创建的大缓冲区中。最后,返回这个新创建的String对象。
再说说Concat方法。这个方法我们经常使用的是它的另一种形式,那就是我们经常会写这样的代码 string s = s1 + s2 + s3;编译器在编译这行代码时,就会将多个字符串相加变为Concat函数的调用:Concat(s1,s2,s3);
字符串特征函数
IsNormalized 方法是用来表明当前字符串是否是被指定的规格规范化了,用以表明当前字符串是以何种编码格式存储的。至于IsAscii和IsFastSort都是外部实现的,无法看到代码。
格式化字符串函数
Format函数有多种重载,最基本的形式就是上面这样。参数有三:Format提供者(IFormatProvider provider), 格式字符串(String format)以及参数列表(params Object[] args)
代码中,在进行了参数检查后,创建了StringBuilder对象,然后调用了StringBuilder的AppendFormat方法完成了实际参数的填充动作。
代码比较多,就不一行行的细说了。基本上就是把格式字符串扫一遍,找出"{数字}"的部分,并用参数值对内容进行替换。高深的算法基本上是一点儿也没有。
接口实现函数
其实主要是IConvertible接口的函数程序员用的最多。通过这个接口的方法,可以把字符串转换成绝大多数需要的类型。在String类中,对IConvetible接口方法的实现,主要是通过System.Convert类的相关方法实现的。Convert类暂不在本文讨论范围内。
内部辅助函数
String类的内部函数有很多,比如TrimHelper,wstrcpy,wcslen等。顾名思义,这几个函数的作用就不必细说了。值得一提的是wcslen函数的算法,比较精巧,贴出来大家看看:
String类的内存布局
下图是String类在内存中的布局:
在内存中,位于字符串内容之前还存放有三个变量,分别是m_arrayLength,m_stringLength,m_firstChar m_arrayLength并没有太多的用处,主要是在调试时断言输出中,用来表示当前字符串的长度;m_stringLength主要是用来表示字符串长度,在多个函数中参与运算。m_firstChar 用来保存当前字符串中第一个字符。
这种布局与以前CComBSTR的情况类似,以极小的空间代价保存了字符串的长度,提高字符串操作的性能。因为字符串长度这个变量,在很多的字符串操作运算中都起着很重要的作用,如果每次都用指针数到'\0',那么性能会下降一大块。
在经过了一个昏天黑地的假日之后,终于决定要做点儿什么了。这次决定说说属于地球上的程序员的共同话题--字符串。
为什么这么说呢?其实很简单,字符串是人机交互的重要元素。用户通过键盘,向计算机传入的绝大部分内容都是字符串,而计算机的计算结果,也有相当一部分要转换为字符串向用户显示。字符串是一个程序员必须要处理,而且要处理好的问题。记得上学时,谭浩强的《C程序设计》中就用很大的篇幅介绍了字符串数组。
这第一篇,就以.NET Framework 中的字符串,String类入手。今后还要继续介绍我所接触过的String类,比如CString类、string(STL)类、BSTR 等等……
获得String 类代码
以前是用Reflector 现在可以通过Visual Studio 2008的源代码调试的方式获得String类的源代码。具体方法可以参考横刀天笑 的文章。要Load System.dll 的符号表文件,然后再将调试断点设定在一行字符串操作代码上,最后按F11键,就可以看到全部的String 类代码了。
“字符串是不可变的”以及字符串内存管理
这句话有点儿难理解。其实,这句话是指字符串占用的空间。字符串的内存空间一旦申请了,就没法随意的伸缩了。如果需要修改字符串内容的大小,比如添加几个字符,就需要重新向内存申请空间。在字符串使用者看来,这没什么。但是对于计算机和OS的内存管理来说,就是一个比较大的挑战。下面列举一种情况:假设你有一篇小诗要放入内存,大概占用了318个字节。你还有一篇文章要保存,占用了内存5K的空间。
此时,如果将318个字节释放掉,然后要放入一个800字节的字符串。那么对于内存管理来说可能就是一个噩梦。因为虽然6K的内存有1K的剩余空间,但是地址并不连续。其实,有足够的空间并不意味着就可以存放对象,还要看存放对象的空间内存地址是不是连续的。当然,对于上述情况,绝大多数情况下OS的内存管理是不会弹出一个“Out of memory”了事儿的。OS内存管理模块会依据算法将某些内存页调出,保存到磁盘,再修改内存映射表…… 总之,会让计算机忙上一阵子。
如果我们在堆内存中频繁的申请、释放一个或者几个字节,那么要不了多久,在可用内存总数不变的情况下,内存空间就会“千疮百孔”也就是我们常说的内存碎片。好在.NET CLR 的GC可以对内存碎片进行整理,但这也是一个非常耗时的工作。
那么对于这么头疼的问题有什么好办法么? 一种解决办法是,给内存设定阀值,比如,设定最小内存申请空间为:512个字节。当申请318个字符的存储空间时,OS实际上分配了512个字节。这样保证内存的申请和释放都是一些相对“完整”的内存“块”以减少内存碎片的产生。 还有一种办法就是尽量遵循“不可变”的原则,减少字符串在创建后的伸长和缩短的操作。
说了这么多,只是让看客们了解一些基础知识,以便更好的理解string类的代码。言归正传……
String 类概述
String 类源代码一共有3257行。作为容器(容纳字符的容器),String 类继承了IComparable、ICloneable、IConvertible、IEnumerable 接口,以便调用者枚举、拷贝、转换容器中的字符。在声明中还有下面的代码:
#if GENERICS_WORK
, IComparable<String>, IEnumerable<char>, IEquatable<String>
#endif
这表示源代码的编写者考虑到了对泛型的支持。如果使用C# 2.0 标准的编译器编译代码,那么String 类还需要继承这三个泛型接口。String 类的方法约有80个(不包括重载形式)。我大概给他们分了分类:
1) 内存申请函数,FastAllocateString。
2) 构造函数,提供了8种构造的重载形式用来构建字符串对象。
3) 字符串操作函数,例如Insert、Join、Substring等,可以对字符串的内容进行修改。
4) 字符串特征函数,例如IsNullOrEmpty、IsNormalize等,向调用者返回当前字符串内容的特征。
5) 格式化字符串函数,Format。
6) 接口实现函数,如String.Iconvertible.ToBoolean() 等。
7) 内部辅助函数。
内存申请函数
FastAllocateString(int iLength) 这个函数不在String.cs中实现,其声明为:
private extern static String FastAllocateString(int length); 具体实现不详(为此苦恼了很久……)。但从该函数的声明可以看出,该函数可以在托管堆上申请一片指定长度的内存,并返回一个String类型的引用地址。在String类中,凡是需要申请内存的地方,都是使用这个函数。
构造函数
在8种构造函数中,最基本的构造函数的声明:
[CLSCompliant(false), MethodImplAttribute(MethodImplOptions.InternalCall)]
unsafe public extern String(sbyte *value, int startIndex, int length, Encoding enc);
也就是说,要给定一个指向字符串首地址的指针(sbyte *value),要创建的字符串在给定的字符串中的起始索引位置(int startIndex),字符串长度(int length),以及编码对象(Encoding enc)。
其实,不论那种构造,最终都是调用CreateString静态函数创建的字符串对象。
1 unsafe static private String CreateString(sbyte *value, int startIndex, int length, Encoding enc) {
2 if (enc == null)
3 return new String(value, startIndex, length); // default to ANSI
4 if (length < 0)
5 throw new ArgumentOutOfRangeException("length",Environment.GetResourceString("ArgumentOutOfRange_NeedNonNegNum"));
6 if (startIndex < 0) {
7 throw new ArgumentOutOfRangeException("startIndex",Environment.GetResourceString("ArgumentOutOfRange_StartIndex"));
8 }
9 if ((value + startIndex) < value) {
10 // overflow check
11 throw new ArgumentOutOfRangeException("startIndex",Environment.GetResourceString("ArgumentOutOfRange_PartialWCHAR"));
12 }
13 byte [] b = new byte[length];
14
15 try {
16 Buffer.memcpy((byte*)value, startIndex, b, 0, length);
17 }
18 catch(NullReferenceException) {
19 // If we got a NullReferencException. It means the pointer or
20 // the index is out of range
21 throw new ArgumentOutOfRangeException("value",
22 Environment.GetResourceString("ArgumentOutOfRange_PartialWCHAR"));
23 }
24
25 return enc.GetString(b);
26 }
该函数在进行过参数检查后,首先在堆内存中,按照指定的长度创建了缓冲区b,然后使用System.Buffer的内存拷贝函数memcpy将指定的字符串内容拷贝到缓冲区b,最后调用编码对象Encoding的GetString方法,得到指定编码方式下的,经过正确编码的字符串。2 if (enc == null)
3 return new String(value, startIndex, length); // default to ANSI
4 if (length < 0)
5 throw new ArgumentOutOfRangeException("length",Environment.GetResourceString("ArgumentOutOfRange_NeedNonNegNum"));
6 if (startIndex < 0) {
7 throw new ArgumentOutOfRangeException("startIndex",Environment.GetResourceString("ArgumentOutOfRange_StartIndex"));
8 }
9 if ((value + startIndex) < value) {
10 // overflow check
11 throw new ArgumentOutOfRangeException("startIndex",Environment.GetResourceString("ArgumentOutOfRange_PartialWCHAR"));
12 }
13 byte [] b = new byte[length];
14
15 try {
16 Buffer.memcpy((byte*)value, startIndex, b, 0, length);
17 }
18 catch(NullReferenceException) {
19 // If we got a NullReferencException. It means the pointer or
20 // the index is out of range
21 throw new ArgumentOutOfRangeException("value",
22 Environment.GetResourceString("ArgumentOutOfRange_PartialWCHAR"));
23 }
24
25 return enc.GetString(b);
26 }
字符串操作函数
这部分包含的函数比较多,着重的说几个。Join函数,这个函数是用来进行字符串合并的函数。从这个函数里面,我们仍然可以看到字符串“不可变”的一面。代码如下:
public unsafe static String Join(String separator, String[] value, int startIndex, int count)
该函数,要求有4个参数。分隔符(String separator),要合并的小字符串组成的数组(String[] value),合并时数组的起始位置索引(int startIndex),要合并的小字符串数量(int count)。和其他函数一样,首先要进行必要的参数检查。然后,根据参数,计算出未来合并出来的大字符串的总长度:
int jointLength = 0;
//Figure out the total length of the strings in value
int endIndex = startIndex + count - 1;
for (int stringToJoinIndex = startIndex; stringToJoinIndex <= endIndex; stringToJoinIndex++) {
if (value[stringToJoinIndex] != null) {
jointLength += value[stringToJoinIndex].Length;
}
}
//Add enough room for the separator.
jointLength += (count - 1) * separator.Length;
再然后,就是重新申请一个刚刚计算好长度的空间,用来存放新创建的字符串:
string jointString = FastAllocateString( jointLength );
看到了吧?新字符串不是以第一个小字符串,也不是以最后一个小字符串为基础,叠加出来的。而是在内存中重新申请了空间。换句话说,Join函数没有“改变”任何一个小字符串。证明了字符串声明了之后是“不可变的”。紧接着53-61行代码,就是通过指针把每个小字符串中的数据放到新创建的大缓冲区中。最后,返回这个新创建的String对象。
再说说Concat方法。这个方法我们经常使用的是它的另一种形式,那就是我们经常会写这样的代码 string s = s1 + s2 + s3;编译器在编译这行代码时,就会将多个字符串相加变为Concat函数的调用:Concat(s1,s2,s3);
public static String Concat(params Object[] args)
这个Concat函数使用了一个可以支持可变参数的关键字params,代码的7-18行仍然是要计算新字符串的长度。第19行,调用了ConcatArray函数进行实质上的连接。private static String ConcatArray(String[] values, int totalLength)
代码的第二行,就调用FastAllocateString方法申请了新字符串的空间,而后将字符串数组中的内容放入新的空间内。字符串特征函数
IsNormalized 方法是用来表明当前字符串是否是被指定的规格规范化了,用以表明当前字符串是以何种编码格式存储的。至于IsAscii和IsFastSort都是外部实现的,无法看到代码。
格式化字符串函数
public static String Format( IFormatProvider provider, String format, params Object[] args)
Format函数有多种重载,最基本的形式就是上面这样。参数有三:Format提供者(IFormatProvider provider), 格式字符串(String format)以及参数列表(params Object[] args)
代码中,在进行了参数检查后,创建了StringBuilder对象,然后调用了StringBuilder的AppendFormat方法完成了实际参数的填充动作。
public StringBuilder AppendFormat(IFormatProvider provider, String format, params Object[] args)
代码比较多,就不一行行的细说了。基本上就是把格式字符串扫一遍,找出"{数字}"的部分,并用参数值对内容进行替换。高深的算法基本上是一点儿也没有。
接口实现函数
其实主要是IConvertible接口的函数程序员用的最多。通过这个接口的方法,可以把字符串转换成绝大多数需要的类型。在String类中,对IConvetible接口方法的实现,主要是通过System.Convert类的相关方法实现的。Convert类暂不在本文讨论范围内。
内部辅助函数
String类的内部函数有很多,比如TrimHelper,wstrcpy,wcslen等。顾名思义,这几个函数的作用就不必细说了。值得一提的是wcslen函数的算法,比较精巧,贴出来大家看看:
private static unsafe int wcslen(char *ptr)
String类的内存布局
下图是String类在内存中的布局:
在内存中,位于字符串内容之前还存放有三个变量,分别是m_arrayLength,m_stringLength,m_firstChar m_arrayLength并没有太多的用处,主要是在调试时断言输出中,用来表示当前字符串的长度;m_stringLength主要是用来表示字符串长度,在多个函数中参与运算。m_firstChar 用来保存当前字符串中第一个字符。
这种布局与以前CComBSTR的情况类似,以极小的空间代价保存了字符串的长度,提高字符串操作的性能。因为字符串长度这个变量,在很多的字符串操作运算中都起着很重要的作用,如果每次都用指针数到'\0',那么性能会下降一大块。