c++入门4--深入解析String类的实现奥秘
创作初心:在加深个人对知识系统理解的同时希望可以帮助到更多需要的同学
🛠️柯一梦主页详情
座右铭:心向深耕,不问阶序;汗沃其根,花自满枝。
今天我们来自主复现一个String类:
1.命名的注意事项
因为我们要自己实现一个名叫String的类,可能和库里面的String出现命名冲突,所以我们一般使用命名空间。但是测试文件,函数实现文件在声明、使用函数的时候,只能包含两个域:一个是命名空间域,一个是类域……这未免有些太麻烦了,我们最好在测试文件和函数实现文件中都包含一个命名空间!!!(多个文件可以共用一个命名空间,编译器最后会把他们合在一起)。
注意:我们为什么会命名冲突呢?
我们可以包含string的头文件,并且展开std(因为string类里面的函数实现都在std里),但是我们在自己写string的时候,就会和标准库里面的string混淆。所以就要在多个文件里面使用同一个命名空间把我们自己写的string包起来。我们在命名空间里面使用我们自己写的string的时候,一些东西比如<<,它会先在我们的命名空间里面匹配合适的,如果找不到合适的,就在命名空间外的std里面找
2.构造函数
**细节1:**成员变量我写的是char* _str,我在写构造函数的时候,接收的参数是const char* str,我想把这个参数拷贝给_str,我不能单纯地把参数的地址赋值给_str(这样的话就是浅拷贝,会相互污染,而且),而是要开辟一个新的字符数组,然后把字符数组的地址传给他,然后再把内容拷贝一下。
还有一个细节:在拷贝的时候我们可能会有两个不同的选择,strcpy和memcpy。但是这两个有什么区别呢?
- char* strcpy(char* dest, const char* src);strcpy的处理对象一般是以/0结尾的字符串,所以他的终止条件非常单一,就是遇到/0。而且strcpy会从dest的起始位置开始覆盖,直到复制完src的‘\0’为止。
- void* memcpy(void* dest, const void* src, size_t n);memcpy的处理对象可以是字符串,也可以是结构体、数组。但是memcpy可以传入参数n,来控制拷贝的src的长短,并且是严格执行,并不会因为/0就停止。
- 值得一提的是,dest是指针,也就是说可以从一串字符串或者数组的中间开始
- 有的同学还会问:我们为什么让_str直接指向str呢?因为我们的目的是把str里面的东西拷贝给_str。因为str指向的地方是一个常量串,我们就没办法修改了。
**细节2:**在解决完_str的赋值问题以后,我们再来解决一个小问题:
1 | string::string(const char* str) |
strlen和sizeof有区别:sizeof是在编译的时候运行的,strlen是在运行的时候调用的。所以在这里我们相当于调用了三次函数。有什么解决办法吗?
- 有的同学会说用赋值好的_size去赋值_str和_capacity:我们要牢记,c++的编译器在初始化自定义类型的时候,是按照声明顺序去初始化的,所以我们必须把_size的位置调到前面去,但是这样一来代码的耦合度就会很高……这不是我们想要的。
- 我们可以利用编译器先走初始化列表这个功能,先把_size赋值了,然后再在函数体里面对其余的两个成员变量初始化。
**细节3:**无参的构造函数和带参的构造函数可以合并,怎么搞呢?我们可以把带参数的构造函数给带上一个缺省值,但是这个缺省值我们给的也很讲究不能给空指针,因为空指针的话strlen会去访问,导致程序出错,所以最好的解决方法就是给’\0’。但是这句话里面有一个错误,因为我没传入的参数是一个const char*,是一个字符串地址,如果我们单单给一个’\0’编译器会报错,因为这不是一个字符串,所以我们可以给“\0”,因为字符串里自带一个”\0”,所以还有一个更简单的写法,啊那就是给一个空字符串“”。
3.析构函数、size函数、c_str函数、[]运算符重载、iterator的实现
析构函数、size函数、c_str函数都很简单就是直接返回类里面的东西
1 | string::~string() |
[]运算符重载我们要写两个,一个是不可修改内容的(不需要使用const修改this指针,和引用返回值),一个是可修改内容的(前面的反过来)
1 | const char& string::operator[](size_t i) const |
- iterator的实现也特别简单,我们的string的底层是一个字符数组,所以iterator也就被简化成了一个字符指针,原生指针只是string的一种实现方式。因为iterator是一个类型,所以我们需要重命名一个类型,也就是typedef char* iterator。此外,在我们实现了iterator之后呢,for(auto ch:s1)这个遍历也可以使用了,因为遍历for的底层就是去调用迭代器。
- 因为有const修饰的string对象,所以我们就不能再使用简单的iterator了,我们需要用const_iterator,同时参数传递的时候this指针也要用const修饰。
1 | string::iterator string::begin() |

