欢迎来到 CILMY23的博客
🏆本篇主题为:模拟实现std::string,深入解析内部实现机制:从大小到运算符重载的探索之旅【万字详解】
🏆个人主页:CILMY23-CSDN博客
🏆系列专栏:Python | C++ | C语言 | 数据结构与算法 | 贪心算法 | Linux | 算法专题 | 代码训练营
🏆感谢观看,支持的可以给个一键三连,点赞收藏+评论。如果你觉得有帮助,还可以点点关注
目录
前言
在上一篇 【STL专题】深入探索C++之std::string:不止于字符串【万字详解】,当中我们了解了string的基本用法和一些基本属性,这些东西可以在我们的文档库查询string - C++ Reference (cplusplus.com)
今天我们就来模拟实现一些string的成员函数,以及一些接口。
我们大致的思维导图如下:
接下来我们就仿照这个思维导图一步步实现我们的string,首先文件配置如下:
我们的这个类,被我们封装在命名空间中,这个命名空间可以自己设定名字,我的如下:
准备好这些后,我们就开始吧
模拟实现string
一、基本属性
这一块和顺序表类似,string容器我们要知道size,即有效字符的长度,_size 指向 '\0',我们要知道容量,即顺序表中的空间大小,以及一个开始的头指针,char * str。另外,我们还会用到npos这个静态成员变量。 如果有不懂的友友,可以再回顾一下上一篇中,我是如何介绍npos的,我们说它是一个size_t (无符号整数)的最大值。
代码如下所示,这样我们就完成了string的基本设置。
namespace CILMY
{
class string
{
public:
private:
char* _str;
size_t _size;
size_t _capacity;
public:
static const int npos;
};
const int string::npos = -1;
}
二、string的构造函数和析构函数
如果有忘记的部分,可以点击以下链接,回顾一下构造函数和析构函数.
【C++】C++中的构造函数和析构函数详解https://blog.csdn.net/sobercq/article/details/138132643
💫 构造函数
在写构造函数的时候我们更喜欢写全缺省这种的构造函数,方便简洁。
当我们想初始化一个string的时候我们会遇到以下两种情况:
- 我们会用一个常量字符串传递
- 我们可能什么都不传递,就只写string s.
我们先拿第一种情况举例
假设传进来一个常量字符串,我们可以先获取长度,让空间也和长度相等。问题又来了,我们应该如何把str存进去呢,这时候我们就要开辟空间了,然后我们再利用strcpy给它拷贝进去。注意:开辟空间的时候要+1
那如果是第二种情况呢?
那如果外界什么都不传递,那还怎么拷贝呢?
其实我们可以用缺省值来解决这个问题,那缺省值又设定什么呢?实际上,C语言中的字符串在末尾会默认认定一个"\0",所以我们可以考虑把这个给拷贝进来。
问题又来了,当我们拷贝的时候,我们知道strcpy遇到"\0"就停止了,所以实际上我们什么都没拷贝,只是在新空间的开头默认放了一个"\0",这也是为什么我刚刚把capacity+1的原因。
这样我们的构造函数就实现完成了。(感兴趣的大伙可以自己写测试代码测试一下,我会写些函数放在文章末尾。)
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
💫 析构函数
析构函数的实现比较容易,这里就不多讲解啦.(总共三步走,delete释放空间,指针置空,长度和空间都为0)
~string()
{
delete[]_str;
_str = nullptr;
_size = _capacity = 0;
}
三、string的遍历
那初始化string后,我们还想看看我们的里面都存了啥,这时候我们就要遍历我们的string了,遍历一共有三种方式:
如果我们想遍历字符串,我们需要再写一个函数来获取长度。
//获取长度
size_t size() const
{
return _size;
}
💫 for + []
如果我们想实现string[]就必须重载运算符[].
注意一点:防止越界!我们可以用assert来检查是否越界.
// []重载
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
for循环的遍历也很简单,主要是因为要重载运算符[],使用起来和数组一样,真的是方便快捷.
for (int i = 0; i < s1.size(); i++)
{
cout << s1[i] << " ";
}
cout << endl;
💫 迭代器iterator和范围for
迭代器到底是什么?我们说它像指针,但不一定是指针,有可能是指针,今天我们就写string是指针的版本。注意:不是所有的迭代器都是指针,这里的迭代器可以用指针来实现。
将char* 用typedef重命名成iterator。
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
那范围for呢?
范围for语法解释很牛逼
自动取数据给ch,自动++,编译底层的时候,其实还是一个迭代器。
我们可以通过反汇编看:
到这,我们的三种遍历方式就验证完了
四、增删查改
💫 增加
💦💦 push_back
这一块和顺序表差不多,首先在添加之前我们肯定要扩容,这就不得不提及我们上一篇中提到的reserve了
💦💦 reserve
那我们如何实现它呢?
我们来看以下原理图:
我画的原理图有点小乱,我来解释解释
首先开辟一个新空间,接着把旧空间的字符拷贝进新空间,释放旧空间,让旧空间指针指向新空间。最后让_capacity = n 即可。
特别注意:
reserve扩容要给'\0'留一个位置哦
这一块大致和顺序表类似。只是我们用上了new和delete。
那什么时候要扩容?
就是当我们空间不够的时候要扩容,故当n > capacity的时候,我们就要扩容了,一般我们可以按照1.5倍扩容,或者2倍扩容。
//reserve扩容
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
尾插过程:
这部分比较简单,我就不多阐述了,上代码!
push_back 代码
//push_back
void push_back(char ch)
{
//扩容
//当空间为0的时候,我们开4个空间
//空间不为0,按照2倍扩容
//push_back 是尾插
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
💦💦 append
append
是 string
的一个成员函数,用于向字符串的末尾添加内容。
我们拿个例子看看就知道了,就如我以下所示,我们要知道尾插进来字符串的长度,知道长度后,才好插入,在原来的空间给它留足位置,拷贝进去。
这样我们的append就写好了。
//append
void append(const char* ch)
{
size_t len = strlen(ch);
if (_size + len >= _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
strcpy(_str + _size, ch);
_size = _size + len;
}
💦💦 +=运算符重载
最经常使用的,肯定是+=了;
这里我们直接复用append和push_back即可。
//+= 一个字符
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
//+= 一个字符串
string& operator+=(const char* ch)
{
append(ch);
return *this;
}
💫 插入
插入分两种情况,但都是在pos位置插入,要么插入一个字符,要么插入一个字符串。
首先我们来看插入一个字符的情况。
🍀🍀一个字符的插入
我们要挪动数据,给pos位置留出一个空间出来。
🍃中间插入
🍃尾插
尾插的过程和中间插入差不多
到这我们的代码就差不多了。
//insert 插入一个字符
void insert(size_t pos, char ch)
{
//对pos进行检查
assert(pos <= _size);
//扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//挪动数据
size_t end = _size + 1;
while (end - 1 >= pos)
{
_str[end] = _str[end - 1];
end--;
}
_str[end] = ch;
_size++;
}
🍃头插
我们信誓旦旦的上测试后发现,头插崩溃了
看了半天没看懂,直接上调试-->,我们发现它一直在挪动数据,问题来了
为什么一直在挪动数据?
其实,这个二进制码有关,我们知道无符号整数,它一直都是>=0的,(不明白的uu可以看链接) ,这就导致一个问题,这个循环永远都是>=pos的,所以我们要把类型都转换成int。那只变一个行不行呢?
答案是也不行
因为一个运算符两边的操作数如果类型不同,会发生一个现象,类型提升,一般是范围小的向范围大的提升。无符号整数的范围更大,因此都转变成了size_t
一个字符的插入代码
//insert 插入一个字符
void insert(size_t pos, char ch)
{
//对pos进行检查
assert(pos <= _size);
//扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//挪动数据
int end = _size + 1;
while (end - 1 >= (int)pos)
{
_str[end] = _str[end - 1];
end--;
}
_str[end] = ch;
_size++;
}
这样就解决了,我们的三种情况,头插,尾插,中间插入。
🍀🍀一个字符串的插入
大部分过程都和上述一致,我们可以把前面的代码扣下来进行改造。
我们要插入一个字符串,就要知道这个字符串的长度,预留足够的空间,我们看末尾位置,要预留足够的空间,直接留长度为len即可,然后我们开始挪动数据,我们只要保证end - len 不在pos位置左边即可。
扩容条件不在是_size == capacity 了,而是判断空间够不够我留下len长度的空间。
数据如何拷贝?
这里我们不能再使用strcpy了,而是拷贝固定长度的strncpy,因为strcpy会多放一个'\0'在拷贝位置的末尾。
一个字符串的插入代码
//insert 插入一个字符串
void insert(size_t pos, const char* ch)
{
//对pos进行检查
assert(pos <= _size);
size_t len = strlen(ch);
//扩容
if (_size + len >= _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//挪动数据
int end = _size + len;
while (int(end - len) >= (int)pos)
{
_str[end] = _str[end - len];
end--;
}
strncpy(_str + pos, ch, len);
_size += len;
}
对这段代码我们也可以优化一下:
如果我们不想要强制类型转换的话:
//insert 插入一个字符串
void insert(size_t pos, const char* ch)
{
//对pos进行检查
assert(pos <= _size);
size_t len = strlen(ch);
//扩容
if (_size >= _capacity - len)
{
reserve(_size + len);
}
//挪动数据
int end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
end--;
}
strncpy(_str + pos, ch, len);
_size += len;
}
这里的扩容可以按照你想要的方式去设计,我是两倍扩容,当然你可以只扩容len扩容,就如我优化的一样。
其次是实现完insert,append和push_back,可以复用insert,push_back,就是在size这个位置插入一个字符,append就是在size这个位置插入一个字符串.
//push_back
void push_back(char ch)
{
insert(_size, ch);
}
//append
void append(const char* ch)
{
insert(_size, ch);
}
💫 删除
上期我们是这么说的:
erase还是挺好用的,erase 函数用来删除字符串中的一部分内容,是一个非常有用的成员函数,允许多种不同的用法以适应不同的需求。
erase的删除有两种情况
1.len == npos,len + pos >= _size意味着全部删除
2.指定位置删除。
☘️☘️对pos进行检查
pos的位置要不要等于size?
答案:不能,因为size指向的是'\0'。
☘️☘️ pos位置后的全删除
这里的删除并非是全部删除,跟顺序表一样,我们只需要把'\0'放在pos位置即可.注意,全部删除的情况还包括,len 的长度加pos位置开始,超过有效字符size的大小。
但是这里也存在溢出的情况,算是一个小bug,所以我们可以用len >= _size - pos 来表示.
☘️☘️只删除一部分
我们只需要把pos+len位置之后的数据,用strcpy拷贝过来即可。
//erase删除
void erase(size_t pos, size_t len = npos)
{
//检查
assert(pos < _size);
//删除
if (len == npos || len >= _size - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
//挪动数据
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
💫 resize
我们可以先来回顾一下resize的用法:
resize重载了两个函数:
resize(size_t n) 与 resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字
符个数增多时;resize(n)用0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。
resize分两种情况,1.将元素减少 2.将元素增多
将元素减少
如果要让元素减少,直接在这个n的位置加个'\0',就相当于减少了字符个数.
将元素增加
那我们就要用c去填充这中间的空白部分,然后在最后n这个位置插入一个'\0';
resize代码:
void resize(size_t n, char ch = '\0')
{
if (n <= _size)
{
_str[n] = '\0';
//空间保持不变,有效字符个数减少
_size = n;
}
else
{
reserve(n);
for (int i = _size; i < n; i++)
{
_str[i] = ch;
}
_str[n] = '\0';
_size = n;
}
}
💫 swap
我们的库里面也有一个swap,swap主要就交换,但是下面这个swap是不是效率不高呢?
我们看它的代码是拷贝构造一个c出来,然后去调用拷贝构造函数,一共三次拷贝,然后调用析构函数,释放c。代价太大,所以我们可以自己写一个swap.
我们的思路也很简单,直接将对应的变量交换即可.没有必要调用拷贝构造函数去拷贝那么多。
//swap
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
💫 查找---find和substr
☘️☘️ find
回顾上期 find 的用法:
如果我们想覆盖字符串,删除,替换,需要找到一个位置的时候,find()就显得极其重要。find 函数用于在字符串内查找子串或字符的第一个出现位置。如果找到了匹配项,它就返回匹配项的下标;如果没有找到,它则返回 npos,这是一个特别定义的常量,表示不存在的位置。
第一眼是暴力循环,我们直接搞两层循环找就完了。那当然,我们实现底层不这么搞,所以我们先把单个字符的解决了再想找子串的吧。
⛵⛵find 一个字符
看过程图就很容易理解了,假设我们找到了,我们就返回 i 这个下标,找不到就返回npos。
代码:
size_t find(char ch, size_t pos = 0)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
⛵⛵find 找一个子串
找一个子串就可以用我刚刚说的暴力结束,但是这样代码不简洁,我们可以使用 strstr (链接)来解决。
过程图如下:
strstr 返回的是一个指针,那指针之间的距离是一个整数,同时如果我们减去的起始位置的指针,那它就是下标位置。
size_t find(const char* sub, size_t pos = 0)
{
//对pos位置检查
assert(pos < _size);
const char* p = strstr(_str + pos, sub);
if (p)
{
return p - _str;
}
else
{
return npos;
}
}
☘️☘️ substr
回顾上期:
substr 函数用于从字符串中提取一个子串。这个方法非常灵活,允许你指定开始位置和需要提取的子串的长度。如果不指定长度,则默认提取从开始位置到字符串末尾的所有字符。
这个函数会返回一个新的 string 对象,包含从 pos 开始、长度为 len 的子串。如果 pos 是字符串的长度或更大,函数会抛出out_of_range 异常。如果 pos 加上 len 超出了字符串的末尾,那么只会提取到字符串的末尾为止的子串。
如果 pos 加上 len 超出了字符串的末尾,那么只会提取到字符串的末尾为止的子串。也就是说从pos位置开始后面的字符串都要了。
我们可以防止溢出,写成
其余情况,就是pos+len < _size,我们就把这个区间的所有的字符串搞进去,直到小于pos + len。
string substr(size_t pos = 0, size_t len = npos)
{
string sub;
if (len >= _size - pos)
{
for (size_t i = pos; i < _size; i++)
{
sub += _str[i];
}
}
else
{
for (size_t i = pos; i < pos + len; i++)
{
sub += _str[i];
}
}
return sub;
}
5.运算符重载
💫 赋值运算符和拷贝构造函数
🌍🌍 拷贝构造函数
拷贝构造函数,最经常使用的情景就是用另外一个字符串然后来拷贝一个新的字符串,这里涉及深拷贝,如果有不懂深浅拷贝的,也可以点击链接看看
拷贝构造函数的大致过程如下:我画的比较简单,理解起来就是,我们要开辟一个空间和s一样的,然后把s当中的字符串拷贝到我们新开辟的空间当中,并且把'\0'加入进去.
代码:
//拷贝构造函数
string(const string& s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
优化:
这里也可以不写tmp.
拷贝构造函数代码
//拷贝构造函数
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
🌍🌍赋值运算符重载
一般用到赋值运算符我们会考虑以下三种情况:
- 将已有的string 拷贝给新的 string
- 拷贝一个字符串
- 拷贝一个字符
这一块比较简单就不多详解了,大家感兴趣可以自己分析试试看,如果你有什么更好的想法,或者意见,欢迎在评论区阐述。
//赋值运算符重载
//s2 = s1
string& operator=(const string& s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
return *this;
}
// s2 = "cilmy"
string& operator=(const char* str)
{
size_t len = strlen(str);
char* tmp = new char[len + 1];
strcpy(tmp, str);
delete[] _str;
_str = tmp;
_size = _capacity = len;
return *this;
}
//s2 = 'c'
string& operator=(char ch)
{
char* tmp = new char[2];
delete[] _str;
_str = tmp;
_str[0] = ch;
_size = _capacity = 1;
_str[1] = '\0';
return *this;
}
6.特殊接口
💫 C字符串-- c_str
回顾一下:
c_str() 返回一个指向正规C字符串的指针,常量,即以空字符结束的字符数组。这个方法用于获取一个C风格的字符串版本,通常是为了与需要传统C字符串的C语言API兼容。
所以其实我们只需要返回一个指针即可。
//c_str接口
char* c_str()
{
return _str;
}
7. 关系比较
这一块的思路跟之前类似,我们可以直接写两个,然后其它复用即可。
有两种思路,
一种是每个字符都遍历比较过去,然后比较大小即可。
二、利用C语言库里的strcmp来比较两个字符串。
strcmp:
首先我们实现 < 和 ==
这些关系比较都是重载成全局的非成员函数。
//关系比较
bool operator> (const string& s, const string& s1)
{
int tmp = strcmp(s.c_str(), s1.c_str());
return tmp > 0;
}
那这里会报错,是因为我们不支持const的c字符串。
增加一个const的c字符串重载函数即可。
const char* c_str() const
{
return _str;
}
那这里是,如果比较对了,返回1,比较不对,就返回0.
基本上实现两个关系就其他就可以复用了,比如实现大于和等于,小于和等于,或者小于不等于这样。那我这里是小于,大于,等于都实现了。大家可以按照喜欢的来实现。
//关系比较
bool operator> (const string& s, const string& s1)
{
int tmp = strcmp(s.c_str(), s1.c_str());
return tmp > 0;
}
bool operator== (const string& s, const string& s1)
{
int tmp = strcmp(s.c_str(), s1.c_str());
return tmp == 0;
}
bool operator>= (const string& s, const string& s1)
{
return (s > s1 || s == s1);
}
bool operator< (const string& s, const string& s1)
{
int tmp = strcmp(s.c_str(), s1.c_str());
return tmp < 0;
}
bool operator<= (const string& s, const string& s1)
{
return !(s > s1);
}
bool operator!= (const string& s, const string& s1)
{
return !(s == s1);
}
8.流插入和流提取
流提取 和 流插入,其实就是和我们使用cout << /(cin >>)
在做输入操作/(输出)的时候一样,控制台会先去等待我们输入一个值/(通过去缓冲区中拿取数据,然后将其显示在控制台上)
💫 流插入
那流插入是输出到屏幕上。所以这里模拟实现cout。
string的流插入比较简单,就是直接把字符遍历打印到屏幕上即可。
//流插入
ostream& operator<<(ostream& out, string& s)
{
for (auto e : s)
{
out << e;
}
return out;
}
问题来了,我们之前在实现日期计算器的时候用到了友元,为什么这里不用?
实际上,我们实现日期计算器的时候讲过,如果类里提供函数,像java那样,我们也可以不使用友元,使用友元是为了方便访问私有成员变量,这里我们没有要访问私有成员变量,所以不用友元。
💫 流提取
流提取的初步思路也是这样,我们直接提取出一个字符,然后给它增加到s当中即可。
//流提取
istream& operator>> (istream& in, string& s)
{
char ch;
in >> ch;
while (ch != ' ' && ch != '\n')
{
s += ch;
in >> ch;
}
return in;
}
但是运行后发现,它卡在死循环这里了,这是为什么呢?
这是因为,C++的cin,c语言的scanf取不到换行或者空格,这里就会没有结束,跳不出循环。
那C语言如何解决呢?
c语言用getchar,getc都可以读到
特别注意,c语言和c++的输入输出不混用,它们会各自读到自己的内存缓冲区,也就是C语言有C语言的流读取内存区块,C++也有自己独立的流读取内存区块。
C++中的istream其实是一个类。
istream - C++ Reference (cplusplus.com)
它提供的函数
可以取下任意一个字符。
因此我们的代码可以优化成以下情况:
//流提取
istream& operator>> (istream& in, string& s)
{
s.clear();
char ch;
in.get() >> ch;
while (ch != ' ' && ch != '\n')
{
s += ch;
in.get() >> ch;
}
return in;
}
我们的代码就正式运行成功了。
这里我们用了一个clear来清空我们的字符,因为尾插是不对的,我们通常都是对一个空string进行流提取。
//清空
void clear()
{
_size = 0;
_str[_size] = '\0';
}
//这里复用resize(0)也是可以的
9. 优化
💫 getline
那我们想提取一行,而不是像上面一个一样只提取一个,我们就可以用getline来实现。
把上述代码抄下来,把条件改了,这个getline就是取一整行的情况了。
//getline
istream& getline(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
while (ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
getline的另外一种写法,用字符数组做一个中转站来实现。
//getline
istream& getline(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
char buff[128];
size_t i = 0;
while (ch != '\n')
{
buff[i++] = ch;
// [0,126]
if (i == 127)
{
buff[127] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
💫 添加const
1.对iterator我们可以增加静态的成员遍历,因为这受制于权限访问。
//迭代器
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
2. find 我们可以写成const成员。
💡个人总结
1️⃣ 开空间的时候,有时候要多开一个,因为要给'\0'一个位置
2️⃣ 一个运算符两边的操作数如果类型不同,会发生一个现象,类型提升,一般是范围小的向范围大的提升。无符号整数的范围更大,因此都转变成了size_t
3️⃣ 迭代器,不一定是指针,它像指针,有可能不是指针,有可能是指针。vs实现下的string中的迭代器不是指针,但string的迭代器可以用指针简单实现
4️⃣中文字符能否输入?---缓冲区中没有中国两个字,但它是拼出来的,中是两个字符,国是两个字符。这个涉及编码
5️⃣ C++的cin,c语言的scanf取不到换行或者空格,c语言用getchar,getc都可以读到,注意,c语言和c++的输入输出不混用,它们会各自读到自己的内存缓冲区。
🛎️感谢各位同伴的支持,本期string专题就讲解到这啦,下期我们将开始接触新的容器---vector 如果你觉得写的不错的话,可以给个一键三连,点赞,收藏+评论,可以的话还希望点点关注,若有不足,欢迎各位在评论区讨论。