SGI STL中string的源码解读(1)

来源:互联网 发布:中银淘宝校园卡 申请 编辑:程序博客网 时间:2024/06/07 01:00

STL中string的源码解读

 

Ryan peng

cutezero@163.com

Sunday, June 03, 2007

 

这是个人最近比较闲暇之余,对SGI STLstring分析,如果有任何理解错误,请和我联系,谢谢!

 

为什么要分析string呢?我们知道大多数的编译器实现的string都各不相同(即便是同一个编译厂商在不同的版本string的实现也不一样,例如MSVC6.0和VS2005中string的实现就不一样,VC6.0中string的实现是用copy_on_write[COW,写时拷贝],VS2005则是直接进行深拷贝)。

以前各个编译器的厂商对String的实现都是使用COW,但目前string的实现趋势是直接使用深拷贝,不再使用COW,其主要原因是目前多线程的使用越来越多,COW技术在多线程会带来额外的性能恶化(原因在于COW在成员函数内部加/解锁),但是不可否认的是,掌握这些技术仍然很有吸引力,也许会在其他地方使用到。

以SGI STL版本3.4.2为主,阅读string的源代码。本文主要分以下几个部分:

Ø         原子操作的作用和实现;

Ø         STL中的concepts;

Ø         String概述;

Ø         实现计数的结构体Rep_Base和Rep;

«        Rep_Base的定义;

«        Rep的定义;

«        Rep中的几个主要函数;

Ø         Basic_string的构造函数和析构函数;

Ø         赋值构造函数operator=;

Ø         replace函数;

Ø         insert和erase函数;

Ø         swap函数;

Ø         Operator[]函数;

Ø         Reverse和resize函数;

Ø         Swap函数;

Ø         Append和operator+函数;

Ø         其他函数;

Ø         调试版本的string。

最初为了描述的方便,把一些函数/变量进行了修改或者删减,后来发现太麻烦了,就偷懒了,可能导致前后文中的变量和函数名不一致。最开始的时候都是是使用字符串来表示,后来发现可能引起大家的误解,因此后来的描述区分的比较清楚分别使用string对象/string,或者c_style的字符串,但是前面写的可能需要根据上下文自己判断了,不好意思了。

1.原子操作的作用和实现

string中真正存储的字符串使用COW,也就是说当两个字符串完全相同时可能(不是一定,取决于编译器实现string赋值和copy构造的方法)指向的是同一块内存,当其中一个string对象被修改时,才为这个string对象建立真正的copy(即分配内存,并初始化对象),很明显在单线程情况下,该方法很有效,因为分配内存很耗时间,而且也可能节约内存。COW用的非常广泛,如Linux下的fork()函数在创建新进程的时候也是使用COW,让子进程和父进程共享同一进程控制块。

在string中为了实现COW,必须记录有多少个对象指向真实的字符串(实际的存储体在下面会看到对应的数据结构),才能正确的操作string对象。为了正确的实现提供原子操作(即要么操作成功,要么什么也不做),如修改这个记录的时候需要原子操作,调用一些成员函数的时候需要原子操作。

一般来说原子操作都是操作系统提供的,当然也可以直接通过汇编代码控制CPU来完成。操作系统一般都会提供mutex,atom,lock等操作,但是不同的操作系统提供的接口不同,也就需要对这些接口进行封装。这些内容一般在GCC的头文件atomicity.h找到。

在此只简单的演示两种方法,看看是怎么实现的,如下:一个例子(Redhat中的实现)完成引用计数值修改的原子操作,

__exchange_and_add(volatile _Atomic_word* __mem, int __val)

{

        __glibcxx_mutex_lock(__gnu_internal::atomic_mutex);

        Int __result;

        __result = *__mem;

        *__mem += __val;

        __glibcxx_mutex_unlock(__gnu_internal::atomic_mutex);

        return __result;

}