4.三个函数push_back、append、operator+=、reserve
4.1push_back与reserve的实现
在数据结构中,只要是涉及了插入相关的知识的时候,我们首先要做的就是检查内存是否还够用。再检查的时候分两种情况:1、_capacity = 0,也就是一个字符都还没有插入的时候,这个时候我们不能盲目的使用_capacity*2。2、有插入的时候,这个时候我们就可以使用_capacity*2了,在使用完_capacity*2以后呢,我们得到了一个新的内存大小,这个时候我们怎么进行扩容呢?不仅是吧_capacity的数值改变一下,更重要的是_str所指向的空间……这个时候我们需要封装一个函数,叫作reserve!!!
我们先来实现一下reserve
1 | void string::reserve(size_t n) |
有三个小细节:
- 我们在开空间的时候,要先用一个新指针指向开辟的空间
- 所开的空间应该比传过来的参数n多一个位置
- 我们在使用memcpy的时候,我们要拷贝的字符个数应该是实际字符数+1,因为要把\0拷贝进去
- 首元素地址+数组里面的实际元素个数=‘\0’的位置
push_back的实现
1 | void string::push_back(const char ch) |
几个小细节:
- 我们确定要开辟的空间的时候,要先考虑_capacity是否为0,因为如果为0的话,2*_capacity就没有意义了
- 我们在尾插的时候,数组名+_size就表示\0的位置
- _size记得++
- 字符数组的最后记得放入‘\0’
4.2append的实现
append是尾插一个字符串,所以我们接收的参数就是const char* ch(以防是一个常量字符串)
1 | void string::append(const char* str) |
几个小细节:
- 我们还是要确定capacity的大小,如果_capacity<_size+len,那么比较_capacity*2与其的大小关系,如果是小的字符串,就可以直接扩二倍,如果是大的,就只开那么大就好了
- memcpy的时候,要拷贝len+1个值,因为要把\0拷进去
- _size记得变更数值
4.3operator+=的实现
operator+=既可以添加字符串,也可以添加一个字符。其实底层就是一个对push_back和append的封装
1 | string& string::operator+=(char ch) |
唯一值得注意的就是他的返回值了,应该是对象本身的引用
5.流输入运算符重载
流输出运算符重载有三种方式:
1 | ostream& operator<<(ostream& out, const string& s) |
1 | ostream& operator<<(ostream& out, const string& s) |
1 | ostream& operator<<(ostream& out, const string& s) |
几个小细节:
- 第一种输出方式有一个陷阱,就是如果字符串里面有’\0’那么就会中途终止,因为c_str是把string对象转换成const char*来进行输出。
- 我们应该使用迭代器或者[]字符索引的方式来进行输出
- 有的人在写reserve的时候使用的是strcpy,这个函数我们之前已经说过是遇到’\0’就停止,当内存不够的时候,我们就会扩容,在赋值原字符串到新字符串的时候,如果原字符串中间有’\0’就会停止,所以中间的那一段内存就空出来了,会导致乱码,出现烫烫烫……或者屯屯屯……
- append的时候可以使用strcpy,因为常量字符串中间不会有’\0’
6.insert和erase的实现
6.1insert的实现极为恶心!!!!!超级多小细节
我们先实现单个字符的插入
1 | void string::insert(char ch,size_t pos) |
直接点出小细节:
- 我们在判断pos是否越界的时候,结合他的类型是size_t,所以只需要让其小于_size即可
- 从普通逻辑来讲,移动方式决定了判别方式,也决定了赋值方式:也就是说end+1 = end的话end就赋值为’\0’的位置,其次判别方式也就顺理成章的变成了end>=pos(但是其中有一个巨大的坑,如果是头插的话,将无法判别end和pos的大小,这是由他们的类型决定的);所以这一块我们只能end>pos,那么逻辑也就只有一种就是end赋值为’\0’后面的那个字符位置,这样一来就不会出现越界
- 接上一个话题,有的人说在第一种方法下,我们把end改为int不就好了吗?end在变成0的时候-1=-1,这样就可以-1<pos(0)了,果真如此吗?在使用逻辑进行比较的时候,end是int类型,pos是size_t类型,size_t的类型高于int,会发生隐式转换,照样无法比较
- _size的值记得更改
字符串的插入:基于刚才的逻辑请你自己实现,并且对照着下面的这串代码进行更正
1 | void string::insert(const char* str,size_t pos) |
6.2erase的实现也很麻烦,非常的绕
1 | void string::erase(size_t pos, size_t len) |
实现过程中的一些小细节:
- 首先肯定是检查pos的越界问题
- 我们要先分成两类,一个是全删的,一个不是全删的,无论是全删还是删一部分,我们的逻辑都是1、改变_size的大小 2、将_str的_size的位置的元素赋值为’\0’
- 如果是删除中间一段的字符串,那么我们就要画图找逻辑,并且避免比较时候由于类型是size_t而产生越界无法比较的问题
- 我们在删完一部分以后,我们还要把后面的元素移到前面…在这里我们就可以直接使用memmove(这个函数可以自动调整读取的顺序,即使复制区域重叠,也可以完成复制)
7.find、substr、拷贝构造函数
7.1find的实现:
先看查找一个字符的:
1 | size_t string::find(char ch, size_t pos)const |
没什么难的,主要是查找失败的时候返回npos
查找一个字符串:
1 | size_t string::find(const char* str, size_t pos)const |
细节:
- 我们可以直接使用strstr去查找字符串(这个是c语言里面带的暴力查找)
- strstr的返回值是一个地址,如何把地址转化为下标呢?其实指针相减就是下标
7.2substr的实现:
1 | string string::substr(size_t pos, size_t len)const |
- substr是返回含一部分的字符串的string;
- 一开始的逻辑和erase的一样
- 后面就再定义一个对象,既可以使用+=,也可以使用memcpy对他们进行赋值
- 最后一个细节就是我们在返回的时候,这里涉及到构造函数,我们要先实现一个构造函数
7.3构造函数的实现:
我刚开始写构造函数的时候写出来了一个错误的:
1 | string::string(const string& s)const |
这里面有一个致命的错误,因为(*this)还没有被初始化,在reserve的函数体内呢,会出现访问_size的时候出现错误(因为是一个野指针)。所以我们必须使用new char[]的方式进行初始化
1 | void string::reserve(size_t n) |
这个逻辑就是对的
1 | string::string(const string& s) |
8.逻辑运算符>,>=,<,<=,==,!=的实现
8.1<的实现:
先看代码:
1 | bool string::operator <(const string& s)const |
细节:
- 我们在走循环的时候,需要定义两个变量,让他们去走各自的string
- 这样一来我们就可以在后续的判断大小的时候使用一个取巧的方式
- 这个取巧的方式就是看i1 i2和各自size的位置关系
8.2==的实现
1 | bool string::operator ==(const string& s)const |
9.cin、clear、getline的实现:
9.1cin的实现:
我们先看一串没有纠错之前的代码:
1 | istream& operator>>(istream& in, string& s) |
cin是一个对象,get()是cin的一个成员函数。
cin的作用:1.决定了从哪个流里面读取数据 2.里面有一个内置的隐形指针,标记着读到的位置
>>的作用:1.读取数据 2.维护那个指针
我们来聊一下为什么有时候会提示你输入,有时候却不会?
首先运行到>>的时候,他会从输入流里面读取数据,但是输入流里面没有数据,所以就会提示你输入数据,当流里有数据的时候,就会进行输入,在第二次运行的时候,>>会直接去流里找,如果第一次你输入的数据够多,第二次是不会让你重新输入的
这串代码的逻辑是我在控制台输入一串代码,中间可以有空格,这个串就被存在缓冲区里面,in>>ch就会一直从缓冲区里面读取字符。但是这串代码没考虑到的是空格不会进入ch,不会去比较。而且这段代码是一段死循环,不会返回。因为istream>>会一直跳过。
修改以后的代码:
1 | istream& operator>>(istream& in, string& s) |
c++的>>是可以把要输入变量里面的值先清空的。但是我们实现的cin>>却不会,所以我们要再写一个函数clear
clear非常好实现,不需要销毁空间,只需要把0位置置成‘\0’,再把size置成0
9.2clear的实现:
1 | void string::clear() |
9.3getline的实现:(getline的功能就是把一行里面的东西全部输入string对象里面)
1 | istream& string::getline(istream& in, string& s, char delim) |
默认的delim是”\n”,getline和cin的实现都有缺陷,因为我们只能+=一些字符,所以如果是一个长串的话就会*2 *2 *2……扩容很多次
下面是修改以后的代码:我们使用一个buff作为缓冲
1 | istream& operator>>(istream& in, string& s) |
1 | istream& getline(istream& in, string& s, char delim) |
10.赋值运算符重载
老式写法
1 | string& string::operator=(const string& s) |
现代写法就是,使用拷贝构造去开空间,然后再使用swap将temp和要构造的对象交换


