在上两篇中,我们已经学习了string类的一个使用,并且做了一些相关的OJ练习,相信大家现在对于string的使用已经没什么问题了。
那我们这篇文章呢,就来带大家对string进行一个模拟实现,这篇文章过后,有些地方大家或许就可以理解的更深刻一点。
那通过之前文章的学习我们已经对string有了一些了解了:
我们知道,string的底层其实就是一个支持动态增长的字符数组,就像我们数据结构里面学的动态的顺序表。
那确定了它的结构,接下来我们就开始模拟实现它。
我们来新建一个头文件string.h
,定义一个string类:
class string
{
public:
//成员函数
private:
char* _str;
size_t _size;
size_t _capacity;
};
string类的成员变量有3个,一个字符指针
_str
指向开辟的动态数组,_size
标识有效数据个数,_capacity
记录容量的大小(不包含'\0'
)。
相信经过之前数据结构的学习,大家很容易就能明白它们的含义。
但是:
我们现在是要自己实现一个string类,而标准库里面已经有string类了。
所以,为了避免冲突,我们可以定义一个命名空间,把我们自己实现的string放到我们自己的命名空间里面。
namespace yin
{
class string
{
public:
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
命名空间的名字,大家可以自己起。
接下来我们来模拟实现一个构造函数:
首先我们提供一个无参的默认构造函数:
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
可以直接在初始化列表初始化。
那我们再来写一个带参的构造函数:
string(const char* str)
:_str(str)
,_size(strlen(str))
,_capacity(strlen(str))
{}
这里是用一个常量字符串去初始化我们的string对象。
但是呢?
我们发现这里报错了,为什么?
,是不是一个权限放大的问题啊。
char* str
被const修饰,不能被修改,但是赋给_str
,_str
是char*
类型的,可以修改,所以这里存在权限放大,是不行的。
那怎么办呢?
是不是可以把_str
也变成const char*
类型的:
这样就可以了。
那我们来测试一下:
我们可以用构造函数构造一些string对象,然后打印一下。
但是呢,我们现在是自己写的string,还没有重载流插入和流提取<< 和>>
,所以不能直接打印string对象。
我们暂且先不实现<< 和>>
的重载:
大家想一下,我们在string的使用里学过,string是不是有一个接口叫做
c_str
啊:
它返回的是一个指向当前string对象对应的字符数组的指针,类型为const char*
,那指针是内置类型我们是可以直接用<<
打印的。
那我们可以先来实现一下c_str
:
试一下:
int main()
{
yin::string s;
yin::string s2("hello world");
cout << s.c_str() << endl;
cout << s2.c_str() << endl;
return 0;
}
运行程序:
我们来分析一下:
首先我们的构造函数好像是没什么问题了
那问题呢其实就出现在打印上面。
那为什么第10行这里打印就崩了呢,不是返回一个空指针吗?那就打印空指针啊。
,这里不是这样的,这里程序挂掉的原因就在于对返回的空指针解引用了。
为什么会解引用?
这里返回的是const char*
类型的指针,我们说cout是会自动识别类型,它这里会以字符串的形式去打印,也就是说它不是打印这个指针,而是去解引用打印它指向的字符串,遇到\0
,停止,而这里返回的是空指针,所以就挂了。
哎,那如果我们用标准库里的string,这里会挂掉吗?
我们看到这里就没事了,只是第一个什么都没打印,因为s本身就是构造了一个空字符串对象。
所以这个地方是第一个问题,需要我们解决。
我们先往下看:
对于string我们还会重载
[]
,那[]
就是去返回指定位置的字符的引用,我们实现一下:
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
逻辑也很简单,但是呢?
我们会发现现在又出来一个问题:
我们用[]
访问到string对象的某个字符是不是可以修改它啊,所以这里返回它的引用,但是,由于前面我们为了解决那个权限放大的问题,把_str
的类型修改成了const char* _str
,所以现在它指向的内容就不能被修改了。
大家思考一下这里问题的根源在哪里?
是不是就出在我们的构造函数啊,上面带参的构造函数我们用一个常量字符串去初始化我们的string对象,由于存在权限放大的问题不能传过去,所以我们把
_str
的类型改成const char*
,但是我们的string对象是可以被修改的,而现在_str
指向一个常量字符串,它有可能直接就是在常量区的,那我们后面扩容是不是也没法搞啊,而且这里还加了const,所以就修改不成了。
那大家思考一些,对于这写问题,我们应该怎么解决呢?
是不是还得去修改一下构造函数啊,对于string对象的这块空间,我们是不是要能够去修改这块空间的内容啊,并且我们如果我们再向里面插入数据空间不够的话是不是还要扩容啊。
所以,我们这里还能这样搞吗?用一个常量字符串构造的时候,直接让我们的_str
指向这个常量字符串,是不是不行啊。
我们是不是可以自己去new
空间啊,然后把常量字符串的内容拷贝过来。
我们来写一下:
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
这里
new
的空间大小是_capacity + 1
,因为这里_capacity
是不包含\0
的空间的。
当然这样的话我们_str
的也就不要再加const了。
,那这个构造函数修改好了,无参的那个呢?
是不是也存在一些问题啊,我们上面打印无参构造函数构造的对象是不是导致程序崩溃了啊,原因是打印的时候对空指针解引用了。
那要怎么修改呢?
我们这里就不能给空指针了:
怎么做呢?
我们也去new,在这里New一个char的空间,并且用New []
,
为什么要用new[]呢?
这里我们就要结合析构函数来看了,我们带参的构造函数用了new【】,所以析构必然要用delete【】,那我们要匹配起来啊,所以这里即使只new一个空间,我们也用new []
那这里我们New一个空间,给它什么值呢?
那这下我们就跟库里面的实现一样了,再来测试:
,那大家再来想一下:
现在我们提供了两个构造函数,一个无参的,一个带参的。
但是,它们两个是不是可以合二为一啊,我们不是学了缺省参数嘛,给个缺省参数不就行了嘛。
那现在问题又来了,缺省参数要怎么给?
首先这里肯定是不能给空指针了:
因为下面strlen
是会对str解引用的,如果是空指针就崩了。
那给个\0
吗?
肯定也不行,首先类型是不匹配的,不过这里在有些地方也有可能发生类型转换,勉强能赋过去,因为我们之前学过char也是属于整型家族的嘛,但是\0
的ASCII码值是0,0转换未指针类型还是空指针。所以不行。
而且在我们当前用的vs2022上直接编译就不通过。
那要怎么给?
,我们这里给一个空串是不是就行啊。
那下面_capacity 和_size都是0嘛,然后开一个空间,把\0拷贝过来。
没有问题。
我们现在写这样一段代码:
int main()
{
yin::string s1("hello world");
yin::string s2(s1);
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
return 0;
}
很容易看出来这里有一个拷贝构造,s2是s1拷贝构造出来的。
但是呢?
我们现在还并没有自己实现拷贝构造,那上面的代码可以运行吗?
,经过前面类和对象的学习,我们知道,拷贝构造函数我们自己不行编译器是不是会默认生成啊。
所以我们可以直接运行上面的代码:
但是我们看到程序出错了,为什么呢?
,这里是不是一个经典的浅拷贝的问题啊。
经过之前的学习我们知道:
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
类中如果没有涉及资源申请时,拷贝构造函数我们自己写不写都可以(因为默认生成的就可以搞定);一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝,就会出现问题。
而我们的string类,底层是一个动态顺序表,空间是我们从堆上new出来的,所以string类的拷贝构造必须是深拷贝,而默认生成的完成浅拷贝,所以这里就出问题了。
我们通过调式来观察一下:
我们可以看到s1和s2的_str指针指向的是同一块空间,那这样析构的时候就会对同一块空间析构两次,所以程序才会崩溃。
另外,除了会析构两次,这种浅拷贝的情况如果我们修改一个string对象是不是也会影响另一个啊,因为它们指向同一块空间。
所以这里的拷贝构造就需要我们自己实现:
怎么实现呢?
当然就需要我们去完成深拷贝。
那也很简单嘛,我们给s2开一个同样大小的空间,然后把s1的内容拷贝过来就行了。
实现一下:
//拷贝构造
string(const string& s)
:_size(s._size)
,_capacity(s._capacity)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
}
那这下是不是就好了啊,我们看一下:
这下两个string对象就拥有独立的空间了。
程序运行就没问题了。
而且,现在我们修改一个对象,对另一个也不会产生影响了:
然后我们看赋值重载:
赋值重载作为类的6个默认成员函数之一,我们不写编译器是不是也会默认生成啊。
但是:
默认生成的赋值重载是不是也是浅拷贝啊,
所以:
和拷贝构造一样,如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要自己实现。
那对于string类来说,我们也需要自己实现一个深拷贝的赋值重载:
那深拷贝的赋值重载要怎么搞呢?大家可以先自己尝试写一下。
,我们来分析一下:
对于我们这个赋值的这个场景:
s3 = s1;
是不是会有这样几种情况:
那我们还要分情况去处理吗?好像太麻烦了。
所以,基于这样的原因,我们干脆统一处理,不管哪种情况,我们都直接释放旧空间,然后开新空间拷贝数据:
那这样写就可以了吗?还有没有什么问题?
,如果是一个对象自己给自己赋值,是不是旧出问题了啊。
怎么回事?
随机值?
因为如果是自己给自己赋值的话,_str和 s._str
都是指向自己的空间,我们上来delete[] _str
直接把自己的空间释放掉了,然后又开新空间拷贝数据,那拷贝的当然是随机值了。
所以我们这里加一个判断:
如果是自己赋值给自己,直接返回就行了。
这下就可以了。
但是:
其实现在我们写成这个样子,还存在一些隐患:
我们这里上来是直接把_str的空间释放掉了,然后去new开新空间,但是new是不是也有可能失败啊(失败抛异常,关于异常我们后面会讲)。
那这样的话,我们直接把原空间释放了,但是开新空间失败了,后面又去拷贝数据这样肯定不行的(当然如果我们去try catch捕获的话就直接跳到catch的地方了,所以其实也没有什么大问题,不过是把原来的string对象破坏了,但是破坏也就破坏了,反正都抛异常了)。
不过我们可以稍微改一下,这样写:
这样即使开空间失败了,我们也没有把原对象破坏掉。
那如果现在我们想要遍历我们创建的string对象:
那现在我们要写一个打印string对象的函数:
首先大家思考一下这里我们要如何传参?
传值可不可以,当然是可以的,我们已经自己实现了拷贝构造了。
但是这里我们会选择传值吗?
是不是不会啊,传值的话还要拷贝构造,那直接传引用不就行了嘛。
那传引用的话,我们这里只是打印,不想它被修改,所以我们一班还会加const:
但是这样写我们会发现它报错了。
为什么会报错?
,这个东西我们之前类和对象里面在讲const成员函数的时候是不是说过啊。
这里s是const对象,它去调size和operator[]这些非const成员函数的时候,this指针的传参发生了权限放大(大家不是很清楚的话可以复习一下之前的文章)。
那我们也说了:
对于类的成员函数,如果在成员函数内部不需要改变调用它的对象,最好呢都可以把它写成const成员函数。
这样普通对象可以调,const对象是不是也可以调了。
那const对象调[ ]的话,const对象不能修改,所以我们返回引用也返回const引用。
但是现在有一个新的问题:
现在普通对象和const对象都可以调[]了,这没问题,但是由于返回的const引用,而普通对象可以被修改的,那现在返回的const引用普通对象还能通过[]修改吗?
不行了:
那怎么解决这个问题?
那除了[]可以遍历访问string对象,还有什么方法?
,是不是还可以用迭代器啊。
那我们接下来就来实现一下迭代器。
怎么实现呢?
那迭代器我们说了大家可以理解成一个像指针一样的东西,但是不一定是指针。
我们最开始介绍了STL有好几个版本,不同的版本实现可能是不一样的。
那其实vs下string的迭代器呢就不是使用指针实现的,而G++下使用的SGI版本是指针实现的。
那这里我们模拟实现就使用指针来实现。
那其实很简单:
相信这段代码不用给大家过多解释了。
那我们的迭代器就实现好了。
我们来试一下:
int main()
{
yin::string s1("hello world");
for (yin::string::iterator it = s1.begin(); it != s1.end(); it++)
{
cout << *it << " ";
}
cout << endl;
return 0;
}
那用范围for可以遍历吗?
当然也可以。
因为我们自己实现了迭代器,我们之前提过,范围for的底层就是用的迭代器。
大家可以理解成范围for的语法其实就跟我们之前学过的宏有点类似,它会被替换成迭代器,相当于把*it赋值给e。
我们可以简单验证一下:
我们把迭代器的实现稍微做一点变动,比如,我们把begin的b改成大写B。
我们发现范围for就用不了了。
我们继续来看:
如果我们再这里面使用范围for:
会发现用不了了。
为什么呢?
,我们说了范围for底层是用的迭代器,而我们现在只实现了普通迭代器,那范围for替换成调用迭代器,这里的s是const对象,去调用普通迭代器(非const成员函数),是不是又是权限放大啊。
所以不行。
那怎么解决呢?
是不是就需要我们实现const迭代器了。
就这样。
这下就可以了。
我们看到标准库里的string还重载了很多关系运算符。
那我们也来模拟实现几个:
那大家思考一下,字符串之间的比较,要怎么做?
我们这里是不是可以考虑直接复用strcmp啊。
写一下:
我们实现两个,剩下的就可以直接复用了。
那大家思考一下,我们上面写的有没有什么问题?
来看:
这里换一下位置就不行了,为什么呢?
因为这样的话就是s去调==了,而s是const对象,调的是非const成员函数,就不行了。
所以,我们说过好几遍了:
对于类的成员函数,如果在成员函数内部不需要改变调用它的对象,最好呢都可以把它写成const成员函数。
全加上const,这样就行了。
,那我们来简单测试一下:
然后我们来实现一下push_back(),那就是尾插一个字符嘛。
那大家想插入数据的话是不是要考虑扩容啊。
那插入数据的接口除了push_back() ,是不是还有append()啊,append()可以在string对象后面追加字符串,那也需要考虑扩容。
那大家想一下,如果扩容的话,我们一次扩多少呢?
还像之前写的顺序表一样扩二倍可以吗?
对于push_back()来说一次扩二倍应该没问题,但是append()还一次扩二倍是不是有可能不行啊。
为什么?
你想如果当前的容量是10,现在追加一个长度为25的字符串,你扩容到原来的两倍才20,是不是也不够用啊。
那我们这里可以怎么扩呢?
,string是不是还有一个成员函数叫做reserve啊,它可以改变容量为我们指定的大小,帮助我们扩容。
但是,我们是不是还没实现它。
所以:
我们先来实现一下reserve:
注意不能先释放旧空间,因为我们还要把旧空间的数据拷贝到新空间。
这就是我们的reserve。
大家看一下,我们的这个reserve有什么问题没有?
,如果我们参数n的值小于_capacity,这里是不是就真的缩容去了,重新开一块小空间,拷贝原来的数据,释放旧空间。
但是我们说了,一般是不会轻易缩容的,标准库里string的reserve其实就不会缩的,我们可以看一下:
是不是没有啊。
那我们自己实现的这个呢?
我们也来测试一下,当然我们还没有实现capacity()这个接口,实现一下:
来看:
我们的确实缩了,但是程序也崩了。
为什么呢?
那我们通过调式一步步走其实可以推测出它应该是在析构的时候报错了。
那原因出在哪里呢?
来看,如果n小于_capacity的话,这里会缩容,先开一块比原来小的空间,然后把原来的数据拷贝过去,但是,空间变小了,还能放的下原来的内容吗?
是不是就不能了,但是strcpy是遇到\0才停止,所以这里就越界了。
所以这里如果要拷贝的话是不是应该用strncpy,不能全拷,但是我们说了一般不缩容,库里面的就没缩,所以这里我们加一个判断:
n > _capacity才做处理。
小的话我也不缩。
但是其实这里还是有一个问题的,我们后面再说。
那reverse搞好了,我们就可以继续实现push_back()和append()了:
那在判断之后,需要扩容我们就扩容,然后我们插入数据就行了:
那push_back()我们这里就选择扩两倍。
另外给大家提一下我们这里选择用strcpy而没有用strcat,这里不推荐使用strcat,当然strcat也是可以完成的。
那为什么不推荐呢?
因为strcat追加字符串的时候是需要自己从头去找被追加字符串的\0
,然后从\0开始追加新的串。
而我们这里明确知道\0的位置,_str + _size
就是\0的位置,用strcpy就直接把这个位置传给它直接开始追加了。
,那我们来测试一下:
虽然有push_back和append,但是我们说了,我们一般不喜欢用它们两个。
因为string还重载了+=,用起来就非常香,尽管+=的底层也是去调push_back和append。
,我们来实现一下:
很简单。
来测试一下:
然后我们来实现一下resize:
resize就是扩容加初始化,我们可以自己指定要初始化的字符,不指定默认填\0。
那这里是不是也分几种情况啊:
首先第一种情况n小于_size,那是不是要删除数据啊,只保留前n个,怎么做?
是不是直接把第n个位置置成\0就行了:
当然等于也可以走这里(等于其实可以不做处理)。
然后就是n>_size ,但是大于也分两种情况,n在_size和_capacity之间以及n>_capacity两种:
n在_size和_capacity之间的话,之间插入数据就行了,如果n>_capacity,那就要先扩容,然后再插入数据。
测试一下:
没问题。
我们先来实现一下insert,当然库里面提供好多个版本,我们不可能全部实现。
首先我们来实现一下在pos位置插入一个字符
怎么写,思考一下:
逻辑是不是很简单啊,首先判断一下,需要扩容的话要进行扩容,然后就去插入数据就行了嘛,如果往中间插的话挪动数据就行了嘛,跟我们之前实现顺序表的insert一样嘛
就写好了,有没有什么问题呢?测试一下:
可以,但是,这样呢?
在下标为0的位置再插入一个*,发现程序挂掉了,怎么回事呢?
大家思考一下,为什么?
当然库里面的肯定是没问题的,不过库里面的跟我们实现的这个还有一点差异:
库里面指定位置插入字符的话还有一个参数n指定插入的个数,直接插入一个的是传迭代器,所以这里我们没有完全跟着标准库走。
那我们这里为什么在下标0位置插入就崩了呢?
当pos为0时,end等于0时还会进入循环,end再- -变成多少?
是-1吗?
这里end的类型是szie_t,无符号整型,所以这里end为0后再- -并不是-1,而是整型最大值,那就越界了,循环也没正常结束,所以程序崩了。
那怎么解决?
把end的类型变成int?
,这里变成int也不行,为什么?
这就用到我们C语言学过的知识了
end和pos比较,就算把end变成int,但是pos是不是size_t类型啊,那这里是不是会发生算术转换啊。
end 还是会变成size_t。
那怎么搞?在参数列表把pos的类型也改成int?
确实可以了,但是我们还是不要这么搞吧!
我们看到表示下标一般都是size_t。
那我们这里呢这样来解决:
刚才是>=,end减到-1才结束,那我们改成>,那到0就结束了,就不会出现刚才的情况了。
但是,这时我们要让end的初始值为_size + 1
,然后依次把end - 1
位置的元素挪到end
。
然后我们测试一下:
就可以了。
那刚才是插入一个字符,现在我们再来实现一个插入字符串的:
那逻辑是不是跟上面插入一个一样啊,只不过上面我们只需要挪出一个空间就可以了,那这里我们是不是需要挪动数据腾出strlen(str)个空间啊。
给大家画个图:
理清思路,代码就简单了:
仔细观察我们会发现把这里的len换成1就和上面那个insert的循环控制一样了。
我们来测试一下:
当然:
那接着我们来实现一下erase,从pos位置开始删除len个字符:
那这里的len呢我们看到有一个缺省值:
npos,npos是啥呢:
,它是一个const静态成员变量,值为-1,但是呢,因为这里它的类型是size_t(无符号整型),所以它在这里其实是整型的最大值。
那在这里就要给大家提一个东西:
我们知道C++11开始支持类的成员变量在声明的时候给缺省值,但是呢有个前提,必须是非静态成员变量才可以在类中声明的时候可以给缺省值。
静态成员变量是不能在声明时给缺省值的,这个我们在之前类和对象的文章里也有讲解过。
我们说了对于静态成员变量:规定静态成员变量的初始化(定义的时候赋初值)一定要在类外,定义时不添加static关键字,类中只是声明。
正常应该这样写。
不过呢,我们看到库里的npos还加了const:
那加const就加嘛?但是呢,加了const之后:
这样直接给缺省值就可以了,又不报错了。
而且呢,这样的写法,这种语法呢,还只支持整型:
所以,这个地方就感觉有点怪啊。
好吧,那这个大家就了解一下,我们还是按正常的写法好吧:
那我们来实现一下erase:
,那这里是不是也分这样几种情况啊:
首先第一种情况就是pos+len小于字符串的长度,那我们是不是要把pos位置开始的后len个字符删掉,但是后面的还要保留啊。
那这种情况怎么做?
是不是挪动后面的数据,把需要删除的覆盖掉就行了啊。
那其它的情况就是给的len比较大,pos+len直接大于等于字符串的长度了,那就把pos后面的全部删掉。
或者就是没有传pos这个参数,缺省值npos,那也要把后面的全删,所以这两种情况可以统一处理。
怎么做?直接把pos位置置成\0
是不是就 了。
我们来写一下代码:
就写好了。
测试一下:
没什么问题。
不过呢:
然后我们在来实现一下string的swap:
那我们在之前讲解string使用的文章里也说了,对比算法库里的swap,string::swap的效率是更高一点的,也给大家简单的解释了原因,那这下我们实现之后相信大家就理解的更透彻了。
来,写一下:
简单测试一下:
然后我们实现一下find:
那find是不是也很简单啊,我们去遍历找就行了嘛,找到了就返回下标,找不到返回npos。
当然库里面string的find还有一个参数pos,就是支持从pos位置开始向后找,pos缺省值为0 :
另外记得加个断言判断pos是否合法。
那这样就行了嘛。
那除此之外find还支持从pos位置开始查找一个字符串:
我们来实现一下,是不是可以考虑直接复用C语言里面的
strstr
去找啊。
写一下:
由于strstr返回的是地址,这里我们可以通过指针-指针的方式得到它的位置。
那我们来简单测试一下:
先来重载一下流插入<<:
那不知道大家还记不记得,之前我们学习类和对象的时候不是练习过一个日期类嘛,日期类里面我们也重载了流插入和流提取,但是我们讲到我们自己重载的如果想像正常cout打印那样用的话,要把流插入<< 和 流提取>>重载到类外面,因为this指针会抢占第一个参数的位置,但正常的话应该是让cout是第一个参数。
那我们来写一下代码,那我们在函数内部是不是直接遍历打印就行了:
这样就行了,参数out接收cout,s接收我们要打印的string对象。
当然这里不能用out<
去打印,这个我们之前也讲了:
打印c_str返回的const char*的指针,它是遇到’\0’就停止了。所以大家可以理解成c_str就是返回C格式字符串。
而我们打印string对象应该是看s对应的size 的,size是多大,总共有多少字符,全部打印完。
注意这两种方式的区别。
测试一下:
然后:
上面我们实现reverse的时候最后不是还留了一个问题嘛,我们说之前的实现还是有点问题的,什么问题呢?
来看,对于我们上面实现的reverse如果是这种情况:
这里怎么出现乱码了呢?
,因为我们之前实现的reverse用的strcpy
它拷贝到\0就结束了,但是我们这里的s1\0后面是不是还有有效字符啊,所以\0后面放的就是随机值,但是打印又是按照size去打印的,所以就打印出了乱码。
所以不能用strcpy,要按照size的大小去拷贝数据:
可以使用memcpy。
这下就可以了。
那接着我们再来重载一下流提取:
那就可以这样搞:
用一个循环,一个字符一个字符的去缓冲区里提取,然后插入到s里,遇到空格或者换行就停止。
试一下:
但是我们发现好像有点不对劲啊,为什么不行啊?
,原因在于cin呢它读不到缓冲区里的空格和换行,为什么读不到呢?
之前也提到过,C语言里的scanf,包括这里的cin,我们在用它们输入的时候是不是有可能输入多个值啊,那当我们输入多个值的时候,它们默认是以空格或者换行来区分我们输入的多个值的。
所以它遇到缓冲区里的空格或者换行的时候,它会认为这是你输入多个值的一个区分,会自动忽略掉它们,不会去提取,所以这里就读不到空格和换行,那循环就不会结束。
那要怎么解决呢?
再来测试一下:
但是如果是这种情况呢:
那怎么清除一个string对象?
再来试一下:
那还有没有什么其它的问题?
,如果我们输入一个特别长的字符串,那这个地方在不断+=字符的过程中是不是可能会频繁扩容啊,那我们有没有什么办法可以解决一下呢?
首先这里解决的方法肯定不是唯一的:
那库里面呢用了一种类似于这样的方式:
这里开了一个数组,每次先把字符一个个放到数组中,满了的话就+=到s里(以字符串的形式),然后把i置成0,后面继续放数组里。
那这样做相对而言扩容就不会那么频繁了。
当然,如果输入结束buff数组没满,那就把里面有多少数据就+=多少。
那buff数组的大小大家也可以自己调整。
,那我们string的模拟实现差不多就到这里:
当然实际库里面string提供的接口是很多的,我们不可能全部实现完,而且有的基本不怎么用,还有的虽然没实现,但是想要实现也很简单,我们带这大家一起实现的都是一些比较重要和常用的。
那这里模拟实现最重要的目的呢,其实还是让大家更好的理解string,同时也作为一个练习。
最后我们再来了解一个东西叫做写时拷贝:
大家说这里打印出来的大小是多少?
是28,为什么是28呢?
,这个我们在string的使用那篇文章是不是给大家解释过啊。
vs上在这里做了一个优化,多开了一个大小为16的数组,这样如果字符串比较短的话就不用去堆上申请空间了,直接存到这个数组里。
那大家知不知道G++下面一个string对象的大小是多少?
G++下,string是通过写时拷贝实现的,string对象总共占4个字节(32位平台下,64位下8个字节),内部只包含了一个指针。
那只有一个指针,它具体是怎么实现的呢?
该指针指向一块堆空间,内部包含了如下字段:
- 空间总大小
- 字符串有效长度
- 引用计数
- 指向堆空间的指针,用来存储字符串
比如我们现在有一个string对象s1,那它大概是这样一个样子:
那写时拷贝又是什么东西呢?
如果现在又有一个s2是s1拷贝构造出来的,我们vs上面是深拷贝。
而Linux的G++(采用的是SGI版本)下面是写时拷贝:
写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给
计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象是资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
此时只有s1一个对象使用这块资源,引用计数就是1 。
现在如果有s2是s1的拷贝,那这里是一个浅拷贝,只是把引用计数加1,表示现在有两个对象使用这块资源。
那释放的时候怎么办呢?
,释放s2的时候,就把引用计数减1,而不是真的释放这块空间,等到s1释放的时候,引用计数为1,表示只有一个对象使用这块资源,就可以释放了。
那这个地方是不是不拷贝啊?
不是的,写时拷贝,写时拷贝,就是写的时候才拷贝。
还拿上面那个例子来说,如果s2只是拷贝s1,我们并没有修改s2,那它们两个就可以共用一块空间,如果我们去修改了s2的内容,那这个时候才会进行真正的拷贝,为s2开一块独立的空间,然后把s1的内容拷贝下来,然后你要修改数据就在你自己的这块空间上进行修改。
修改数据才会触发写时拷贝(Copy-On-Write),不修改当然就不会改。这就是托延战术的真谛,非到要做的时候才去做。