学习C++,特别是C++中的STL部分,重点不是学习如何去使用STL,而是知道其底层原理是怎么样的,是怎么去实现的。因此,本篇文章带来的是对C++中的string的模拟实现。废话不多说,让我们去了解string是如何实现的吧!
一.模拟实现构造函数
对于构造函数,在官方库中,C99有下面种类:
我们主要实现的是
string();
string(const char* s);
string(const string& str);
前两个称为构造函数,第三个称为拷贝构造
①无参构造函数string();
//无参构造string() { _str=newchar[1]; _str[0] ='\0'; _size=_capacity=0; }
解析:由于是无参构造,在创建string对象时,字符串是空的,需要一个'\0',所以_str开辟一个字符的空间,用来存放'\0',然后将字符串有效个数和字符串空间赋值为0.
②带参构造函数string(const char* str);
//带参构造string(constchar*str) { _size=strlen(str); _capacity=strlen(str); _str=newchar[_capacity+1]; strcpy(_str, str); }
解析:先对_size和_capacity进行初始化,而且_capacity没有额外+1,而是在_str开辟空间的时候才加1,加1的目的是放'\0',而先不加1的原因是在后面实现插入数据的时候,我们需要用到if(_size==_capacity)这个条件来判断容量是否满了,需要扩容,如果先加1,就不好操作了。最后将str的内容拷贝到_str中去。
温馨提示:带参构造函数没有使用初始化列表,原因有其下:如果使用初始化列表,在初始化_str,_size和_capacity的时候,有以下方法:
方法①:
string(const char* str)
:_str(str)//不能这样初始化:因为这样给,给的常量字符串,常量字符串存在常量区,常量区不能被修改
{
//......
}
方法②:
//将上面写法改成下面这种,使用new来开辟空间,空间的大小为str的大小+1
string(const char* str)
:_str(new char[strlen(str)+1])//+1存放'\0',strlen不包含'\0'
,_size(strlen(str))
,_capacity(strlen(str))
{
}
//但我们发现,如果使用上面的方法,我们需要使用3次strlen,这样的方法不好
//其实我们可以不使用初始化列表,这样就能只是用一次strlen
对于带参构造函数,上面那种方法还不够好,因为我们还可以使用缺省参数!
看下面的实现代码:
string(constchar*str="") { _size=strlen(str);//0_capacity=_size;//0_str=newchar[_capacity+1];//0+1=1strcpy(_str, str); }
这里的缺省值为什么是""?
解析:""代表的是空字符,里面就隐藏着一个'\0',由于strlen是以第一个'\0'为终点的,所以当我们给的缺省值是""的时候,strlen计算出来的长度是0。到这里,肯定会有人想,为什么不直接给nullptr或'\0'? 因为nullptr给了str后,str为空指针,strlen不能在空指针上使用,就会报错。而给'\0','\0'是一个字符,其ascll码值为0,直接给指针类型的变量,也相当于是空指针了。所以着两种都不可以。如果给"\0",这个是可以的,这个跟""类似,""是带一个'\0',而"\0"是字符串,里面有两个"\0",计算出来的长度都为0.
③拷贝构造函数
拷贝构造函数,在C++中,有两种版本的写法,称为传统版本和现代版本。
先来看传统版本的:
//拷贝构造的传统写法string(conststring&s) { _str=newchar[s._capacity+1]; _capacity=s.capacity(); _size=s._size; strcpy(_str, s._str); }
解析:拷贝构造跟构造函数的实现方法差不多,区别就在于拷贝构造是将参数s的属性内容全部拷贝到this中,所谓this,就是调用拷贝构造的string类对象的指针。
对于拷贝构造函数,最重点的是深拷贝和浅拷贝!
这里重点解析一下深拷贝和浅拷贝。
浅拷贝:说实在的,浅拷贝虽然带着个拷贝,但是其空间内存是共用的。看下图:
这会导致什么呢?当我们调用完s2,但后面的程序还需要用到s1的时候,析构函数会将调用完,不需要再使用的s2释放掉,将其空间内存还给操作系统。到了这一步,问题就出现了,我的s2的内存是还给操作系统了,但是s1也是指向这个内存空间的啊!s1还需要用到,这不就导致内存泄漏了吗!报错就报错了在这里了!
所以,我们需要用到深拷贝。
深拷贝:给s2独自开辟一块空间,用来存放"hello world",这样以后,s1和s2是独立的,有自己各自的房间,谁也不会影响谁。所以我们看到上面的代码,给_str开辟了一块与s1同样大的空间,这就是深拷贝。
这里我们额外结合之前的知识总结一下,什么时候用到深拷贝,什么时候不需要深拷贝,只需浅拷贝即可。
非自定义类型,比如int、char、double等等的类型,不需要用到深拷贝,同样的它们也不需要用到析构函数。
对于自定义类型:比如string就需要用到深拷贝了,因为每个对象都需要有自己的小房间。
然后来看看现代版本的,对于现代版本,追求是代码简介,不是效率。看下面代码:
string(conststring&s) :_str(nullptr) , _size(0) , _capacity(0) { stringtmp(s._str);//调用构造函数swap(_str, tmp._str); swap(_size, tmp._size); swap(_size, tmp._capacity); //tmp不能指向随机值,所以要初始化 }
解析:现代版本的构造函数,使用了初始化列表来初始化s2。因为我们是创建了tmp对象,来为s2打工,让tmp和s2交换,交换后的tmp不能指向随机值,而是指向nullptr,所以要初始化s2。通俗的来讲,tmp相当于s2的打工人,为s2获取s1的属性,然后将其交给s2,而s2把工资(nullptr)发给tmp。
但是这样的写法不够简介,而且需要调用swap,调用3次拷贝构造(传值传参嘛)。在改写之前,我们来区分一下string自带的swap和C++库自带的swap的区别:
string自带的swap:
C++库自带的swap:
我们可以看到,C++库自带的swap函数,是模板类型的,每一次调用,都需要推演出参数的类型,在上面的代码中,就需要推演int、char*两种类型,需要拷贝3次,比较花时间。而使用string类自带,已经定义好string类,并且是引用,不需要拷贝。所以我们来实现一下string类的swap。
其实,我上面讲的,虽然我们模拟实现了string类的swap,调用了string的swap,但效率是一样的,因为实现的代码是这样的:
voidswap(string&s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); }
我上面说了,一切都是为了代码的简洁哈哈哈哈哈哈哈哈哈,上面说了那么多,是为了让我们了解一下底层原理。
最后,我们可以将现代版本改写成这样子:
string(conststring&s) :_str(nullptr) ,_size(0) ,_capacity(0) { stringtmp(s._str); //this->swap(tmp);swap(tmp); }
④析构函数
析构函数对于自定义类型也是很重要的。因为需要释放掉内存空间。
~string() { delete[] _str; _str=nullptr; _size=_capacity=0; }
⑤operator=()赋值
operator=()跟拷贝构造一样,也有传统和现代写法
先来个传统的写法:string s2(s1);
string&operator=(conststring&s) { if (this!=&s)//不能给自己赋值 { char*tmp=newchar[s._capacity+1]; strcpy(tmp, s._str); delete[] _str;//需要把s2原本的空间给释放掉_str=tmp; _size=s._size; _capacity=s._capacity; } return*this; }
解析:思路很简单,就是创建一个新的char* ,来接收s,然后释放掉s2原本的内存空间,再指向tmp的内存空间即可。
然后来看现代写法:
跟拷贝构造的现代版本一样,使用交换,找个打工人。
string&operator=(conststring&s) { if (this!=&s) { stringtmp(s);//直接调用拷贝构造swap(tmp); } return*this; }
二.模拟实现string类的容量操作
①size() 和 len(),还有capacity()
这size() 和 len()的功能是相同的,都是返回字符的有效个数。这里就写size()。
size_tsize() const { return_size; }
为什么要加个const?因为size是只读,加const保护起来吧。
capacity()是返回总空间的大小。可以理解为:size是水的体积容量,capacity是水杯瓶子的容量
size_tcapacity() const { return_capacity; }
②clear()
清除掉有效字符,但是不改变空间大小。把水瓶里面的水喝完吧。这个实现的方法是直接将_size置为0,然后将0位置的_str[0]赋值'\0',就行了
voidclear() { _size=0; _str[0] ='\0'; }
③resize(size_t n = 0)/resize(size_t n = 0, char c = '\0');
这个函数重要。我们先来分析这个函数的功能如何:
1.这个函数的功能是对有效字符的个数进行操作。它有三种情况。①当n大于字符串的长度并且小于capacity的时候,就是进行插入数据的处理②当n大于capacity,并且大于字符串长度的时候,是进行插入数据+扩容的处理。③当n小于字符串长度的时候,便是删除数据。
2.对于resize(size_t n)/resize(size_t n,char c)这两种重载,需要知道的是,resize(size_t n)是将多余的空间用0来填充,而resize(size_t n,char c)用字符c来填充。
了解到这里,我们就可以去模拟实现它了。当然啦,我们发现我们这里实现的时候,用到了reserve和operator[],这两个我们还没实现,但这样说明了C++在设计的时候,很多功能都是互相辅助的,没你没我都不行。
voidresize(size_tn=0, charval='\0') { //当n大于_sizeif (n>_size) { reserve(n);//判断是否需要扩容,即n是否大于capacityfor (size_ti=_size; i<n; i++) { _str[i] =val; } _size=n; _str[n] ='\0'; } else//小于 删除 { _str[n] ='\0'; _size=n; } }
解析这段代码:我们通过上面的情况,当n>_size,自然是要插入数据,然后再看看是否需要扩容,这一步交给reserve(n)来完成。插入数据这一步,从第_size这个位置开始,这个位置是值是'\0',自然会被覆盖掉,然后一直到n-1,在第n个位置补上'\0',最后将_size的长度变为n。完成!当n<_size,自然是相当于删除数据,直接在第n个位置补'\0',然后_size改成n,完成!当n==_size,什么都不用干。
④reserve(size_t n)
这个函数的功能,是增大水杯的容量,一升的水杯不够我喝,那我就用两升的,两升的不够,那就十升。所以,对于这个函数,它的功能是扩容,为字符串预留空间,是把空间(capacity)增大,不会缩小空间,而且不会改变有效字符的个数或长度。具体是①当n大于当前的空间的时候,那么reserve会将空间扩大。②当n小于等于当前空间的时候,那么不会对原有的空间做什么事。
看下面实现的代码:
voidreserve(size_tn) { if (n>_capacity)//判断是否需要扩容 { char*tmp=newchar[n+1];//多加1,给'\0'strcpy(tmp, _str);//将_str的内容拷贝过去delete[] _str;//释放原本的空间_str=tmp;//拿到tmp的内存空间_capacity=n;//最后将容量改为n } }
三.模拟实现访问及遍历的函数
①operator[](size_t pos);
这个比较简单实现,在返回前最后断言一下。当然,需要写两个重载类型,一个是可读可写,一个是只读不写。
//可读可写char&operator[](size_tpos) { assert(pos<_size); return_str[pos]; } //只读constchar&operator[](size_tpos) const { assert(pos<_size); return_str[pos]; }
②begin()+end()
实现begin()和end(),用到迭代器。迭代器就是一种用法跟指针差不多,但不一定是指针的东西。现阶段,我们可以只知道它的用法,就是把它当成指针用就行了,但是不一定是指针。
代码如下:
typedefchar*iterator; iteratorbegin() const { return_str; } iteratorend() const { return_str+_size; }
解析:对于begin(),返回的_str,因为_str是指针类型, 指向的是首元素的地址,便是最开始的那个位置,即begin。对于end(),返回的是_str+_size,指向的是最后一个位置,即'\0'这个位置。
③范围for
其实范围for,看着好像很高大上一样,我们在用的时候,不知道它为什么能够识别到循环的起点和重点,为什么有这样的功能。这里揭秘:范围for其实就是迭代器的分身!它相当于宏替换一样,当我们写出范围for后,在编译代码的时候,编译器就会将其展开,变成了使用迭代器来实现循环的代码一样了。看下面代码:
strings("12345"); //使用迭代器string::iteratorit=s.begin(); while (it!=s.end()) { (*it)++; ++it; } cout<<s.c_str() <<endl; //范围forfor (auto&ch : s) { ch--; } cout<<s.c_str() <<endl;
结果分别为:23456和12345
然后,如果我们将迭代器中的begin()改为Begin(),也就是将函数名称改一改,接下来我们就会发现,虽然while循环依然可以使用,范围for用不了了。
报错的内容为:
咦?不对啊,我都没有在范围for循环中用到begin()这个函数,怎么会在报错信息中出现这个?这也就很好地解释了我上面所讲的,范围for类似于宏替换,将使用迭代器的循环替换成了范围for。
四.模拟实现string类对象修改操作
①push_back()
push_back的实现,相当于数据结构中的顺序表差不多,如果我们对顺序表的实现熟悉的话,实现push_back一点问题都没有。
在插入数据前,判断一下容量是否满了,如果满了就进行扩容。然后在_size处插入数据,然后将_size++,最后在_size处添上'\0'。代码如下:
voidpush_back(charch)//可以二倍扩容,但是要注意,如果是个空字符串,也就是说capacity是0 { if (_size==_capacity) { size_tnewCapacity=_capacity==0?4 : _capacity*2; reserve(newCapacity); } _str[_size] =ch; _size++; _str[_size] ='\0'; }
加个小知识:
我们来看看每次是如何扩容的?
使用下面代码来测试一下:
voidTestPushBack()//每次扩容代价都比价大,提前把空间开好{ strings; size_tsz=s.capacity(); cout<<"making s grow:"<<endl; for (inti=0; i<1000; i++) { s.push_back('c'); if (sz!=s.capacity()) { sz=s.capacity(); cout<<"capacity changed: "<<sz<<endl; } } }
在windows下,vs2019编译器:
在Linux下:
结论:在vs2019的编译器下,每一次扩容的容量是一点五倍左右,而在Linux系统下便是两倍。
②append();
append()的实现也差不多,不过当容量满了,扩容的时候,扩二倍就一定够吗?肯定不是的,比如当原来的内容是"hello\0",而我要插入"world hello world\0",这个字符串就已经比本身的二倍还要长,所以是不能直接扩二倍。而是扩容到需要追加的字符串的长度再加1,这个长度才行。
voidappend(constchar*str) { intlen=strlen(str); if (_size+len>_capacity)//判断原有的长度加上要追加的字符串长度是否大于现有的容量 { reserve(_size+len); } strcpy(_str+_size, str);//size下标的位置开始,拷贝_str,结尾有'\0',不需要额外加'\0'_size+=len; }
③operator+=();
这个需要模拟实现两种情况,因为它既可以追加一个字符,也能追加一个字符串。而且,它的模拟实现,我们可以直接调用push_back和append。
//追加字符串string&operator+=(constchar*str) { append(str); return*this; } //追加字符string&operator+=(charch) { push_back(ch); return*this; }
④c_str()
这个函数,是返回C格式的字符串。何为C格式的字符串?在c里面,一个字符指针拿到的是字符串的首元素的地址。使用这个函数,就可以拿到这个地址了。
代码如下:
constchar*c_str() const { return_str; }
⑤find()
find()函数返回的是目标字符的位置下标,默认从0开始,也就是说缺省值为0,是半缺省函数。我们通过循环来找。当然,find()函数可以找字符,也可以找字串。对于找某个字符来说,直接使用循环遍历,找到就返回下标。对于找子串,我们可以使用strstr函数来找,返回的是子串的首元素的地址,用char*类型的变量来接收,最后返回首元素的下标。代码如下:
//找字符size_tfind(charch, size_tpos=0) const { assert(pos<_size); while (pos<_size) { if (_str[pos] ==ch) { returnpos; } ++pos; } returnnpos; } //找子串size_tfind(constchar*str, size_tpos=0) const { assert(pos<_size); constchar*ptr=strstr(_str+pos, str); if (ptr==nullptr)//判空,如果没找到,就返回npos { returnnpos; } else { returnptr-_str;//找到,就ptr-_str,即为首元素的下标 } }
⑥insert()
inset()函数相当于是顺序表里面的任意位置插入,所以我们就知道了它的效率不太行,如果字符串很长很长,但我们想要在字符串里面插入字符或字符串,那么就需要挪动很多字符。这也是我们为什么必须学习其底层的实现方法,只有了解了,学习了才能知道哪些接口好用,哪些不好用。
在插入之前,老办法,先判断容量是否满了,如果满了,那就扩容。同样了,insert重载了两种方法,一个是插入字符,一个是插入字符串。
注意,这部分有坑,所以要重点讲解。
插入字符:先来看下面代码:
string&insert(size_tpos, charch) { assert(pos<_size); if (_size==_capacity) { intNewCapacity=_capacity==0?4 : _capacity*2; reserve(NewCapacity); } size_tend=_size; while (end>=pos) { _str[end+1] =_str[end];//往后挪end--; } _str[pos] =ch; _size++; return*this; }
这一看,是没问题。如果我们给出的例子是这样的
根据上面代码,最后将第一个位置的w挪到第二个位置后,x就会插入到下标为0的这个位置,但实际上.......我们一运行,陷入了死循环。
为什么?原因就在于end我们的类型是无符号的,不会有负数,当end等于0的时候,将第一个w字符挪动后,不会变成-1,而是变成一个很大的值,直接进入下一个循环。
下面给出调试的结果:
那么,就有同学会想,我们可以把size_t改为int呀!
好的,我们现在来改一下:
当我们的end变为-1的时候,我们会发现一个问题,怎么回事?明明end为-1,pos为0,而循环条件是end>=pos,还会进入循环?其实原因就在于int隐式提升,int会提升为size_t。在C/C++中,当小的类型于相较大的类型做运算时,小的类型会向大的类型提升,比如int跟double做运算时,int会提升为double。
其解决方法就是,将pos强制转换成int类型。还有就是,在C++的string类的库中,end的类型就是size_t的,我们既然要模拟实现string,我们就遵循规则。那么我们该如何取解决这个问题呢?
好办!让end再往后挪一格,此时,我们循环挪动数据的目标,就是挪动数据,不是放数据,此时循环条件变成end>pos,不能等于。
看下面代码:
string&insert(size_tpos, charch) { assert(pos<_size); if (_size==_capacity) { intNewCapacity=_capacity==0?4 : _capacity*2; reserve(NewCapacity); } //size_t end = _size;//int end = _size;//while (end >= (int)pos)//{// _str[end + 1] = _str[end];//往后挪// end--;//}size_tend=_size+1; while (end>pos) { _str[end] =_str[end-1];//数据挪的位置而不是放的位置end--; } _str[pos] =ch; _size++; return*this; }
插入字符串:先看下面代码:
根据上面的插入字符的代码,我们很容易就写出下面这样的代码:
string&insert(size_tpos, constchar*str) { intlen=strlen(str); if (_size+len>_capacity) { reserve(_size+len); } size_tend=_size+len; while (end>pos) { _str[end] =_str[end-len]; end--; } strcpy(_str+pos, str); _size+=len; return*this; }
我们测试下面这个代码:
一运行,发现,只打印了hello,怎么回事?其实我们不能使用strcpy,因为它会将hello 里面的'\0'也拷贝了过去,所以也只打印了hello出来。
所以我们改一下这个代码,将strcpy改成strncpy;
运行,发现结果对了!好啦,就这样了!错!错哪?我们先来调试瞅一瞅
我们其实可以好好想想,看看这个调试的结果
当pos等于6,而end也等于6的时候,应该就停止了,直接将最近的字符串拷贝过去, 覆盖掉,但是循环并没有停止,反而进行进行,这会导致什么结果?end小于pos了,减掉是负数,在无符号下,是一个很大的数,所以我们接着调试运行看看结果:
它会将随机数给挪过去了!这显然是不可以的。所以我们必须控制好循环条件,将循环条件改为end>=pos+len,或者是end > pos+len-1.
⑦erase()
erase()函数是个半缺省函数,如果我们不写需要删除的字符串的长度,那么就会默认使用npos长度,也就是说从pos位置开始删,删完全部。又或者说是给出的长度加上pos,这个长度大于_size,也会将所有的字符串删掉
而当长度小于_size,那么我们就需要往前挪动数据即可。代码如下:
string&erase(size_tpos, size_tlen=npos) { //当不给长度len时,默认是npos,或者需要删除的长度大于_size,那么就是删除从pos开始到结尾if (len==npos||pos+len>_size) { _str[pos] ='\0'; _size=pos; } else { size_tbegin=pos+len; while (begin<=_size) { //往前挪动数据_str[pos] =_str[begin]; pos++; begin++; } _size-=len; } return*this; }
⑧operator<<()
要实现string的流输出,我们要明确一下格式,是cout<<s<<endl;所以我们不能将operator<<()函数写在类域中,因为类域中的函数,默认第一个参数是this。但我们又必须拿到string类中的私有变量,那就使用友元吧,但也不一定需要友元,我们可以直接在类域外写。
ostream&operator<<(ostream&out, conststring&s) { for (size_ti=0; i<s.size(); i++) { out<<s[i]; } returnout; }
⑨operator>>()
实现流输入,跟流输出一样,写在类域外。重点在于,我们有几个细节需要考虑。第一个,在输入的时候,可能已经有内容了,是重新输入的,所以在输入之前,先将原本的内容情况。第二个,为了避免不断扩容,我们可以定义一个字符数组,用来存放输入的字符,当字符数组满了,再追加到string对象中,然后重新输入。第三个,由于流输入cin跟scanf一样,遇到空格或换行就会停下来。最后,代码如下:
istream&operator>>(istream&in, string&s) { s.clear(); charbuff[128] = { '\0' }; size_ti=0; charch=in.get();//一个字符一个字符地拿while (ch!=' '&&ch!='\n') { if (i==127)//127这个位置留给'\0' { s+=buff; i=0; } buff[i++] =ch; ch=in.get(); } if (i>=0)//当i!=127但是又有空格了,那么跳出循环,将当前i这个位置给上'\0'表示字符串结束位置 { buff[i] ='\0'; s+=buff;//最后追加给s } returnin; }
vs和g++下string结构的说明
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节
vs下string的结构:
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字
符串的存储空间:
①当字符串长度小于16时,使用内部固定的字符数组来存放
②当字符串长度大于等于16时,从堆上开辟空间
union_Bxty{ char_buff[16]; char*_ptr; size_t_size;//保存字符串长度size_t_capacity;//保存从堆上开辟空间的总容量} _Bx;
这种设计是有一定道理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内
部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高,本质上是以空间换时间。
g++下string结构:
G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指
针将来指向一块堆空间,内部包含了如下字段:
空间总大小
字符串有效长度
引用计数
struct_Rep_base{ size_type_M_length; size_type_M_capacity; _Atomic_word_M_refcount; };
比如有两个string类的对象s1和s2,然后进行s1(s2)这样的拷贝构造时,会默认是浅拷贝,然后在析构的时候,根据引用计数的个数来判断是否释放空间。那什么时候会进行写时拷贝呢?那就是s2要修改数据的时候,就会额外给s2一个空间。这就跟操作系统中的父子进程概念类似!
本篇文章结束~这就是模拟实现string的详细过程,如果有什么不懂的可以下方评论留言~喜欢的朋友可以点个收藏~