其中__glibcxx_mutex_lock和__glibcxx_mutex_unlock都是封装好的操作,保证原子操作(如调用操作系统的函数pthread_mutex_lock, pthread_mutex_unlock, pthread_mutex_trylock)。

如果没有定义上面的函数(准确的说是通过宏调用操作系统的函数),也可以直接通过汇编代码完成,如下所示:

__exchange_and_add (volatile _Atomic_word *__mem, int __val)

{

register int __result;

__asm__ __volatile__ ("lock; xaddl %0,%2"

                                : "=r" (__result)

: "0" (__val), "m" (*__mem)

: "memory");

return __result;

}

关于C/C++语言中嵌套ASM汇编有很多资料可以参考,这里简单分析一下上面的代码:

lock;

//lock;汇编指令前缀,表示后面的指令在CPU上操作是串行完成;当CPU中的控制器检测到这个前缀时候,就会锁定内存总线,一直到该条指令执行完毕,在此期间其它的CPU不能访问这条指令所访问的内存单元。

xaddl %0,%2"

//完成加法,其中0表示result,2表示*__mem

: "=r" (__result)

//表示输出

: "0" (__val), "m" (*__mem)

//表示输入,__val和__result使用同一个寄存器,*__mem表示在内存中;

    : "memory")

    //表示限定约束,memory中的内容被修改

值得注意的是不同类型的CPU对应的汇编也不同,上面的汇编是对应i386结构的。

为了能更好的说明这一部分,再举一个简单的例子,这个是针对m68000的实现,利用了C++语言的性质,如下:

template<int __inst>

struct _Atomicity_lock

{

static volatile unsigned char _S_atomicity_lock;

};

template<int __inst>

volatile unsigned char _Atomicity_lock<__inst>::_S_atomicity_lock = 0;

 

__exchange_and_add(volatile _Atomic_word* __mem, int __val)

{

    int __result;

// Use bset with immediate addressing for 68000/68010 (not SMP-safe)

__asm__ __volatile__("1: bset.b #7,%0/n/tjbne 1b"

                       : "+m"(_Atomicity_lock<0>::_S_atomicity_lock)

                       : /* none */

                       : "cc");

__result = *__mem;

__mem = __result + __val;

_Atomicity_lock<0>::_S_atomicity_lock = 0;

return __result;

}

基本思路也是锁总线,只不过实现方式不同罢了。

在__exchange_and_add函数前面还要修饰符__attribute__((unused)),这是GCC的扩展,表示该函数或变量可能不使用,这个属性可以避免编译器产生警告信息。

2. STL中的concepts

在众多的STL的实现中SGI STL的现实相对来说非常好的,在string中也使用到了concepts的概念,首先介绍一下concepts。

概念(Concepts)简单的说是用于模板参数的类型系统,对模板的参数进行约束。模板因为其独特的性质,只有在实例点的时候才会真正的生成代码,那么也就说按照以前我们所写的代码,即便是模板参数有错误,在编译时候我们也不能得到错误,这当然和我们的期望相违背,为了解决这个问题有一些方法如boost库中使用的约束类,而约束类也存在一些缺点,引入concepts能够解决很多问题。

下面就简单的看一下SGI STL中的concepts。

例如我们在assign函数中看到这样的代码__glibcxx_requires_string(__s);这些其实是一些宏,和我们原来的方法一样,它的对应展开就是assert(__s),还不能说这是concepts。但是下面的例子就是concepts了。

template<typename _Tp>

inline void swap(_Tp& __a, _Tp& __b)

{

// concept requirements

__glibcxx_function_requires(_SGIAssignableConcept<_Tp>)

 

const _Tp __tmp = __a;

__a = __b;

__b = __tmp;

}

这是STL中swap算法,交换两个变量,我们知道两个变量能够交换的条件就是他们具有可赋值性,也就是说我们期望在编译的时候判断模板参数是否具有可赋值性,为此上面的范型算法就加入了concepts,对模板参数进行判断。Concepts将会通过下面的宏展开:

