string类的模式实现
在上一篇的博客中,详细讲解了string类的常见用法,这篇着重讲一下string类常用的接口是如何实现的。
在开始正文之前,首先要介绍一下深浅拷贝。
浅拷贝和深拷贝
浅拷贝
假设我们自己写一个string类,其中只有构造函数和析构函数,拷贝构造函数用系统默认的来代替。
class my_string
{
public:
my_string(const char* str)
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
~my_string()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
通过上面的my_string类我们构造出两个对象s1和s2,s1是通过构造函数生成的,而s2通过拷贝构造生成。
int main()
{
my_string s1("hello world");
my_string s2(s1);
return 0;
}
但是运行之后会出现下图的错误信息
这是因为系统默认的拷贝构造函数在创建s2对象的时候,并没有为s2重新开辟一块空间,而是将其与s1共用一块空间(从下图可以看出,s1和s2用的空间地址都是0x00c75990)这样在程序结束的时候,析构函数在销毁两个对象的时候,会将这块空间释放两次,这就会出现释放野指针的报错。因为在第一次释放的时候,这一块空间就已经还给系统了,此时s2中的_str就成为了野指针,再次释放就会出现错误。
深拷贝
要解决浅拷贝的问题,拷贝构造函数和赋值运算符重载就不能使用默认的函数,而需要显式给出,也就是说在调用拷贝构造或者赋值运算符重载时,要给相应的对应分配空间。
my_string(const my_string& s)
:_str(new char[strlen(s._str) + 1])
{
strcpy(_str, s._str);
}
如上代码所示,在拷贝构造中给相应的对象重新开辟空间,而不是共用一块空间,这样对象进行销毁的时候哦,能够保证两个对象对应的字符串指针都是有效的,在释放的时候就不会出错。
string类的模拟实现
构造函数
对于string类对象的构造,由于是字符串,那么是一定需要开辟空间的。同时,在构造函数的参数内给一个缺省值空字符串,这样在使用的时候可以不给初始值,更加灵活。
//构造函数,没有返回值
string(const char* str = "")//构造函数的参数不能改变,默认缺省值为空字符串
//先对其创建str长度加一的空间
{
_size = strlen(str);//_size初始值为给定的字符串长度
_capacity =_size;
_str = new char[_capacity + 1];//用new开辟空间,数量应该用[],不能加括号,括号代表的是初始化的值
//多开一个空间是给'\0'留位置
strcpy(_str, str);//将str的内容拷贝到_str中
}
在给string类对象的字符串指针开辟空间的时候,需要多开辟一个位置,因为string类为了更好的兼容C格式的字符串,在最后也以’\0’结束,因此多开辟一个位置留给’\0’。
拷贝构造函数
拷贝构造函数最大的问题就是深浅拷贝的问题,这一块在开头已经详细讲解了,这里不再过多赘述。在模拟实现拷贝构造函数的时候,为对象开辟一块与拷贝的对象相同的空间,并将成员变量原封不动的复制过去即可。
传统写法
比较传统的写法就是为拷贝的对象开辟一块相同的空间,然后将被拷贝对象的内容拷贝到新的对象中。
//传统写法
string(const string& s)//传值的话会引发无限的拷贝构造,导致死循环
:_str(new char(strlen(s._str) + 1))
{
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
这里要注意的就是参数要进行引用传参,不加引用的话,传过来的参数会对对象进行拷贝构造,进而引发无限的拷贝构造,陷入死循环。
现代写法
现代写法与传统写法本质上是一样的,但是现代写法的结构更加清楚。通过一个交换函数,直接将所有的内容进行交换。可能有很多人会有疑问,这里为什么不用开空间了呢?因为在string内部,成员变量_str本身是一个指针,这个地址下存储的是字符串。在交换之后相当于将地址进行了交换,这样就不需要进行开辟空间的操作。
交换函数如果用库函数给的模板,会引发深拷贝,这样效率不高,因此在本文中所有的交换都是自己写的,直接改变成员变量。
//交换函数
//自己写的交换函数比较简单,只需要对变量进行改变即可
void swap(string& s)//这里必须用引用做参数,否则会陷入拷贝构造的无限循环
{
//在自己的交换函数中调用模板swap函数
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
//现代写法
string(const string& s)
: _str(nullptr)//这里必须将_str置空。如果不置空的话,交换之后s析构时就会出现释放随机值使系统崩溃
, _size(0)
, _capacity(0)
{
string tmp(s._str);//这里进行拷贝构造就相当于开辟了一个空间,与当前对象交换之后出函数就进行析构
swap(tmp);//在自己写的交换函数中直接将成员变量都改变了
}
这里在函数内部定义了一个对象tmp,因为我们不能直接和参数s进行交换,否则就会改变被拷贝的对象。因此重新定义一个临时对象tmp,通过拷贝构造相当于开辟一个新空间,与当前对象进行交换,就可以达到拷贝构造的目的。
注意: 用现代写法必须将成员变量_str置空,否则交换之后哦tmp会清理资源,调用析构函数,此时如果_str不置空,里面放的就是随机值,释放的话会报错。
赋值运算符重载
传统写法
赋值运算符的传统写法就是释放掉原空间,然后根据赋值的对象开辟同样大小的空间,改变成员变量的值。
//传统写法
string& operator=(const string& s)//返回值加引用支持连续赋值,参数加引用防止拷贝构造无限循环
{
if (this != &s)//防止自己给自己赋值
{
//先释放原空间
delete[] _str;
_str = new char(strlen(s._str) + 1);//给_str开辟与s相同长度的空间
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
为了支持连续赋值,在返回值加上了引用。
现代写法
赋值重载的现代写法比起传统的要简单很多,对该对象和赋值的对象通过swap函数直接交换即可。为了不改变原赋值对象的值,在传参的时候不加引用,通过拷贝构造的参数来进行交换,出了作用域也就销毁了。
//交换函数
//自己写的交换函数比较简单,只需要对变量进行改变即可
void swap(string& s)//这里必须用引用做参数,否则会陷入拷贝构造的无限循环
{
//在自己的交换函数中调用模板swap函数
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
//现代写法
string& operator=(string s)//将两个内容交换之后,s出了函数作用域就会销毁,也就不会导致内存泄漏
//参数中不加引用,通过参数的拷贝构造来进行交换
{
swap(s);
return *this;
}
reserve函数
reserve函数是对空间的容量进行调整,但是不进行初始化。那么如果开辟的空间小于当前空间,reserve是不做任何处理的;如果开辟的空间大于当前的空间,reserve就会进行增容。
//reserve函数
//调整容量,但是不进行初始化
void reserve(size_t n)
{
//如果n小于当前字符串的容量,不做任何改变
//如果大于当前容量,则根据n进行改变
if (n > _capacity)
{
//如果只是增加容量的值,但是空间是没有变的
//根据给定的新容量,来开辟新空间
char* str = new char[n + 1];//多开一个空间给'\0'
//将原来的内容放入
//strcpy(str, _str);//不能用strcpy来复制,因为strcpy遇到\0就终止了,但是可能后面还有\0作为有效字符
strncpy(str, _str, _size + 1);
//释放原来的空间
delete[] _str;
_str = str;//将新的地址给_str
_capacity = n;
}
}
需要注意的有两个地方:
1、开辟空间的时候要多开一个,给’\0’留出空间。
2、在复制字符串的时候,不要用strcpy,因为strcpy函数遇到’\0’会停止复制,但是有可能后面有很多的’\0’也是有效字符。因此可以用strncpy,通过字符的个数来复制。
resize函数
resize函数和reserve函数的用法类似,但是resize可以对空间的值初始化。
resize函数的扩容分为三种情况:
1、开辟的空间小于当前的有效长度,也就是缩容。这种情况将原来的内容切掉大于要开辟空间的部分。
2、开辟的空间大于有效长度但是小于现有容量,将大于有效长度的部分进行初始化。
3、开辟的空间大于现有容量,需要进行扩容,并且进行初始化。
//优化的resize函数
void resize(size_t n, char val = '\0')
{
//如果大于当前字符串的长度
if (n > _size)
{
//同时大于当前的容量,通过reserve函数将其扩容
if (n > _capacity)
{
reserve(n);
}
//之后通过memset将其初始化
//不管是大于当前字符串还是大于当前容量,都需要将其从字符串的末尾到n全部初始化
memset(_str + _size, val, n - _size);
}
_size = n;
_str[n] = '\0';
}
string的增删查改
增
push_back函数
push_back就是尾插,这块比较简单,在_size位置上插入数据,然后_size向后挪动一位放入’\0’即可。这里面要注意的就是插入时如果有效长度等于当前的容量则需要扩容,扩容时可以进行1.5倍或者2倍增容;但是容量为0时,需要初始化为4,否则无法进行扩容。
//尾插
void push_back(char c)
{
//如果长度等于容量,则需要扩容为2倍你
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = c;
_str[_size] = '\0';
}
insert函数
(1)用insert插入字符
insert是在任意位置进行插入,由于字符串用的是连续空间,因此在任意位置进行插入需要挪动数据,开销比较大。
//insert函数,从中间插入删除
//从中间插入字符
string& insert(size_t pos, char c)
{
assert(pos >= 0);
//计算新的长度
size_t len = _size + 1;
//如果新长度大于现有的容量,则扩充为2倍
if (len >= _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//从pos位置开始,向后移动一个位置
for (size_t i = _size; i >= pos; --i)
_str[i + 1] = _str[i];
_str[pos] = c;
return *this;
}
那么如果实现了insert函数,为了增加复用性,其实尾插可以复用insert,也就是在_size位置使用insert函数。
(2)用insert插入字符串
insert插入字符串相对于插入字符要复杂一些,插入的字符串将原有的字符串分为两部分,因此我们在实现的时候,首先将pos位置之前的内容复制,然后插入新的字符串,再将剩余的字符串放在新字符串的后面。
string& insert(size_t pos, string s)
{
assert(pos <= _size);
size_t len = _size + strlen(s._str);
char* tmp = new char[len + 1];
//先将原有的字符串放入,可以说是将pos位置前的字符串拷贝一份
strcpy(tmp, _str);
//在pos位置之后插入新的字符串
strcpy(tmp + pos, s._str);
//最后将剩余的部分放到插入的字符串之后
strcpy(tmp + strlen(tmp), _str + pos);
delete[] _str;//释放原空间
_str = tmp;
_size = len;
_capacity = len;
return *this;
}
append函数
append函数是用来连接字符串的函数。具体的实现就是如果新的字符串长度小于当前的容量,可以将连接的字符串直接复制在原来的字符串后面;如果新的字符串长度大于当前容量,则需要扩容,再将原来的内容和新的字符串放进新开辟的空间中。
void append(const string& s)
{
//首先计算当前字符串的长度加上连接字符串长度之和
size_t len = _size + strlen(s._str);
//如果总和大于当前的容量,则需要扩容
if (len > _capacity)
{
//用一个临时变量来存储原内容
char* tmp = new char[len + 1];
strcpy(tmp, _str);
//释放掉原空间
delete[] _str;
_str = tmp;
//改变容量
_capacity = len;
}
//从原内容的最后将连接的字符串复制进去
strcpy(_str + _size, s._str);
_size = len;
}
重载+=运算符
+=运算符在string类中,可以连接一个字符或者字符串。由于我们前面已经实现了push_back函数和append函数,就可以直接复用。+=字符就相当于push_back,+=字符串就相当于append。
//+=重载
//+=一个字符
string& operator+=(char c)
{
//+=一个字符也就是push_back
push_back(c);
return *this;
}
//+=一个字符串
string& operator+=(const string& s)
{
//+=一个字符串也就是append函数
append(s);
return *this;
}
删
pop_back函数
pop_back就是尾删,只需要将_size的值减一即可。也就是说,并不需要真的删除数据。
//尾删
string& pop_back()
{
if (_size > 0)
_size--;
return *this;
}
erase函数
erase函数能够在任意位置删除一个或者多个字符。string类是连续空间,因此在删除数据的时候需要挪动数据,开销比较大。
//erase函数
string& erase(size_t pos = 0, size_t len = npos)
{
assert(pos <= _size);
size_t end = pos + len;
while (end <= _size)
{
_str[end - len] = _str[end];
++end;
}
_size -= len;
return *this;
}
查
find函数
find函数用来查找字符或者字符串,并返回查找的字符串第一次出现的位置。查找字符比较简单,直接遍历一遍查找即可;而查找字符串在实现的时候可以利用strstr函数来查找子串(strstr的底层实现在前面的博客中也写了),这样比较方便快捷。查找到子串出现的地址后,由于要返回的是第一次出现的下标,因此需要再次遍历字符串,将下标返回。
//find函数:返回第一次出现的位置
//查找字符串
size_t find(const string& str, size_t pos = 0)
{
assert(pos <= _size);
char* goal = strstr(_str, str._str);//找出第一次出现的位置的地址
//遍历字符串,得到该位置的下标
size_t index = 0;
for (index = 0; index < _size; ++index)
{
if (_str[index] == *goal)
return index;
}
return -1;
}
//查找字符
size_t find(char c, size_t pos = 0)
{
assert(pos <= _size);
size_t i = 0;
for (i = 0; i < _size; ++i)
{
if (_str[i] == c)
return i;
}
return -1;
}
rfind函数
rfind函数是用来反向查找的,也就是从后往前查找字符或者字符串。这个看起来实现很难,但是可以利用已经实现的find函数,将原字符串进行逆置,来进行查找。
//反向查找字符
size_t rfind(char c, size_t pos = 0)
{
assert(pos <= _size);
reverse(_str);
size_t i = 0;
for (i = 0; i < _size; ++i)
{
if (_str[i] == c)
return _size - i;
}
return -1;
}
//反向查找字符串
size_t rfind(const string& str, size_t pos = 0)
{
assert(pos <= _size);
//首先逆置字符串
reverse(str._str);
//运用find函数进行查找
int index = find(str, pos);
if (index != -1)
return _size - index;
return -1;
}
改
重载[]运算符
为了使string类能够像字符串一样进行更改和遍历,我们通过重载[]来实现。
//重载[]符号
char& operator[](size_t i)//如果要使得用[]可以修改,返回值必须加引用,这表示可读可写
{
return _str[i];
}
//还应该再有一个接口,表示只读的接口,不能更改数据
const char& operator[](size_t i) const
{
return _str[i];
}
这里有重载[]运算符给了两种,因为可能调用[]的时候数据类型不同,对于非const类型的,可以调用第一个,表示可读可写;对于const类型,调用第二个,表示只读。这样子可以很好的区分。
比较函数
string类的内容存储的都是字符串,那么也应该有一些比较大小的运算符,这里一一进行了重载。实现思路比较简单,直接上代码。
//重载比较运算符
//大于
bool operator>(const string& str)
{
for (size_t i = 0; i < strlen(str._str); ++i)
{
if (_str[i] < str._str[i])
return false;
}
return true;
}
//等于
bool operator==(const string& str)
{
for (size_t i = 0; i < strlen(str._str); ++i)
{
if (_str[i] != str._str[i])
return false;
}
return true;
}
//剩余的都可以复用上面两个
//小于等于
bool operator<=(const string& str)
{
return !(_str > str._str);
}
//大于等于
bool operator>=(const string& str)
{
return (_str > str._str) && (_str == str._str);
}
//小于
bool operator<(const string& str)
{
return !(_str >= str._str);
}
//不等于
bool operator!=(const string& str)
{
return !(_str == str._str);
}
重载输入输出运算符
由于自己模拟实现的string类是自定义类型,用标准库中的输入输出是无法做到写入或者显示字符串的。因此,我们需要对<<和>>运算符进行重载,满足string类的输入输出。
重载<<运算符
重载输出运算符比较简单,就是对于string类对象中的字符串,对每一个字符进行遍历输出即可。
ostream& operator<<(ostream& cout, string& s)//返回值加引用是为了能够支持连续输入输出
{
for (auto ch : s)
cout << ch;
return cout;
}
重载>>运算符
重载输入运算符这里比较复杂,需要考虑以下四个问题:
1、对比我们平常输入的时候,输入进去的值并不是连接在原有的内容之后,而是覆盖了原来的值。因此,我们在重载>>的时候首先要清空该对象的字符串中的内容。
2、在重载的时候,如果我们用标准输入流>>来进行每一个字符的输入的话,它会自动忽略掉空格或者换行符,导致程序崩溃。为了解决这个问题,可以调用istream中的get接口来获取字符,这样就可以在接收到换行符以前将所有的字符读入。
3、需要考虑空间扩容的问题,那么我们就可以复用之前的接口,用push_back或者+=运算符都可以。
4、如果将重载<<和>>写在类中的话,会出现一个问题,在类中this指针是始终存在的,那么放在类中实现的话就会将原本的右值写在左边了,不符合我们的使用习惯。因此将这两个函数写成友元函数,这样就可以直接写在类外,并且也能够访问成员变量。
//重载输入输出运算符
//由于在类中重载的话,this指针会占用左值,因此在类外实现
//将这两个函数设置为友元,因为在类外要访问成员变量
friend istream& operator>>(istream& cin, cxd::string& s);
friend ostream& operator<<(ostream& cin, cxd::string& s);
istream& operator>>(istream& cin, string& s)
{
s.clear();
//输入遇到空格或者换行符默认输入结束
char ch;
ch = cin.get();//用>>在开始输入的时候,会自动忽略掉空格或者换行,导致死循环;可以调用istream中的get接口获取字符
while (ch != '\n')
{
s += ch;//空间不够会自动增容
ch = cin.get();
}
return cin;
}
析构函数
//析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
成员变量
private:
char* _str;
size_t _size;
size_t _capacity;
static const size_t npos;
整个代码我放在了码云中,有需要的可以自行查看:
string类模拟实现
https://gitee.com/cao-xudong/bit/tree/master/My_string