👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
前言
- 参考文档:点击跳转
目录
一、准备工作
为了方便管理代码,分两个文件来写:
Test.cpp
- 测试代码逻辑string.h
- 模拟实现string
二、string的结构
string
是一个管理字符数组的类,底层其实就是一个支持动态增长的字符数组,就像数据结构的动态顺序表。
string
类的成员变量有三个
- 字符指针
_str
指向开辟的动态数组 - 有效数据个数
_size
_capacity
记录容量的大小
还需要注意的是:这里新命名了命名空间域wj
,就是避免和库中的string
产生冲突。
三、string底层容量是否包含\0(capacity函数)
在模拟实现之前,首先要考虑底层的capacity
是否包含\0
,这里以VS 2019
为例(不同的编译器底层实现可能不同)
通过以上验证发现:
vs 2019
对于string
底层实现的默认容量是15
。- 容量包含
'\0'
。 - 大约是以
2
倍扩容。
【函数原型】
【代码实现】
四、常见构造函数
4.1 默认构造空字符串
无参构造默认是有
'\0'
以上代码有个易错点:要注意初始化列表的顺序,是按照成员变量的顺序来赋值的!
4.2 用C字符串构造
五、 拷贝构造
如果类中有动态分配内存的指针变量,则需要手动编写深拷贝的拷贝构造函数。编译器生成的默认只会完成浅拷贝。
六、 reserve函数
reserve
一般很少会缩容,一般都是扩容- 扩容原理:1. 开一块新的空间。2. 拷贝数据到新的空间。3. 释放旧空间然后指向新空间
七、验证构造函数 - c_str()
为了验证代码的正确性,需要打印出结果。由于自己模拟实现的string
,还没有实现重载流插入<<
,所以不能直接打印string
对象,而流插入<<
是可以自动识别内置类型的。因此string
是有提供转为内置类型的接口c_str
:
为什么会在函数后加个const
呢?在往期博客我们讲过:只要成员函数内部不修改成员变量,都应该加上const
接下来来测试代码:
- 无参构造
- 用C字符串构造
- 拷贝构造
八、析构函数
由于成员变量含有动态内存开辟的空间,因此要手动写出析构函数
九、普通遍历操作 (size接口 + [] 接口)
对于普通遍历,最基本的是要实现size
接口和[]
接口。
size
接口
【代码实现】
[]
接口
【代码实现】
【测试结果】
十、迭代器
- 迭代器
iterator
本质上就是一个容器的内嵌类型,这个迭代器可以是个自定义的类、也可以是typedef
的类型。而string
的迭代器本质就是一个char*
类型的指针,因此直接typedef
即可。- 但是要注意:
typedef
要写在public
段中,因为迭代器本身就是要给类外用的。
【代码实现】
【测试结果】
除此之外,范围for
的底层就是迭代器
十一、尾插
11.1 push_back - 尾插一个字符
在尾插字符之前,需要考虑容量是否足够。
【测试结果】
11.2 append - 尾插字符串
append
有很多函数形式,这里只实现常用的
【代码实现】
【测试结果】
11.3 operator+= – push_back和append升级版
+=
运算符重载既可以尾插一个字符,还可以尾插字符串。因此,直接复用push_back
和append
即可。
【测试结果】
十二、插入insert
string
的插入接口设计有点冗余,我们直接实现最常见的即可
- . 在
pos
位置插入n
个字符
【思路】
- 首先要判断下标的合法性。
- 其次还要判断插入的字符加上原有的字符是否超过当前容量,超过就扩容。
- 然后就是挪动数据和插入数据。注意挪动数据一定要从最后一个字符
'\0'
开始挪动n
次;不能从pos
位置开始挪,否则后面的内容就被覆盖了。以下是动图展示
通过思路分析,不难可以写出以上代码。但是以上代码有一个bug
,当头插时,程序就如下图一直在闪烁光标。
通过走读代码我们发现:pos
和end
的类型是都是无符号类型size_t
。因此end
最后自减到-1
,由于类型是size_t
,而无符号的-1
是一个相当大的数,循环条件成立就会一直死循环下去。
因此这里有两种方法:
第一种:将pos
和end
的类型全部改为int
这种方法虽然可以,但是和库里提供的参数类型还是有所差别的,因此还是有些不好。
void insert(int pos, size_t n, char x)
{
// 判断下标pos的合法性
assert(pos >= 0 && pos <= _size);
// 可能存在扩容
if (_size + n > _capacity)
{
reserve(_size + n);
}
// 挪动数据
int end = _size;
while (end >= pos)
{
_str[end + n] = _str[end];
end--;
}
// 插入数据
for (int i = 0; i < n; i++)
{
_str[pos + i] = x;
}
_size += n;
}
第二种:既然size_t
类型的end
自减到-1
就会死循环,那么加个end != -1
不就完事了。恰好,string
库里提供了公共静态成员常量npos
,这个常量使用值就是用-1
定义。
所以,最终代码如下:
需要注意的是:静态成员需要在类外定义。
【测试结果】
- 在
pos
位置插入字符串
思路和以上类似
【测试结果】
十三、删除操作erase
【思路】
- 首先要检查下标的合法性
- 删除要分情况讨论:
第一种:当前下标往后的字符全都需要删除
第二种:删除的字符是符合范围内的
【测试结果】
十四、查找find
- 查找字符
- 查找字符串
查找子串可以有很多方法,最简便就是使用C语言中的strstr
函数
十五、字符串截取substr
十六、改变有效字符个数resize
三种情况:
- 当
n
小于size
,相当于删除数据,保留n
个字符- 当
n
等于size
,则保留原数据- 当
n
大于size
,则会增加字符,同时后面会补充n - size
个字符(不指定默认是'\0'
)
十七、流插入<<
成员函数默认第一个形参都是对象的地址,也就是隐藏的this
指针。由于cout
抢占了对象的第一个位置,因此不能当做成员函数,就只能写在类外。
此外,这里需要需注意:
C形式字符串
:以字符数组的形式存储的,通过'\0'
来确定字符串何时结束。string字符串
:string
类它可以自动追踪字符串的长度,并且不需要以'\0'
结尾来表示字符串的结束。有多少字符就打印多少字符。
【代码实现】
【测试结果】
十八、流提取>>
18.1 朴素版本
注意:用>>
读取string
对象,默认读取到空格或者换行就不会往下读了
【测试结果】
如以上结果:当我输入完字符并且回车后,结果并没有显示出来;并且程序进入了阻塞状态。这里有一个输入的小细节:
cin
在默认情况下不会读取空格和换行。当使用>>
操作符读取字符串时,它会自动跳过开头的空白字符(包括空格、制表符和换行符),然后将连续的非空白字符作为一个字符串读取。
为了解决这个这个问题,istream
类还提供一个接口get()
:读取一个字符(包括空格和换行)
【测试结果】
但以上代码还是不够完善,当多次对一个对象进行输入时,以上代码并没有对之前形成一次覆盖,可以对比库里的string
因此,在输入之前要清除对象的内容。
【测试结果】
还有一个问题,当一开始输入连续空格或者换行时,可以对比自己实现的和库里的:
因此要过滤前面的空格或者换行
【测试结果】
18.2 优化版本
但是以上代码的性能不够好,当输入好几个字符时,由于+=
就会不断进行扩容,就会有损耗。
优化思路:提前开128
个空间的字符数组,减少扩容的消耗(vs
的底层也是这么实现的)
18.3 getline
getline
可以读取到空格,但不能读取换行。
【代码实现】
十九、清空操作clear
直接将第一个字符改成'\0'
即可
二十、比较操作
- 字符串比较都是按照ASC码比。
20.1 <
20.2 ==
20.3 <=
20.4 >
20.5 >=
20.6 !=
二十一、交换swap
十七、赋值运算符重载
17.1 为什么要写手动写赋值运算符重载
由于string
类有动态开辟的成员变量。如果不写深拷贝,两个对象会同时指向动态开辟的空间,就会导致析构两次的问题。
17.2 法一:传统写法
- 首先开一个和
s2
同样大的空间并且把s2
的数据拷贝- 然后再释放掉
s1
指向的空间- 最后再让
s1
指向新拷贝的那个空间
17.3 法二:现代写法
- 用
s2
拷贝构造tmp
的对象,然后再让tmp
和s1
交换。(s1 = s2
)
17.4 法二延伸
十九、源码
本篇博客的代码仓库地址:点击跳转