深入解析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。但是这两个有什么区别呢?

  1. char* strcpy(char* dest, const char* src);strcpy的处理对象一般是以/0结尾的字符串,所以他的终止条件非常单一,就是遇到/0。而且strcpy会从dest的起始位置开始覆盖,直到复制完src的'\0'为止。
  2. void* memcpy(void* dest, const void* src, size_t n);memcpy的处理对象可以是字符串,也可以是结构体、数组。但是memcpy可以传入参数n,来控制拷贝的src的长短,并且是严格执行,并不会因为/0就停止。
  3. 值得一提的是,dest是指针,也就是说可以从一串字符串或者数组的中间开始
  4. 有的同学还会问:我们为什么让_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; } ![](https://i-blog.csdnimg.cn/direct/a4f95cb68b6a443f9e302b143a693928.png) ## 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; } } 有三个小细节: 1. 我们在开空间的时候,要先用一个新指针指向开辟的空间 2. 所开的空间应该比传过来的参数n多一个位置 3. 我们在使用memcpy的时候,我们要拷贝的字符个数应该是实际字符数+1,因为要把\\0拷贝进去 4. 首元素地址+数组里面的实际元素个数='\\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'; } 几个小细节: 1. 我们确定要开辟的空间的时候,要先考虑_capacity是否为0,因为如果为0的话,2\*_capacity就没有意义了 2. 我们在尾插的时候,数组名+_size就表示\\0的位置 3. _size记得++ 4. 字符数组的最后记得放入'\\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; } 几个小细节: 1. 我们还是要确定capacity的大小,如果_capacity\<_size+len,那么比较_capacity\*2与其的大小关系,如果是小的字符串,就可以直接扩二倍,如果是大的,就只开那么大就好了 2. memcpy的时候,要拷贝len+1个值,因为要把\\0拷进去 3. _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< 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; > } 几个小细节: 1. 第一种输出方式有一个陷阱,就是如果字符串里面有'\\0'那么就会中途终止,因为c_str是把string对象转换成const char\*来进行输出。 2. 我们应该使用迭代器或者\[\]字符索引的方式来进行输出 3. 有的人在写reserve的时候使用的是strcpy,这个函数我们之前已经说过是遇到'\\0'就停止,当内存不够的时候,我们就会扩容,在赋值原字符串到新字符串的时候,如果原字符串中间有'\\0'就会停止,所以中间的那一段内存就空出来了,会导致乱码,出现烫烫烫......或者屯屯屯...... 4. 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'的位置给end while (end > pos) { _str[end] = _str[end-1];//这一段极其重要,我们把'\0'的位置给了end,我们要把\0带着一起往后挪动 --end; } _str[pos] = ch; ++_size; } 直接点出小细节: 1. 我们在判断pos是否越界的时候,结合他的类型是size_t,所以只需要让其小于_size即可 2. 从普通逻辑来讲,移动方式决定了判别方式,也决定了赋值方式:也就是说end+1 = end的话end就赋值为'\\0'的位置,其次判别方式也就顺理成章的变成了end\>=pos(但是其中有一个巨大的坑,如果是头插的话,将无法判别end和pos的大小,这是由他们的类型决定的);所以这一块我们只能end\>pos,那么逻辑也就只有一种就是end赋值为'\\0'后面的那个字符位置,这样一来就不会出现越界 3. 接上一个话题,有的人说在第一种方法下,我们把end改为int不就好了吗?end在变成0的时候-1=-1,这样就可以-1\ _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; } } 实现过程中的一些小细节: 1. 首先肯定是检查pos的越界问题 2. 我们要先分成两类,一个是全删的,一个不是全删的,无论是全删还是删一部分,我们的逻辑都是1、改变_size的大小 2、将_str的_size的位置的元素赋值为'\\0' 3. 如果是删除中间一段的字符串,那么我们就要画图找逻辑,并且避免比较时候由于类型是size_t而产生越界无法比较的问题 4. 我们在删完一部分以后,我们还要把后面的元素移到前面...在这里我们就可以直接使用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; } } 细节: 1. 我们可以直接使用strstr去查找字符串(这个是c语言里面带的暴力查找) 2. 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; } 1. substr是返回含一部分的字符串的string; 2. 一开始的逻辑和erase的一样 3. 后面就再定义一个对象,既可以使用+=,也可以使用memcpy对他们进行赋值 4. 最后一个细节就是我们在返回的时候,这里涉及到构造函数,我们要先实现一个构造函数 ### 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._size;//只有这种情况是true,其余都是false } 细节: 1. 我们在走循环的时候,需要定义两个变量,让他们去走各自的string 2. 这样一来我们就可以在后续的判断大小的时候使用一个取巧的方式 3. 这个取巧的方式就是看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与hellox return 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; }

相关推荐
蜗牛沐雨4 小时前
详解c++中的文件流
c++·1024程序员节
无聊的小坏坏4 小时前
从零开始:C++ TCP 服务器实战教程
服务器·c++·tcp/ip
老王熬夜敲代码5 小时前
C++继承回顾
c++·笔记
qq_310658515 小时前
webrtc代码走读(六)-QOS-FEC冗余度配置
网络·c++·webrtc
Aevget6 小时前
从复杂到高效:QtitanNavigation助力金融系统界面优化升级
c++·qt·金融·界面控件·ui开发
jf加菲猫6 小时前
条款20:对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
开发语言·c++
jf加菲猫7 小时前
条款21:优先选用std::make_unique、std::make_shared,而非直接new
开发语言·c++
scx201310047 小时前
20251019状压DP总结
c++
m0_748240258 小时前
C++ 游戏开发示例:简单的贪吃蛇游戏
开发语言·c++·游戏