模拟实现string
- 前言
- 类的成员变量
- 构造函数
- 析构函数
- size和length
- [ ] 重载
- 迭代器
- 赋值运算符重载和拷贝构造函数
- 拷贝构造函数
- 赋值运算符重载
- 现代式写法
- reserve 和 resize
- reserve
- resize
- 字符串追加
- push_back
- append
- +=
- insert
- pos位置插字符
- pos位置插字符串
- erase
- >> 和 <<
- <<
- >>
- find
- substr
- 最后一点
- vs上的
- gcc下
前言
本篇博客是结合上一篇string介绍中的内容来讲的,如果对于string不熟悉的话,可以点击下面的传送门先看看:
【C++】string介绍
为了和库中的string做区分,我下面的所有代码都是放在FangZhang命名空间中写的:
如果不明白为什么这样做的话,可以看我第一篇关于C++的博客:从C语言入门C++的基础知识中关于命名空间的知识。
类的成员变量
_str是用来存放字符串的,包括 ‘\0’ 。
_size用来记录当前字符串中的有效字符个数。
_capacity用来记录有效字符的总个数。
_size和_capacity都不记录 ‘\0’ 这个字符。
例子:若当前字符串中保存有 hello
那么如下图下所示:
在vs2019中,标准库中的类对象初始化时会直接将capacity开到15(如果字符个数小于15的话),即使是空对象也是。
下面的模拟实现肯定不会像vs里面一样,只是简单的实现一下,更方便了解string类,不然我都直接去写编译器去了😂。
构造函数
我的写法:
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
这里缺省参数给的是 “”, 这个就是个常量字符串,里面会有 ‘\0’ 作为结尾。有的同学可能会写成 “\0”,这样的字符串其实里面有两个 ‘\0’,写起来反而有点画蛇添足了。
前面在讲类和对象时,说了构造函数尽量使用初始化列表,但是这里初始化列表不好用。
例如:
这样不能直接赋值,因为前面成员变量中_str是char * 的,但是这里的参数是const char *的,不能直接赋值, 会发生权限放大,所以初始化列表不能这样直接搞。
还有这样的:
注意,若初始化的时候没有给值,不能初始化为nullptr。不然下列场景会报错:
这里用到了c_str,讲下这个:
其实就是返回成员变量_str,也就是字符串的地址,直接用const修饰上,防止被修改,不管是不是const对象都不能修改。
那上面的例子就好说了,就是不能直接打印nullptr,打印了就相当于解引用空指针了。
还有这样的:
认为这样就能成功了,但是这里的初始化顺序不是按照初始化列表中的,而是类的成员变量的声明中的。这在我前面类和对象的介绍中也是讲了这一点的。
我这里的声明是:
也就是说,这里初始化时会先初始化_str,但_str中又用到了_capacity,而_capacity还没有初始化,是未知数,一个很大的数,所以这里初始化是有问题的。
在vs2013下这个开空间的时候会直接报错bad allocation。
但在vs2019下会先开空间,然后析构的时候再报错。
反正就是错的,不要这样写。
最上面的那个就是标准写法。
析构函数
析构没啥好注意的,就是 delete 的时候加 [ ]
size和length
很简单,size就是length,都不能被修改,给一个就行。
[ ] 重载
这个可以说是 string 里面最有用的。
要实现两个,一个const对象用,一个非const对象用。
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
char& operator[](size_t pos)const
{
assert(pos < _size);
return _str[pos];
}
然后就没什么好说的了。
直接给个例子:
迭代器
这个也比较重要。
其实string的迭代器底层就是指针。
长这样:
把用例给出来:
这个迭代器实现了就能用范围for了,因为范围for的底层就用的是迭代器。
赋值运算符重载和拷贝构造函数
将这两个放一块是因为若类的成员变量中定义时必须要动态开辟空间,则二者在使用时都会出现深浅拷贝的问题。
拷贝构造函数
这个比赋值简单点,先说这个。
首先,这两个函数若自己不提供,编译器会默认提供的,但是编译器提供的只是浅拷贝,这一点在我讲类和对象那三篇的时候也是说过的。
那就先给出浅拷贝的情况:
代码如下:
我这里没有自己实现拷贝构造,运行:
程序崩掉了,原因就是 s1 和 s2 中的 _str 是指向同一块空间的。
当代码运行完了,析构的时候会析构两次同一块空间。第一次析构s2的时候没事,但是第二次再次析构s1时就是非法行为了。因为第一次 delete s2 中的 _str 时已经把而这指向的同一块空间还给系统了,第二次再delete时就属于私闯民宅了,在米国可是犯法的,屋子里的人可以直接向你开枪的。
所以这时候就需要我们自己写拷贝构造。
代码如下:
此时再运行上面的例子就不会出问题了:
因为s1和s2指向的空间是不一样的:
此时再析构,不会出现释放同一片空间的情况。
最后代码如下:
string(const string& s)
:_str(new char[s._capacity + 1])
, _size(s._size)
, _capacity(s._capacity)
{
strcpy(_str, s._str);
}
赋值运算符重载
这里难度稍微上升一点。
先说编译器默认给的赋值运算符重载:
跑起来出现浅拷贝。
下面说自己写的赋值运算符重载。
s1 赋值给 s2 时有几个注意事项:
- s1和s2的空间不相同怎么办
- 自己给自己赋值的情况
- new失败了,怎么办
第一点:不论谁的空间大。都将s2的空间重新开,开为跟s1一样大。因为如果出现s2空间很大,s1空间很小,就会出现非常浪费的情况;s2的空间小于s1的,又要重新给s2开空间,再把s1拷过去。两种情况和一块,不如直接给s2重开。
先将代码给出:
运行起来,不会出现浅拷贝的情况:
能跑,但是不够完善。
再说第二点:如果自己给自己赋值
如果是上面的实现方式:
结果乱码。
看代码:
s2给自己赋值的时候,进去先释放其本来的空间
开新空间,然后再strcpy,就会将新开的空间中的内容拷回去,此时新开的空间中的内容还没有初始化,就会导致内容中存放的是未初始化数据。此时赋值结束,就会打印乱码。
再把代码改改:
再运行,就没问题了。
再说第三点:new失败
可能开空间比较大时就会new失败,new失败的时候会抛异常,利用这一点,写下如下代码:
运行:
跑起来很顺利。
说一下为什么:
但前面的代码中
所以最后的代码如下:
string& operator= (const string& s)
{
if (this != &s)
{
delete[] _str;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
现代式写法
这个标题是什么意思?
就是上面的两个还可以改成别的写法。
叫现代式写法,不如叫资本主义写法。
拷贝构造
看:
这里的三个swap不是我自己给的,是标准库中提供的。
STL中提供了一些简单算法,其中就包括swap,这个在我讲模板的那篇中也说了的,这里再提一下。
分析一下:
如下:
这里编译器做了优化,会自动将this的所有内容初始化。
在2013中是不做初始化的,this中的所有的值都是随机值。
不然标准写法应该是这样:
然后还可以简化一下,就是将下面的那些库中的swap换成自己类中的:
可能有的同学看不懂,没事,看解析:
赋值运算符重载
看代码:
其实这里判不判断相等都无所谓了,只要tmp给出了,就算相等,交换了也没有什么效果。
可以不判断相等,但是还有更绝的写法:
这样也是成立的。因为tmp是传值的方式传参的,就是新定义的对象。
到这里两个关于深拷贝的函数代码就算彻底讲完了。
再说点细节上的东西。
库中的swap和我们自己提供的swap有什么区别。
首先,先把库中的实现给出来:
如果我这里用库中的swap来交换两个string对象的话:
可以实现,但是可以看到,库中提供的swap交换的时候全是深拷贝。
传参深拷贝,赋值深拷贝。都给原来的两个对象拷没了。
看:
地址变化非常大,因为全是深拷贝,不断的在开新空间,就会导致效率变低。
再用一下我们自己实现的:
可以看到地址就没变,还是那两个地址。
所以说,库中的swap对于交换自定义类型的时候要慎用,尤其是当自定义类型中有动态开辟空间的,若此时用库中的swap会产生一堆深拷贝,效率就会降低,不如自己实现swap。
我们自己实现的swap,对于带动态开辟的成员的自定义类型的成员来说只是内部成员值的交换,地址不会发生改变,相对而言效率会更高一些。
还有一点就是在赋值重载和拷贝构造中是不能使用库中的swap来直接交换两个对象的。
看:
对于赋值运算符重载
如出现上面的代码,会直接崩掉。
因为在库中的swap中,赋值的时候会用到赋值运算符重载,而赋值运算符重载又要用到库中的swap,两个互相调,这样就死循环了。
对于拷贝构造
和上面的赋值运算符同理,传参的时候要调用拷贝构造,而拷贝构造又要调用库的swap,又死循环了。
最后一点,库中的swap和自己实现的swap不是函数重载,函数重载是在同一作用域下才会发生的,而库中的swap和我们自己实现的swap不是同一作用域。
我们的swap在类中,而且还套在命名空间里。
库里的swap在std中,虽然展开了,但也是在全局中的。
二者的实现是不在同一个作用域下的。
reserve 和 resize
reserve
先说reserve()
按照库中的reserve来。
库中的reserve没有缩小这一说的。
然后这里实现的时候要考虑new失败,所以搞了个tmp。
其他就没什么要注意的了,跟拷贝构造很像。
然后这个说完就可以说字符串追加了,但是还要说一下resize。
resize
代码如下:
细节就不讲了,很简单。
给几个例子:
字符串追加
三个:
- push_bach(专门追加字符)
- append(专门追加字符串)
- +=(两个都有)
按照库中的来,push_back没有返回值,后面两个返回值都是string&。
挨个说。
push_back
很简单,扩容的时候注意 _capacity 是否为0。
为零的情况就是初始化的时候没有给字符串,是默认的空字符串,此时 _capacity 就是0。
然后这个就讲完了。
append
代码:
注意的点:
首先要知道你要追加的字符串的长度 len 。
判断扩容的条件为 _size + len > _capacit 才是对的。这个很好理解,你觉得不好理解是因为全是字符太抽象了,给个例子就行了。比如说 _size = 0, len = 2, _capacity = 2。是不用扩容的,两个就够了,正好。我就不再多给例子了,你们自己好好琢磨一下就行。
运行一下:
+=
其实前面的这两个 push_back 和 append 已经顺带把这个讲了,因为 += 可以直接复用这两个函数。
看:
完事,就是这么简单。
运行:
insert
这个函数就是在 pos 位置处插入字符或者字符串。
实现起来比较恶心,尤其是插入字符串的时候对于边界的把控。
我感觉这个实现起来类似于插入排序的思想。
pos位置插字符
string& insert(size_t pos, const char& ch);
先给张图:
假如说我要在 2 下标处插入 #,那么结果就是这样:
大致思路就是,先讲pos位置后面的字符往后挪一个单位。
然后再把pos位置处的字符换成#。
定义一个end来表示字符串中的最后一个字符(‘\0’)的位置,通过end来后挪字符。
看图解:
然后就开整:
判断扩容这东西记住,只要是往里面加东西就要判断扩容。
同理,删除的时候,就要判空。
这要形成条件反射的。
运行:
多插入几个:
没有问题。
但是其实上面的代码是有问题的,当我在0处插入时:
光标一直在闪动,说明死循环了。
原因是循环那里出问题了。
这个地方,end是size_t的,pos也是size_t的,当pos为0时,end >=0 是永远成立的,虽然end在一直- -。其实每次到0的时候又变成一个非常大的数。就死循环了。
怎么解决呢?
可以将二者都改为int,但是这种方式是很挫的,因为库中给的pos就是 size_t。
也可以将二者判断时强转为int。
但是同样很挫。
我们要改一下思想。
让end初始值改为 _size + 1,然后数值在后挪的时候让 _str[end] = _str[end-1],然后判断条件改为 > 就OK了。
插字符的就到这,下面讲插字符串的。
pos位置插字符串
同样的思路,只不过字符在往后挪的时候要改变一下策略。
来例子:
还是第二个位置插#,只不过这次插三个。
再看下图解:
其实就是把插字符时代码中的1改为len就行。
len是插入字符串的长度。
就不讲那么多了,直接给代码:
如果对边界把控不熟悉,就画画图,给几个数值,比对比对就ok了。
比如说我写插入之前就要把图画出来,搞几个值标到下面,然后给几个例子,就写出来了。
图画的有点潦草,也不是给大家看的,就是给我看的,只是给大家说一下我怎么写的insert。。。
erase
有insert肯定少不了erase。
那么先看库中的实现:
string& erase(size_t pos, size_t len = npos)
还是pos位置开始删字符,前一篇讲string的时候也说了npos是string类中的一个静态成员变量,而且用const修饰了的。
这里要说一点:
普通的静态成员变量必须类内声明,类外初始化。
但是C++中有个恶心的地方,就是如果静态成员变量加了const修饰,就可以直接在声明处初始化。
如果此时在类外定义就会报错。
上面把npos设置为私有的了,但是库中的npos其实在类外是可以访问的,所以我们这里就要把npos设置为公有:
就这么些。
接着前面的讲:
删除的时候,len缺省值为-1,但是len是size_t的,那就可以说是无穷大了。也就是说,len不给值的时候就是默认pos位置往后的全部劈掉。这一点要注意。
给个例子:
2位置往后删两个。
直接给代码了:
还可以用strcpy:
这个就说到这。
>> 和 <<
每个类重载流提取和流插入都是很有必要的,因为重载了会好用很多。
注意要在类外定义,类内定义的话this指针会和流对象抢位置。
<<
ostream& operator<<(ostream& out, string& s)
直接给代码:
这里得用循环,不能用out << s.c_str();
因为如果_str中间有\0就没法全部打印。
>>
istream& operator>>(istream& in, string& s)
这里不能直接用in,因为字符串中如果包含空格cin是不认这个字符的,就导致没办法停止。
用in的代码如下:
运行:
像这样根本停不下来。
所以说不能用in,得换一个,用istream类中的get。
这个函数可以获得字符或者字符串,肯定是认识 空格 或者 \n 的。
这里获得字符就够了。
代码如下:
运行起来:
这样就ok了。
但是上面的>>效率是比较低的,因为用+=的时候,如果输入的字符串越长,那么扩容的次数就会越多,效率就变低了。
解决方法可以提前在while循环前直接给s扩容,比如下面这样:
但是这样又出现问题了,当输入的字符串比较短的时候如果后续s不变,就会永久性的浪费一些空间。
利用输入缓冲区的概念,再改改:
运行起来是成立的:
其实代码中还存在一个bug。
我们的:
标准库的:
可以看到,标准库里是先将s1中的字符清空,然后再重新输入。
所以还得改改:
在类中提供一个clear函数接口就行。
再运行:
<< 和 >> 到这就完了。
下面说一下find。
find
先看库中的:
实现找子串和字符的。
找字符:
找字串(这里偷个懒,直接用C库中的strstr了):
strstr这个函数如果找到子串返回的就是一个常量字符串,如果找不到就返回空。
像这种找子串的有专门的kmp算法和BM算法,如果各位感兴趣的话可以自己搜一搜了解一下。
substr
这个也要实现一下
这个函数功能是从 pos 位置开始产生一个长度为 len 的子串。
代码如下:
细节上就是对于边界的把控,其他就没啥了。
运行:
代码还可这样写:
我们再拿上一篇中的那个网址分割的测试一下我们substr和find是否正确。
结果正确。
最后一点
关于函数的日常string中也不会用那么多,前面的就够用了。
再说一下vs上的和gcc上的实现string和前面我们自己实现的区别。
vs上的
vs上是以空间换时间的做法。
vs中成员变量里面还有一个_buff数组,大小16,最后一个放字符 \0 ,这个数组是用来放长度小于16的字符串的,也就是说,当你初始化一个字符串的长度小于16时,就不会用到 _str,是直接用 _buff 数组的。
也就是这样:
这也就是为什么我们就算不初始化string对象,调试的时候能看到string中capacity的大小为15。
看:
上面库中的string对象大小为28,我们实现的大小为12,差了16,这16就差在buff数组上了。
当初始化字符串长度大于16时,就会不用buff,将字符串放到堆中,有_str来维护。
gcc下
这里采用的是引用计数 + 写时拷贝的方法。
引用计数是指有几个对象的 _str 指向某空间,计数就是多少,当计数大于1时,析构一个对象不会直接释放其 _str 所指向的空间,而是将那个计数-1。当剩余最后一个对象的时候,此时计数就是1,析构时才会释放空间。
写时拷贝本质是延时拷贝,当多个对象中的 _str 指向同一块空间,某个对象去修改这块空间的内容的时候才会为这个对象开辟一块新空间来让其修改,剩余对象仍指向那块空间,也就是拷贝对象的时候不直接深拷贝,先浅拷贝,当某个对象想要修改内容的时候再深拷贝。这样的方式就是当多个对象没有写入的时候就目的就达到了。
到此结束。。。