目录
前言
上篇博客讲了string类的使用,今天这篇博客我们来模拟实现string类,这有利于我们了解string的底层原理。
1.浅拷贝和深拷贝
为了研究深浅拷贝,我们先自己写一个简单的string类,它包括了构造函数 ,析构函数,c_str()函数以及[ ]的重载:
namespace bit1
{
//先实现一个简单的string,只考虑资源管理深浅拷贝问题
//暂且不考虑增删查改
class string
{
public:
string(const char* str)
:_str(new char[strlen(str)+1])
{
strcpy(_str, str);
}
~string()
{
if (_str)
{
delete[] _str;
}
}
const char* c_str()const
{
return _str;
}
//引用做返回值
//1.减少拷贝
//2.支持修改返回的对象
char& operator[](size_t pos)
{
assert(pos < strlen(_str));
return _str[pos];
}
private:
char* _str;
};
}
1.1浅拷贝
首先我们写以下代码:
void test_string1()
{
bit1::string s1("hello world");
bit1::string s2(s1);//我们没有写,系统自动生成一个拷贝构造,这个构造函数是浅拷贝或者是值拷贝
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
}
运行一下,发现程序崩溃了:
现象是虽然打印出了s1和s2的值,但是程序却崩溃了,我们来调试看看:
通过调试发现创建的s1和s2对象的地址相同,这说明程序结束时析构s1和s2是对同一片空间析构两次,程序因此而崩溃。
并且在改变s2的时候,s1也改变了:
说了这么多,错误的根源是什么呢?其实就是浅拷贝所造成的程序崩溃,在上面的程序中,我们没有显式写出拷贝构造函数,这个时候编译器就会自动生成一个拷贝构造函数,这时生成的就是浅拷贝,或者叫做值拷贝,他只是把s1的值单纯的拷贝给s2,但是它们的地址仍然时相同的。
1.2 深拷贝
那么深拷贝我们应该怎么写呢,原理很简单就是不仅要把值拷贝过去,两个对象不能指向同一片空间:
代码如下:
//显式写出一个深拷贝构造
//s2(s1)
//s->s1
//_str->s2._str
string(const string& s)
:_str(new char[strlen(s._str)+1])
{
strcpy(_str, s._str);
}
这个时候就不会发生上面浅拷贝的情况了,地址变成了不一样的,修改s2也不会影响s1了
1.3赋值
如果我们不自己写一个赋值, 那么系统会自动处理成浅拷贝赋值
所以我们必须写一个深拷贝的赋值:
//s1 = s3
string& operator=(const string& s)
{
//防止自己给自己赋值
if (this != &s)
{
delete[]_str;//把s1之前的空间先释放了,直接拷贝的话容易造成空间浪费或越界
_str = new char[strlen(s._str) + 1];//这种情况,当开空间失败之后,s1也被释放了
strcpy(_str, s._str);
}
return *this;//返回左操作数支持连续赋值
}
以上程序有几点要注意:
- 不能直接把值赋给被赋值的对象,如s1 = s3,如果s1的空间比s3大,会造成空间的浪费,比s3小,就会造成越界,所以我们先释放s1的空间,再给他分配一块和s3一样大的空间再把数据拷贝过去。
- 注意要返回左操作数支持连续赋值
- 为了防止自己给自己赋值,造成自己的空间被释放,要先判断是不是自己给自己赋值。
同时以上程序也有一点不足的地方,就是如果空间不够了,导致new失败了,那么s1的空间却被释放了,这是我们不想发生的事情,因此要做个小改进:
//s1 = s3
string& operator=(const string& s)
{
//防止自己给自己赋值
if (this != &s)
{
//改进:
//先开空间,后释放
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);
delete[]_str;
_str = tmp;
}
return *this;//返回左操作数支持连续赋值
}
在这里我们先开空间,如果new失败了,就会检验出来,而不会进行下面的delete去释放原空间,这样算是修正了刚刚的问题。
2.string的模拟实现
上面通过将深拷贝和浅拷贝,我们实现了一个简易的string类,现在开始我们要写一个完善的string类,它可以增删查改并且像库里面的string一样去使用。
首先我们要增加两个成员变量,_size和_capacity,_size指有效字符的个数,_capacity指实际存储有效字符的空间,如"hello world",_size就是11,_capacity也是11,但我们要记得开空间的时候永远给'\0'预留一个空间,因为\0不算有效字符,但它实实在在存在于内存中,所以每次开空间都开_capacity+1个空间。
//完善的考虑增删查改和使用的string
namespace bit2
{
class string
{
public:
private:
char* _str;
size_t _size;//有效字符个数
size_t _capacity;//实际存储有效字符的空间
};
}
2.1 构造函数和析构函数
我们将刚刚的构造函数改造下,先初始化_size和_capacity两个变量(注意,初始化成员变量的顺序并不是初始化列表的顺序,而是成员变量排列的顺寻,所以我们先不在初始化列表里初始化_str,而是在构造函数体里面初始化_str):
//构造函数
string(const char* str)
:_size(strlen(str))
,_capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, str);//拷贝了\0
}
但其实这不是默认构造函数,默认构造函数是我们不传参就可以调用的,所以我们需要改造成默认构造函数,默认构造函数最好写成全缺省的,于是就写成了下面这种形式:
//默认构造函数
string(const char* str = "")//这里的细节就是把缺省值变成空字符串,使得_size和_capacity都为0
:_size(strlen(str))
, _capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, str);
}
析构函数没什么说的,只要将_str所指向空间释放并置空,把_size和_capacity变量变成0就行:
//析构函数
~string()
{
if (_str)
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
}
2.2 拷贝构造和赋值重载
拷贝构造和赋值重载和刚刚写的简易版的差不多,把_size和_capacity处理一下就行:
//深拷贝
string(const string& s)
:_size(strlen(s._str))
, _capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
//赋值
string& operator=(const string& s)
{
//防止自己给自己赋值
if (this != &s)
{
char* tmp = new char[s._capacity+ 1];
strcpy(tmp, s._str);
delete[]_str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
2.3 capacity()和size()
这两个函数也没什么说法,只需要返回_capacity和_size的值就行了,当然只要不改变成员最好还是要加上const,这样普通成员可以调用,const成员也可以调用:
//size()
size_t size()const
{
return _size;
}
//capacity()
size_t capacity()const
{
return _capacity;
}
2.4reserve()
reserve的作用就是向系统申请一个大小为n的空间作为string的容量,它的编程思路:
- 判定需要申请的值和现在的_capacity的关系,如果大于现在的,就进行扩容(小于不进行任何处理,因为reserve不会缩容)
- 然后new一个大小为n的空间,并将原string里的数据拷贝到新空间中。
- 将原先的空间还给内存(delete),并将申请的新空间的赋值给原指针,更新_capacity的值。
void reserve(size_t n)
{
if (n > _capacity)//如果申请空间大于现存空间,就扩容
{
char* tmp = new char[n + 1];申请一段n个大小的空间
strcpy(tmp, _str);//拷贝数据到新空间
delete[]_str;//将原空间还给内存
_str = tmp;//更新指针指向
_capacity = n;//更新容量
}
}
2.5 [ ]
[ ]其实没什么东西,但是要提供两个版本的[ ],一个是普通版本,一个是const版本,这样const的string对象也可以调用它:
//[]
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos)const
{
assert(pos < _size);
return _str[pos];
}
2.6 push_back()和append()和+=
前一篇博客说到push_back()和append()都是尾插,前者是插入单个字符,后者可以插入一个字符串,它们的插入思路差不多:
- 首先要判定_size和_capacity的大小,如果_size和_capacity相等,就要进行扩容(增大_capacity的值)。
- 扩容之后,将我们要插入的字符或字符串插入到结尾,根据插入字符的个数改变_size的值,并在结尾加上'\0'。
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);//判断需不需要扩容,同时杜绝capacity为0二倍永远为0的bug
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
void append(const char* str)
{
size_t len = _size + strlen(str);
if (len > _capacity)
{
reserve(len);
}
strcpy(_str + _size, str);//从size的位置进行拷贝str
_size = len;
}
+=的话单纯就是对push_back()和append()的复用啦,如果是+=的是单个字符,那就是复用pushback,如果+=的是字符串,那就是复用append:
string& operator+=(const char* str)
{
append(str);
return *this;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
2.7 resize()
上一篇博客提到resize也是可以申请一段内存,同时可以改变size的大小,并且可以初始化这片空间,它可以分为以下集中情况:
- 当申请的内存大于capacity时,需要申请内存,并且初始化这段内存,并改变size和capacity的值
- 当申请的内存小于capacity但大于size时,不需要申请内存,只需要初始化并改变size和capacity大小就行了
- 当申请的内存小于size时,不需要申请内存,需要改变size,并且给size位置的值置'\0'。
程序如下:
void resize(size_t n, char ch = '\0')
{
if (n < _size)//小于size则改变size大小并在size位置置'\0'
{
_size = n;
_str[_size] = '\0';
}
else//大于size
{
if (n > _capacity)//大于capacity需要申请内存
{
reserve(n);
}
for (size_t i = _size; i < n; i++)//初始化申请的那块内存
{
_str[i] = ch;
}
_size = n;//改变size
_str[_size] = '\0';//结尾置\0
}
}
2.8迭代器的模拟
string的迭代器的底层就是原生指针:
typedef char* iterator;
begin()很简单,只需要返回string第一个数据的指针就行:
iterator begin()
{
return _str;
}
end()是返回string最后一个有效字符的下一个位置的指针:
iterator end()
{
return _str+_size;
}
为了支持const的string对象也可以使用迭代器,我们也必须写begin()和end()的const版本,但是它可以简单的对原来的函数进行const修饰就解决问题吗?
iterator begin()const
{
return _str;
}
iterator end()const
{
return _str+_size;
}
/*上面的函数可以解决问题吗?*/
我们来看看:
那么这种现象的原因是什么呢?因为我们定义的迭代器是typedef char* iterator;char*类型的迭代器通过解引用就可以修改,所以需要增加一个const迭代器:
typedef const char* const_iterator;
const_iterator begin()const
{
return _str;
}
const_iterator end()const
{
return _str + _size;
}
2.9 insert
insert的实现思路其实挺简单:
- 判定pos位置的合法性,pos不能越界插入值
- 然后判定需不需要扩容,要保证我们插入了一个字符之后capacity的容量还够
- 开始挪动数据,注意是从后面开始挪,不能时从前面开始挪动,否则会覆盖
- 在指定的位置插入数据
string& insert(size_t pos, char ch)
{
//先判定pos合法性
assert(pos <= _size);
//判定需不需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//挪动数据
size_t end = _size;
while (end >= pos)
{
_str[end + 1] = _str[end];
--end;
}
//插入数据
_str[pos] = ch;
_size++;
return *this;
}
但是以上程序的头插需要我们特殊处理一下;当pos = 0,以上程序会出现死循环,因为end是一个unsigned int类型的值,--0会变成一个很大的正值,因此我们处理无符号类型的值时,一定要小心对循环条件的处理,要小心end = pos的情况,因此我们做如下改进:
string& insert(size_t pos, char ch)
{
//先判定pos合法性
assert(pos <= _size);
//判定需不需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//挪动数据
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
//插入数据
_str[pos] = ch;
_size++;
return *this;
}
我们将end的位置定义为\0的下一个位置,当end = 0的时候循环就终止了,不会发生end<0的状况。
接下来还需要实现对字符串的插入,原理和插入单个字符类似,但是拷贝数据需要用到strncpy:
string& insert(size_t pos, const char* str)
{
//先判定pos合法性
assert(pos <= _size);
//判定需不需要扩容
int len = strlen(str);
if (len == 0)//防止是空串
{
return;
}
if (len + _size > _capacity)
{
reserve(len + _size);
}
//挪动数据
size_t end = _size + 1;
while (end > pos)
{
_str[end + len-1] = _str[end - 1];
--end;
}
//插入数据
strncpy(_str + pos, str, len);
_size = len + _size;
return*this;
}
2.10 erase
erase的作用是删除从pos位置开始的len个字符,它的模拟实现的程序原理是:
- 先判定pos的合法性,pos的值应该小于_size的值
- 分情况讨论,当pos后面的值全部需要删除时,只需要在pos位置置'\0'就行了,当后面的值部分删除时,需要挪动数据
string& erase(size_t pos, size_t len = npos)
{
//判断pos的合法性
assert(pos < _size);
//分情况讨论,根据len的值的大小做出不同的处理
if (len == npos || len + pos >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else//把后面的数据挪过来,覆盖掉前面的数据
{
size_t begin = pos + len;
while (begin <= _size)
{
_str[begin - len] = _str[begin];
begin++;
}
_size = _size - len;
}
return *this;
}
2.11 find
find顾名思义就是在字符串中找到字符或者字符串,找得到返回它的下标,找不到返回npos,它有两种重载形式,找字符和找字符串
size_t find(char ch, size_t pos = 0)
{
for (; pos < _size; ++pos)
{
if (_str[pos] == ch)
{
return pos;
}
}
return npos;
}
size_t find(const char* str, size_t pos = 0)
{
const char* p = strstr(_str + pos, str);
if (p == nullptr)
{
return npos;
}
else
{
return p - _str;
}
}
2.12 << 和 >>
流插入和流提取操作符的模拟实现有几点需要注意:
- 他们的函数声明定义需要放在全局(不能放在类里面),这样iostream类的对象才可以抢占左操作数
- 使用istream类对象和ostream类对象需要包含头文件iostream和使用命名空间std,不然会报错。
它们的模拟实现代码如下所示:
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
流插入操作符的实现比较简单,它只需要将字符串的全部内容写入输出流中就可以了。
//定义在全局,istream类对象才能抢占左操作数
istream& operator>>(istream& in,string& s)
{
char ch;
ch = in.get();
//in >> ch;//不能用这个,因为流提取识别不了空格
while (ch != ' ' && ch != '\n')//while循环用来将ch的值给写入s中
{
s += ch;
//in >> ch;
ch = in.get();
}
return in;
}
流提取操作符的实现需要注意的是将输入缓冲区的值写入到临时变量ch时,不能用>>操作符,因为>>不能识别空格,它碰到空格就停止提取了,这意味这我们不能连续进行多个值的提取,所以需要用get,它是将缓冲区的所有值都取出来。
3.拷贝构造的现代写法
下面介绍一种看起来很秀的拷贝构造,直接po代码:
//swap
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(s._str);
swap(tmp);
}
是不是感觉很秀,直接复用了构造函数,并且和这个临时对象交换了值和大小和容量,而且创建的tmp也是个临时对象,出了这个函数的作用域就自动调用析构函数销毁了。
另外赋值也可以采用类似的写法,并且它更加过分,都没有创建临时对象:
string& operator=(string s)
{
swap(s);
return *this;
}
赋值直接将形参的值与*this交换,同时形参由于是个普通对象(不是引用传参和指针传参),交换并不会影响到实参,当函数结束时,形参也被销毁了,可谓是资本家行为,利用了你还要把你赶走。