深入解析C++ String类的实现奥秘
创作初心:在加深个人对知识系统理解的同时希望可以帮助到更多需要的同学
😄柯一梦的专栏系列
🚀柯一梦的Gitee主页
🛠️柯一梦主页详情
座右铭:心向深耕,不问阶序;汗沃其根,花自满枝。
今天我们来自主复现一个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的赋值问题以后,我们再来解决一个小问题:
string::string(const char* str):_str(new char[strlen(str)+1]),_size(strlen(str)),_capacity(strlen(str))
{strcpy(_str, str);
}
const char* string::c_str() const
{return _str;
}
strlen和sizeof有区别:sizeof是在编译的时候运行的,strlen是在运行的时候调用的。所以在这里我们相当于调用了三次函数。有什么解决办法吗?
- 有的同学会说用赋值好的_size去赋值_str和_capacity:我们要牢记,c++的编译器在初始化自定义类型的时候,是按照声明顺序去初始化的,所以我们必须把_size的位置调到前面去,但是这样一来代码的耦合度就会很高......这不是我们想要的。
- 我们可以利用编译器先走初始化列表这个功能,先把_size赋值了,然后再在函数体里面对其余的两个成员变量初始化。
细节3:无参的构造函数和带参的构造函数可以合并,怎么搞呢?我们可以把带参数的构造函数给带上一个缺省值,但是这个缺省值我们给的也很讲究不能给空指针,因为空指针的话strlen会去访问,导致程序出错,所以最好的解决方法就是给'\0'。但是这句话里面有一个错误,因为我没传入的参数是一个const char*,是一个字符串地址,如果我们单单给一个'\0'编译器会报错,因为这不是一个字符串。
3.析构函数、size函数、c_str函数、[]运算符重载、iterator的实现
析构函数、size函数、c_str函数都很简单就是直接返回类里面的东西
string::~string()
{delete[]_str;_str = nullptr;//?_size = _capacity = 0;
}
size_t string::size()
{return _size;
}
const char* string::c_str() const
{return _str;
}
[]运算符重载我们要写两个,一个是不可修改内容的(不需要使用const修改this指针,和引用返回值),一个是可修改内容的(前面的反过来)
const char& string::operator[](size_t i) const
{assert(i >= 0 && i < _size);return _str[i];//返回的是什么类型,主要是看函数的头,而不是说你堂而皇之的写一个 &_str[i],这是不对的
}
char& string::operator[](size_t i)
{assert(i >= 0 && i < _size);return _str[i];
}
- iterator的实现也特别简单,我们的string的底层是一个字符数组,所以iterator也就被简化成了一个字符指针,原生指针只是string的一种实现方式。因为iterator是一个类型,所以我们需要重命名一个类型,也就是typedef char* iterator。此外,在我们实现了iterator之后呢,for(auto ch:s1)这个遍历也可以使用了,因为遍历for的底层就是去调用迭代器。
- 因为有const修饰的string对象,所以我们就不能再使用简单的iterator了,我们需要用const_iterator,同时参数传递的时候this指针也要用const修饰。
string::iterator string::begin()
{return _str;
}
string::iterator string::end()
{return _str + _size;
}

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

