字符串一种在程序中经常要使用到的数据结构,然而在C中却没有字符串这种类型。在C++中,为了方便字符串的使用,在STL中提供了一个string类。该类维护一个char指针,并封装和提供各种的字符串操作。
  一、为什么要实现隐式公享写时拷贝
  试想一下,如果我们要自己实现一个string类,简单的方式是什么?是让每一个string类的实例维护一个在内存中独立的字符数组,每个string对象各不相干。这样一个对象的任何变化都不会影响到其他的对象。这样做的好处是处理简单,不易出错,但是这样做的缺点却是内存的使用量、程序的效率也低。
  例如,对于如下的例子:
  int swap(string &x, string &y)
  {
  string tmp(x);
  x = y;
  y = tmp;
  }
  在上面的代码中,我们需要做的事情非常简单,只是想交换一下两个string对象的值而已。然而,如果我们采用上面所说的实现方式,一共在内存上进行了三次的用new来创建一个新的数组并复制数据,同时还会调用两次的delete[]。我们花了这么大的力气才完成了这样一个简单的动作。而隐式共享写时复制的内存管理策略却可以解决这样的问题。虽然C++11用&&运算符解决了上述问题,但是在程序中使用大量的string的副本,而不改变其值的情况还是不少的。例如string数组,删除一个元素后的移动操作。
  二、隐式共享写时复制的实现思想
  什么是隐式共享写时拷贝呢?是当用一个string对象初始化另一个string对象或把一个string对象赋值给另一个string对象时,它们内部维护的指针其实指向了内存中的同一个字符数组,这是隐式共享。
  使用这种方法,上述的代码不需要调用new来创建数组也不需要复制,也不会调用delete[](如果不是很明白也不要紧,看完实现代码和后自然明白了)。然后两个指针指向同一个对象很容易引发错误,当其中一个对象执行析构函数释放掉其内部指针指向的内存时,另一个对象却对此完全不知情,可能会引用一个不存在的内存,从而让程序崩溃。所以为了方便资源的管理,我们引用智能指针的思想,为每个内存中的字符数组(用new创建,存在于堆中)添加一个引用计数used,表示有多少个对象引用这个块内存(即字符数组)。当一个对象析构时,它会把引用计数used减1,当used为0时,表示没有对象引用这块内存,从而把这块内存释放掉。当然由于used也要在对象中共享,所以它也是一个堆中的数据,每个对象有一个指向它的指针。
  而当一个string对象需要改变它的值时,例如
  string s1("abc");
  string s2(s1);
  string s3("edf");
  s2 += s3;
  此时,s1和s2指向了堆内存中的同一个字符数组,而当s2的值要改变时,因为如果直接在其指向的内存中修改,则会影响到对象s1,所以为了让s2的操作不影响到s1,s2会在重新new出一块内存,然后先把之前所引用的字符数组的数据复制到新的字符数组中,然后再把s3中的字符数据复制到新的字符数组中。这是写时拷贝。注意,同时还要把之前指向的内存的引用计数减1(因为它指向了新的堆中的字符数组),并在堆中重新new一个块内存,用于保存新的引用计数,同时把新的字符数组的引用计数置为1。因为此时只有一个对象(是改变值的对象)在使用这个内存。
  三、代码实现及设计要点详解
  说了这么多,还是来看看代码的实现吧,为了与标准C++的string类区别开来,这样采用第一个字母大写来表示自定义的字符串类String。
  源代码可以点击下面的连接下载:
  http://download.csdn.net/detail/ljianhui/7143351
  其头文件_stringv2.h如下:
#ifndef _STRINGV2_H_INCLUDED
#define _STRINGV2_H_INCLUDED
/***
String类的部分实现,采用的内存管理策略是:隐式共享,写时复制
实现方法:与智能指针的实现类似
***/
class String
{
public:
String();
String(const String& s);
String(const char *pc, size_t len);
String(const char *pc);
~String();
String& operator=(const String &s);
String& operator=(const char *s);
String& operator+=(const String &rhs);
String& operator+=(const char *rhs);
void clear();
size_t getLength()const {return _length;}
const char* cstr()const {return _cstr;}
private://function
void _initString(const char *cstr, size_t len);
void _decUsed();
char* _renewAndCat(const char *cstr, size_t len);
void _addString(const char *cstr, size_t len);
void _addAssignOpt(const char *cstr, size_t len);
private://data
char *_cstr;
size_t *_used;
size_t _length;
size_t _capacity;
};
String operator+(const String &lhs, const String &rhs);
std::ostream& operator <<(std::ostream &os, const String &s);
std::istream& operator >>(std::istream &in, String &s);
#endif // _STRINGV2_H_INCLUDED
  从上面的String的数据成员,我们可以看到String在其内部维护一个指向堆内存的字符数组的char指针_cstr和一个指向堆内存中字符数组的引用计数的size_t指针_used。本类并没有实现String的所有操作,只是实现了大部分的初始化和String跟写操作有关的函数。
  注意:为了说明的方便,我会使用s._cstr等方式来指明一个成员变量所属的对象,或使用*s._cstr等方式来引用一个对象的指针成员所指的内存。但这并不是说在类的外面访问成员变量,只是为了说明的方便和清晰而已。为了方便代码的阅读,类的成员变量或私有函数都以下划线“_”开头。
  下面来一个函数一个函数地解释其实现方式。
  1)默认构造函数
  String::String():
  _cstr(NULL),
  _used(new size_t(1)),
  _length(0),
  _capacity(0)
  {
  }
  这里需要注意的地方是,在默认初始化中,我们并不使用new来申请内存,而是直接把_cstr置为NULL,这样做是因为我们不知道程序接下来会做什么动作,贸然为其分配内存是不合理的。例如,对于如下操作,则无需分配内存,
  String s1("abc");
  String s2;
  s2 = s1;
  根据隐式共享的原则,只需要把s2._cstr的值赋为s1._cstr即可。而为什么没有为对象分配内存,而*_used的值却为1呢?这里只要是为了操作的统一,考虑上面的语句s2 = s1,其产生的操作应该是把s2._used所指向的内存数据(引用计数)的值减1,因为s2._cstr不再指向原先的字符数据。s1._used所指向的内存数据的值加1。若*s2._userd的值为0,释放s2._userd和s2._cstr所指向的内存。而如果在这里,*s2._userd的初始值为0,0减1会变成-1,而_userd是一个无符号整数的指针,它的值会变成2^32-1,从而让程序运行的结果不符合我们的预想。而*s2._userd的初始值为1则可完美地避免这个问题。
  2)复制构造函数
  String::String(const String &s):
  _cstr(s._cstr),
  _used(s._used),
  _length(s._length),
  _capacity(s._capacity)
  {
  ++*_used;
  }
  本函数非常易懂,是把s的成员的值全部复制给*this即可,但是由于多了*this这个对象引用s的字符数组,所以应该把该字符数组的引用计数加1。注意,此时this->_used和s._used指向了同一个对象。
  3)带C字串参数的构造函数