#define __glibcxx_function_requires(...) /

__gnu_cxx::__function_requires< __gnu_cxx::__VA_ARGS__ >();

 

template <class _Concept>

inline void __function_requires()

{

void (_Concept::*__x)() _IsUnused = &_Concept::__constraints;

}

其中参数为:

template <class _Tp>

struct _SGIAssignableConcept

{

void __constraints()

{

_Tp __b _IsUnused(__a);

__a = __a;// require assignment operator

__const_constraints(__a);

}

 

void __const_constraints(const _Tp& __b)

{

_Tp __c _IsUnused(__b);

__a = __b;// const required for argument to assignment

}

_Tp __a;

};

可以看出实际上最后执行的是_SGIAssignableConcept::__constraints()。

最后要注意一点,STL中的关于concepts不一定打开,如果你要使用concepts应该自己打开编译开关。

3. string概述

很多资料都告诉了这样的事实string其实就是使用typedef对下面模版类的别名定义。即basic_string<char, char_traits<char>, allocator<char> > class;typedef basic_string string;其中char_traits<char>也是模版类,主要定义了几种类型和基本的操作。Allocator<char>也是模版类,主要是进行内存管理。当然上面的char也有可能是w_chart,当且仅当我们定义使用宽字符(是通过宏变量来控制)。

basic_string中的模板参数Char_traits<class type>定义的类型主要有char_type(表明类型),int_type(就是int类型,定义该类型的目的是type可能和int发生类型转换),pos_type(表明位置信息),off_type(表明结束信息)和state_type(表明目前的状态,其实就是int),基本的操作主要有assign,copy,find,move,eq(等于),lt(小于)。

对于basic_string中的模板参数allocator<>的分析在《STL源码剖析》中已经分析的较为详细,就是进行内存管理。

在模版类basic_string中定义了typedef __gnu_cxx::__normal_iterator<pointer, basic_string> iterator;首先请问你是觉得这样的定义如果不是在模版中正常吗?你会不会觉得basic_string的定义还没有完成,怎么可以看成是一个完整的类型作为参数传递呢?噢,这个应该不是问题,为什么?因为我们只是定义一个类型,并没有定义任何变量,当然不用内存分配,编译器当然会饶过他继续编译不会报错。其实内部仍然是使用Pointer直接作为它的迭代器,只不过对Pointer进行了封装,形成类(重载了++,--,*,->,[],&,+等操作符)。你会不会觉得这很麻烦,确实是,没有提供比原始指针更强大的功能,但也要想想为什么这么设计,原因就是一个简单的Pointer不能提供一些类型,如value_type等等(即traits),没有办法必须封装。

4. 实现计数的结构体Rep_Base和Rep

在模板类basic_string中嵌套定义了这两个结构体,Rep_base在该结构体中主要进行引用计数的定义和Rep继承于Rep_base主要是进行引用计数的相关操作和内存的分配策略,因此这两个结构体是非常关键的。

结构体_Rep_base的定义如下:

Struct _Rep_base

{

    Size_type  _M_length;

    Size_type  _M_capaticty;

    Int        _M_refCount;

};

代码中有这样的解释:

Ø         字符串真正存储的是原字符串加上一个NULL,故真正的长度是_M_length+1;

Ø         _M_Capacity一定不小于_M_length,而且内存的分配的增长总是以当前_M_capacity+1为单位;

Ø         _M_refCount的取值可以分为三种:

«        -1:可能内存泄露,有一个变量指向字符串,字符串可以被更改,不允许引用copy,也就是当出现这种情况时,这个string对象不会再和其他string对象共享了;

«        0: 有一个变量指向字符串,字符串可以被更改;

«        n>=1:有n+1个变量指向字符串,对字符串操作时应该加锁,字符串可以被更改;

