String模拟实现
简介
在上一篇博客中简单介绍了STL和String的简单了解以及常用接口的应用,那么这一篇我们就来模拟实现一个string类以及一些常用的接口函数。
模拟实现
在上一篇博客中我们了解到string类实际上就是一个字符序列,以’\0’结尾,有数据结构基础的小伙伴可以理解为存放字符的一个顺序表。要模拟实现,当然就需要把类的基本成员变量先搬出来:
class string
{
public:
private:
char* _str;
size_t _size;
size_t _capacity;
};
构造函数
我们知道,string类底层提供了7个重载的构造函数,这里我们就只实现含参构造以及拷贝构造。含参构造比较简单,利用new操作开辟一个和传递的字符串常量一样大小的空间,然后将内容拷贝至_str所指向的内存空间即可完成含参构造,由于是初始化,size的大小肯定和字符串大小一致,capacity的话主要看个人习惯吧,可以给到size+1表示比size多一个’\0’,也可以不用多1,但是在开辟空间时一定要记得给’\0’留一个空间。
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
拷贝构造
我们知道string类是默认提供拷贝构造函数的,但是事实上它默认提供的拷贝构造是一个浅拷贝,有关深浅拷贝的问题这里可以给个图供小伙伴简单理解下:
string str1;
string str2(str1);
我们string底层实际上是利用一个char*的指针指向一个存放字符串的一段连续内存空间,浅拷贝实际上就是再定义了一个字符数组的指针,然后指向了同一个内存空间,乍一看好像也没什么问题,毕竟也是完成了一份拷贝,str2也算是有着跟str1一模一样的数据内容。
但是我们都知道,这个字符数组的空间实际上是我们new出来的,在析构的时候我们会delete会释放这一段内存空间保证不会有内存泄漏的问题,所以当上述代码结束后就会导致str2先析构一次释放空间,然后str1再次析构,这样同一个内存空间就被释放了两次导致报错。
这个时候就出现了一个叫深拷贝的东西,能达到一样的目的并且避免掉这个问题,怎么操作呢?很简单,就是让str2指向另外一个一样大小和内容的空间就可以了,就像这样:
那么我们就来实现一下深拷贝的操作
string(const string& s)
:_str(new char[strlen(s._str) + 1])
{
strcpy(_str, s._str);
}
原理也是比较简单,就是开辟一个跟s._str一样大小的空间,然后用str指向它,再将s的内容拷贝到当前对象即可,但是我们都知道,除了string str2(str1)的拷贝方式之外,我们还可以使用string str2=str1的方式完成拷贝构造,这又是怎么实现的呢?
这个地方实际上是重载了‘=’运算符,具体实现实际上和上面的基本一致,也是开空间然后拷贝数据内容:
string& operator=(const string& s)
{
if (this != &s)//防止自己给自己赋值
{
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
这里需要注意的是,因为我们会先释放掉str原来指向的那段内存空间,在进行自己拷贝自己之前,自己的数据已经丢失了,那肯定会拷贝失败的,所以需要判断一下,不让自己拷贝自己。
上述的两种方式是深拷贝的传统写法,我们来看看现代写法的思路是怎么操作的:
void swap(string& s)
{
//域作用限定符,优先在类域里面
//直接交换,减少了多次拷贝构造
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
swap(tmp);
}
string& operator=(string s)
{
swap(s);
return *this;
}
这里swap函数比较简单,因为定义的是类成员函数,所以形参列表实际上还有一个隐藏的this指针,目的就是交换两个类的数据内容。
string(const string& s)函数中通过string tmp(s._str);创建了一个临时对象tmp,这段代码实际上调用的就是我们模拟的含参构造函数,用s的字符串创建一个tmp对象,然后将tmp对象和当前对象直接交换字符指针,就可以让当前对象的指针直接指向目标内存空间完成深拷贝。
string& operator=(string s)实际上就是利用了string(const string& s),因为在传递参数的过程中s的数据内容实际上就是深拷贝出来的,然后再根据上一个一样的操作完成深拷贝。
遍历
在上一篇博客中也提到了string的三种遍历方式
- []+下标
- 迭代器
- 范围for
[]+下标的方式实际上就是对[]的重载,因为底层的char*是允许直接[]访问的,但是string未重载[]前不支持,原理也比较简单,实际上就是访问对应下标的元素即可:
const char& operator[](size_t i) const
{
assert(i < _size);
return _str[i];
}
char& operator[](size_t i)
{
assert(i < _size);
return _str[i];
}
这个assert是一个断言,在这里可以表示为我断言这个i<_size,否则直接终端程序。这两段功能一致,但是由于const对象是不允许修改的,所以才有了一个const版本的[]重载,普通对象是允许修改的,所以用引用做返回值。
迭代器访问,要使用迭代器完成遍历我们首先需要知道迭代器是什么东西,现阶段的话可以简单理解成就是一个指针(不是所有迭代器都是指针),所以我们使用typedef一下:
typedef char* iterator;
typedef const char* const_iterator;
const修饰的指针重载为const_iterator,根据上一篇博客的遍历方式,我们还需要一个begin和end,begin返回的是首地址。end返回的是最后一个元素的下一个地址:
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
同样,const对象访问const_iterator,即可实现迭代器的遍历操作:
string::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
it++;
}
string::const_iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
it++;
}
范围for遍历:范围for我们以前提到跟迭代器相关,实际上范围for会被编译器处理为迭代器的方式访问,可以理解为迭代器的另类访问模式:
for (auto e : s1)
{
cout << e << " ";
}
增删查
修改比较简单就不说了,[]+下标直接修改即可
增
对字符串进行增添操作我们已经将过push_back,append以及insert,下面一一模拟实现一下
push_back(‘追加字符’)
这个还是容易理解的,就是在_size的位置添加字符ch后在ch的下一个位置添加上’\0’最后size++即可。但是需要注意的是size==capacity即数组已经装满了要进行扩容操作,扩容string类提供了一个reserve的接口,我们也顺便实现一下:
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strncpy(tmp, _str,_size+1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
_str[_size + 1] = '\0';
_size++;
}
扩容的原理在于开辟一个新空间,然后把原来的数据内容拷贝进新的内存空间,释放原来的空间,修改capacity的值,这里需要注意一点的是我在这里使用的是strncpy控制了拷贝数据的数量为_size+1,其目的实际上是为了将原字符串的’\0’也拷贝过去。
这里提出另外一个‘+=’操作符的重载实现追加字符,原理简单一看就会:
string& operator += (char ch)
{
push_back(ch);
return *this;
}
append(‘追加字符串’)
这个我感觉实现也不是很难,利用strcpy函数直接将要追加的字符串拷贝在原字符串末尾即可实现追加字符串,然后就是需要考虑增容的问题:
void append(const char* str)
{
size_t len = _size + strlen(str);
if (len > _capacity)
{
reserve(len);
}
strcpy(_str + _size, str);
_size = len;
}
同样的‘+=’追加字符串:
string& operator += (const char* str)
{
append(str);
return *this;
}
insert(‘任意位置添加’)
这个增加方式就跟数据结构实现的顺序表插入一致了,挪动数据然后插入即可:
string& insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 0 : 2 * _capacity);
}
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
_size++;
return *this;
}
删
删除操作的话大致就erase和pop_back,但是pop_back太简单了,粗暴点直接把size位置改成’\0’然后size–即可,这里就不实现了,下面实现erase。
erase(‘删除’)
erase简单来讲就是从哪开始删,删多少个,然后size-=个数即可,实现也是比较简单:
string& erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
size_t leftLen = _size - pos;
if (len >= leftLen)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
用len保存后面剩余字符数量实际上是为了判断具体怎么删,因为大致分两种情况:
- 后面的全部删除
- 删除中间一段,后面的还要往前面挪
查
string的查找无非两种,字符在哪或者字符串在哪。
查找字符还是比较简单的,直接遍历string对象,找到了返回下标,没找到返回-1;
查找字符串的话可以借用C语言的strstr库函数找到匹配字符串的地址,如果不为空,就用它减去首地址即可,否则没找到返回-1:
size_t find(const char* str, size_t pos = 0)
{
assert(pos < _size);
const char* ret = strstr(_str + pos, str);
if (ret)
{
return ret - _str;
}
else
return 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;
}
操作符重载
比较操作符重载
大小比较的操作符重载只需要写一部分,然后直接复用即可,比如写好了大于的函数,小于的函数可以复用!(s1>s2)即可,其中逻辑判断可以逐一字符比较也可以调用C语言库函数strcmp完成:
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator<=(const string& s1, const string& s2)
{
return s1 < s2 || s1 == s2;
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
需要注意的是,复用的函数一定要在当前函数体的上方,不然可能调用的时候找不到你要复用的函数。
流插入和流提取操作符重载
其中cin是无法获取空格和回车的,所以我们需要一个类似于C语言中getchar的东西,在istream提供了一个get可以达到这个效果:
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
ostream& operator<<(ostream& out, const string& s)
{
for (auto e : s)
{
out << e;
}
return out;
}
上述重载>>我们虽然可以输入字符串,但是依旧无法获取到空格,string类提供了一个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;
}