String::String(const char *cstr, size_t len)
{
if(cstr == NULL)
return;
size_t str_len = strlen(cstr);
if(len <= str_len)
{
_initString(cstr, len);
}
}
void String::_initString(const char *cstr, size_t len)
{
if(cstr == NULL)
return;
_cstr = new char[len + 1];
memcpy(_cstr, cstr, len);
_cstr[len] = 0;
_used = new size_t(1);
_length = len;
_capacity = len;
}
  该函数非常简单,由于是构造函数,而且使用的参数是C风格的字符串,所以默认为其字符串一定不是某个对象所引用的字符数组,所以直接为其分配内存,并复制字符。非常明显,因为是该对象第一个创建该字符数组的,所以其引用为1.
  4)带C风格字符串的构造函数
  String::String(const char *cstr)
  {
  if(cstr == NULL)
  return;
  size_t len = strlen(cstr);
  _initString(cstr, len);
  }
  其实现与上原理相同,只是参数不同,不再详述。
  5)析构函数
String::~String()
{
_decUsed();
}
void String::_decUsed()
{
--*_used;
if(*_used == 0)
{
if(_cstr != NULL)
{
delete[] _cstr;
_cstr = NULL;
_length = 0;
_capacity = 0;
}
delete _used;
_used = NULL;
}
}
  _decUsed()函数可以说是该类内存释放的管理函数,可以看到,每当一个对象被析构时,其指向的堆中的字符数组的引用计数会减1,当引用计数为0时,释放字符数组和引用计数。
  6)赋值操作函数
  String& String::operator=(const String &s)
  {
  ++*(s._used);
  _decUsed();
  _cstr = s._cstr;
  _length = s._length;
  _capacity = s._capacity;
  return *this;
  }
  该赋值操作函数的参数一个本类的对象,该类赋值操作函数第一个要避免的是自身赋值的问题,在有指针存在的类中是特别要重视这个问题,而在这个String类也不可例外。为什么这样说呢?因为我们调用赋值操作函数时,必须要减少左值的引用计数,增加右值的引用计数,这个在第1)点已经说过了,而如果是自身赋值的话,在减少其引用计数时,其引用计数可能为0,从而导致字符数组的释放,从而让_cstr指针悬空(delete[]掉了,却在赋值的过程中,重新赋为delete[]前的值,即_cstr的值没有在赋值过程中改变)。
  一般的程序的做法是判断参数的地址与this是否相等来避免自身赋值,而这里却可以采用巧妙的策略来避免这个问题,可以看到上面的代码并没有if判断语句。我们首先对*s._used加1,这样*s._used至少为2,然后再对*(this->_used)减1,这样即使s与*this是同一个对象,也可以保证*(this->_used)的值至少为1,不会变为0,从而让字符数组不会被释放。因为复制是使用隐式共享的,所以直接复制指针,使指针_cstr其指向与s同一个存在中的字符数组并复制其他的数据成员即可。
  同时,我们还要记得返回当前对象的引用。
  7)重载的赋值操作函数
  String& String::operator=(const char *cstr)
  {
  if(cstr != NULL)
  {
  _decUsed();
  size_t len = strlen(cstr);
  _initString(cstr, len);
  }
  return *this;
  }
  该赋值操作函数的参数一个C风格的字符串,因而不会发生自身赋值的问题。与String(const char *cstr)函数相似,不同的是使用赋值操作函数时,对象已经存在,所以要调用_decUsed来减少该对象的_cstr原先指向的字符数组的引用计数。然后生成根据cstr创建一个全新的字符数组。并返回当前对象的引用。