Ø         当_M_length,_M_capactiy和_M_refCount均为零,表示空串。

_Rep的定义

_Rep继承于_Rep_base,同时_Rep中还定义了三个静态数据成员,这些数据成员都有独特的意思。size_type    _S_max_size和_CharT  _S_terminal分表表示字符的最大长度和字符串的结束标志(即是’/0’也就是0)。_S_max_size这个值表示可以最大分配的内存,这个值表示使用1G内存分配字符串,_S_max_size = (((npos -sizeof(_Rep_base))/sizeof(_CharT)) - 1) / 4;,其中npos是定义在模版类basic_string中,初始值为-1(也即0xFFFFFFFF)。

定义一个数组size_type _S_empty_rep_storage[];(这并不是一个0长度的数组,0长度的数组是在编译时并不分配空间,仅仅作为占位符),在对应的定义文件(basic_string.tcc)中有明确的定义,如下_S_empty_rep_storage[ (sizeof(_Rep_base) + sizeof(_CharT) + sizeof(size_type) - 1) /sizeof(size_type)];

最后要注意一下静态对象初始化的时机,静态对象一般是在.ini段中完成初始化,即在main函数之前代码段中完成,一般使用缺省的构造函数完成,象上面的数组中的元素会被初始化为0(该数组初始化的结果可表示空串有1个引用)。

_Rep中的几个主要函数

1. _S_Create分配字符串占用的内存空间

_S_create(size_type __capacity, size_type __old_capacity,

                const _Alloc& __alloc)

{

const size_type __pagesize = 4096; // must be 2^i * __subpagesize

const size_type __subpagesize = 128;

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

 

// The biggest string which fits in a memory page

const size_type __page_capacity = ((__pagesize - __malloc_header_size - sizeof(_Rep) - sizeof(_CharT)) /sizeof(_CharT));

 

//capacity使用指数增长的方法

if (__capacity > __old_capacity && __capacity < 2 * __old_capacity && __capacity > __page_capacity)

__capacity = 2 * __old_capacity;

 

size_type __size = (__capacity + 1) * sizeof(_CharT) +sizeof(_Rep); //加1的原因是在字符串最后添加一个’/0’

 

//根据_M_capacity调整size(真正需要new/malloc的内存大小)

const size_type __adj_size = __size + __malloc_header_size;

if (__adj_size > __pagesize)

{

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);

}

else if (__size > __subpagesize)

{

const size_type __extra = __subpagesize - __adj_size % __subpagesize;

__capacity += __extra / sizeof(_CharT);

__size = (__capacity + 1) * sizeof(_CharT) + sizeof(_Rep);

}

 

//注意这里是分配大小为size的内存,不是上面的adjsize,因为mallocheadersize是在new/malloc系统增加的

void* __place = _Raw_bytes_alloc(__alloc).allocate(__size);

_Rep *__p = new (__place) _Rep;

__p->_M_capacity = __capacity;

__p->_M_set_sharable();  // One reference.,设置共享标志

 __p->_M_length = 0;

return __p;

}

上面的函数中几个常量的含义是理解函数的关键,弄清楚这几个常量这个函数的实现也就明白,这几个变量的作用如下:

1.    __pageSize的大小是指在分配内存的时候使用的,很类似于实际中virtual memory(但是和实际中virtual memory的大小无关),__pageSize是每次内存分配的最小单位;

2.    __subPageSize的大小是每次分配的字符串必须以__subPageSize对齐,显然这可以加快分配速度,不必要每次都对齐,但是显然可能浪费了空间;

3.    __mallocHeaderSize的意思是这样的,我们在malloc内存的时候,每次调用new/malloc都会比真正的所需要的内存大上几个字节(一般来说是4个字节),这几个字节是存储的是分配内存的真正的长度。【源码中的注释说这个可以为N ×sizeof(void*)(其中N=0,2,4),并且写到,据说N大比小好,所以取4,实际(vc/dev)中new/malloc附加的空间都是4个字节而已,即1×sizeof(void*)】;

