STL string 类型探秘

一【概述】

在C语言中,我们一般用char数组来定义一个字符串,那么既然是数组我们往往需要提前判断字符串的最大长度,可问题是谁也不知道这最大长度究竟有多长,而且这也是很多编程BUG的根源。那么在C++标准库中,我们可以通过string类型来定义一个字符串,就不必考虑数组长度等这么多底层的东西,只需要考虑业务功能的实现就可以了。

虽然有了现成的string类型,可是string这个类是如何实现的呢?是不是仅仅对一个传统的char数组进行了一个简简单单的类封装呢?这个字符数组的末尾还是以’\0’结尾的吗?它的数组长度是如何定义的?string类型的内存是如何管理的?我们没必要重复发明轮子,但是作为一个职业的代码玩家应该懂得轮子是如何发明的!

这儿以g++4.1.0的标准库的string实现为例。String其实是basic_string<char>,所以本质上是basic_string。basic_string另外一种常用的形式是basic_string<wchar_t>,这个一看就知道是用来处理宽字符串的。那么下面先看下basic_string一般情况下的内存结构的示意图:

 

该图显示出在每个字符串的尾部一般都会有一个'\0'结尾,这个跟传统的字符串是一样的,但是它的头部会有一个Rep对象,该对象的作用在于记录该basic_string对象的描述信息,如:字符串的长度、basic_string对象目前能包容的最大字符串长度等。这儿得分清一个概念就是字符串长度不等于字符串占用的内存大小,因为在宽字符情况下,一个字符的占用空间往往大于1个字节。当然,我们还注意到basic_string分配的内存空间往往要比比实际需要的内存空间大。这个是出于多种原因,如:字节对齐、尽量是虚拟内存页大小的整数倍、在字符串长度增长的时候不必频繁增加内存空间,等等。

二【类设计】

好了,到这儿应该对basic_string以及常用的string有个大概的印象了。那么下面我们在看一下basic_string的具体实现了。如图:

 

basic_string会有3个模板参数_CharT用来定义字符类型的,目前就两种:char和wchar_t。_Traits这个参数封装了所有_CharT类型特性以及相关操作。_Alloc设定了内存配置器的类型,它主要负责顶层的内存分配工作的。_Traits和_Alloc都有默认值,通常情况下,我们不用去管他。再看_M_dataplus成员变量,该变量是_Alloc_hider类型,而真正指向实际字符串的是它的一个字符串指针变量_M_p。每个字符串的尾端都会有一个terminal(结尾标识),一般是’\0’,这样当我们调用c_str()函数的时候它就直接将_M_p指针返回就可以了。还有一个函数data(),网上说它在返回字符串的时候不含有’\0’,不过我从这个版本的源码来看它跟c_str()的功能是完全一样的。

_Rep继承自_Rep_base,该类负责对字符串的内存进行管理,_M_length定义了当前字符串的长度,而_M_capacity定义了当前分配的内存所容纳的最长字符串大小。_M_refcount定义了该字符串被引用的次数(因为为了提高性能, basic_string使用了COPY-ON-WRITE技术,所以一块内存空间可能被多次引用)。而_Rep中的3个变量都是静态变量,它们也定义了字符串类型的一般特性,如:string类型所能定义的最大字符串长度、字符串结尾标识字符、空字符串的内存空间(这也意味着多个空字符串用的其实都是同一分内存拷贝)。

三【内存分配策略】

下面我们关注一下下字符串的内存分配策略。该版本的STL默认通过new_allocator来为string类型分配内存,源码如下:

      const size_type __pagesize = 4096;
      const size_type __malloc_header_size = 4 * sizeof(void*);

      // The below implements an exponential growth policy, necessary to
      // meet amortized linear time requirements of the library: see
      // http://gcc.gnu.org/ml/libstdc++/2001-07/msg00085.html.
      // It's active for allocations requiring an amount of memory above
      // system pagesize. This is consistent with the requirements of the
      // standard: http://gcc.gnu.org/ml/libstdc++/2001-07/msg00130.html
      if (__capacity > __old_capacity && __capacity < 2 * __old_capacity)
	__capacity = 2 * __old_capacity;

      // NB: Need an array of char_type[__capacity], plus a terminating
      // null char_type() element, plus enough for the _Rep data structure.
      // Whew. Seemingly so needy, yet so elemental.
      size_type __size = (__capacity + 1) * sizeof(_CharT) + sizeof(_Rep);

      const size_type __adj_size = __size + __malloc_header_size;
      if (__adj_size > __pagesize && __capacity > __old_capacity)
	{
	  const size_type __extra = __pagesize - __adj_size % __pagesize;
	  __capacity += __extra / sizeof(_CharT);
	  // Never allocate a string bigger than _S_max_size.
	  if (__capacity > _S_max_size)
	    __capacity = _S_max_size;
	  __size = (__capacity + 1) * sizeof(_CharT) + sizeof(_Rep);
	}

 

假设原有字符串长度为__old_capacity,那么当字符串的长度__capacity > __old_capacity同时__capacity < 2*__old_capacity时,请求字符串的长度将被调整为__capacity = 2*__old_capacity。这也是为了当字符串长度不断增长时,避免因频繁的分配内存空间而导致性能下降。那么,这时为string类型分配的内存空间大小为:长度为__capacity的字符串空间+结尾标识字符+_Rep对象空间大小。最后,basic_string还会考虑以虚拟页大小为模向上取整(这个页大小不一定是实际的页大小,默认一般是4096)。

在确定了大小之后,我们就通过operator new来分配内存空间,接着通过placement new来初始化该内存块。最后再设置下_Rep对象中的成员变量(主要是_M_length_M_refcount)和字符串结尾标识。这样,字符串的内存构造就算结束了。

另外,由于basic_string采用了写时拷贝技术(COPY-ON-WRITE),所以有时会等到真正需要的时候才会去分配内存。比如:

 

string str1 = "Apple";
string str2 = str1;\\str2 与 str1共用同一份内存拷贝
str2 = "Orange"; \\str2 与 str 1各自使用不同的内存拷贝


str2在初始化时直接就与str1共用同一份内存拷贝,可是当再次给str2赋另外一个值的时候,string会为str2分配一块独立的内存空间。这样做确实提高了程序的性能,避免了某些情况下无谓的性能开销,可是在多线程运行的情况下,这样做也有可能带来string对象的读写同步问题。

四【结束语】

唉,STL真是考虑全面啊,搞个字符串都能整这么多东西出来,很多细节性的东西都想到了,不过越是复杂的东西越是不可控,学习成本就越高,难怪Linus会说:C++ is a horrible language

另外:侯捷的《STL源码分析》一书中对traitsallocator都有详细的论述,不过在STL源代码中我没有找到该书所讲的那个默认配置器。根据书中描述该默认配置器当请求的内存大小大于128字节时通过new分配,而当小于该值时,又会通过内存池分配。这个,额,应该是新版本里面给去掉了吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章