4.    字符串的存储示意图如下: 

_Rep             string

__P

2. _M_refdata返回字符串的内存位置

_CharT* _M_refdata() throw()

{

return reinterpret_cast<_CharT*>(this + 1);

}

这个函数非常简单,只要注意一点,那就是this + 1真正的位置。This指的是从头开始的内存地址,this + 1就是上图中的__P所指的位置(1就是sizeof(_Rep))。

3. _M_colne创建新的字符串空间和信息

_M_clone(const _Alloc& __alloc, size_type __res)

{

// Requested capacity of the clone.

const size_type __requested_cap = this->_M_length + __res;

//分配空间

_Rep* __r = _Rep::_S_create(__requested_cap, this->_M_capacity,

__alloc);

    //copy对应的字符串

if (this->_M_length)

traits_type::copy(__r->_M_refdata(),_M_refdata(),this->_M_length);

    //设置字符串的长度和结束标志

__r->_M_length = this->_M_length;

__r->_M_refdata()[this->_M_length] = _Rep::_S_terminal;

return __r->_M_refdata();

}

4. _M_refdata仅仅增加计数信息

_CharT*    _M_refcopy() throw()

{

    if(__builtin_expect(this != &_S_empty_rep(), false))

__gnu_cxx::__atomic_add(&this->_M_refcount, 1);

    return _M_refdata();

}

__builtin_expect(x,expected_value)是GCC提供的实现的一个内部函数,其值就是x,但x的值等于expected_value的可能较大,这可以让gcc产生较好的跳转代码。这只是一种优化写法。

If判断完成的就是,this不是空串,则为真,执行原子操作,为计数值加1.

5. _M_grab是clonerefdata的入口判断

_CharT*    _M_grab(const _Alloc& __alloc1, const _Alloc& __alloc2)

{

    return (!_M_is_leaked() && __alloc1 == __alloc2)

               ? _M_refcopy() : _M_clone(__alloc1);

}

在这个函数中将判断是进行引用计数加1还是重新建立一个新的字符串。必须说明的该函数只有才basic_string的copy ctor和assignment(赋值指的是相同类型的赋值,当有str =“123”,这将是调用构造函数,即便是有很多的这样的语句也不会调用引用计数的)中才可能被调用,也就是说在有在新的字符串按copy或者赋值创建的时候才考虑使用引用计数。

进行refcopy或者clone的关键标识是:首先没有内存泄漏标志(关于这个标志主要是禁止string再次被共享,后面会有具体的描述),然后就是两个string对象的分配相同。

6. _M_destroy释放空间

_M_destroy(const _Alloc& __a) throw ()

{

//如果不是空串,将释放空间

if (this == &_S_empty_rep())    return;

//调整释放空间的大小,这才是真正需要释放的大小

const size_type __size = sizeof(_Rep_base) + (this->_M_capacity + 1) * sizeof(_CharT);

//_Raw_bytes_alloc是allocator类型

_Raw_bytes_alloc(__a).deallocate(reinterpret_cast<char*>(this), __size);

}

7. _M_dispose减少引用计数值并决定释放空间

void   _M_dispose(const _Alloc& __a)

{

    if (__builtin_expect(this != &_S_empty_rep(), false))

       if (__gnu_cxx::__exchange_and_add(&this->_M_refcount, -1) <=0)

           _M_destroy(__a);

}

当引用计数值小于等于0的时候,已经表示没有字符串指向这块内存,需要释放。注意这个地方的等于0也释放内存的,和我们最初所说的0表示一个引用有矛盾的。但是注意这里是完全正确的,__exchange_and_add()函数返回的是没有修改前的值,因此返回值为0其实真实的refcount已经为-1了。

Rep中其他简单的函数如设置lengthcapacityrefcount等都比较简单。 
原创粉丝点击