目录
前言
总代码
string结构框架搭建
三个成员
构造
析构
拷贝构造、赋值重载 和 swap
size、c_str、operator[ ]
string迭代器的简单实现
扩容 reserve
insert(插入字符和字符串)
单字符
字符串
push_back、append、+=
erase 删除
find查找
运算符 >、<、>=、<=、==、!=
substr(重点)
operator<<
operator>>
结语
前言
string的实现较为简单,属于奖励课的知识点,但是string的知识点比较冗余,没有后面STL的其他结构那样简洁,很多是日常中不会用到的,所以我们今天就实现一些日常中特别常见且重要的
如果有需求要看所有的函数的话,可以上C++官方的网站上去学习 网址如下:
https://legacy.cplusplus.com/reference/string/string/?kw=string
总代码
如果有仅为复习需要的友友,可以直接点开下方gitee链接,里面是该博客的string实现
C++ string实现 gitee
string结构框架搭建
三个成员
我们先来将string的大体框架搭建一下吧
首先,string这个类我们可以拿一个命名空间包起来,以免我们实现后与库里命名的冲突
我们的string一共有三个成员组成:
- char* 类型的指针
- size(有效数据个数)
- capacity(空间总大小)
第一个指针用来指向string的头,size和capacity这两个可以用来分辨string是否满了,是否需要扩容,如下:
namespace hjx
{
class string
{
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
构造
值得注意的是,构造函数在这里有一个坑:我们写函数参数的时候,需要给一个缺省值“”
该符号的意思是:因为是双引号,所以系统会认为这是一个字符串(只不过没有内容)
但是会默认往里面放一个 \0,我们需要这个 \0 因为不加的话,后面如果要插入,string会因为找不到\0而报错
另外,我们初始的对象有三个,一个指针,一个size,一个capacity,我们需要传一个char* 的参数来初步构建string,所以我们在初始化时还需要将空间开出来,这里直接new就好
但是我们要new多大的空间呢?
这时,我们可以使用strlen来计算传过来的参数有多大,但是如果三个参数的初始化都用strlen,那么代价就会略大
我们可以只对size使用strlen在初始化列表初始化,后面的两个都在函数体里复用size的大小即可
代码如下:
string(const char* str = "")
:_size(strlen(str))
{
_str = new char[_size + 1];
strcpy(_str, str);
_capacity = _size;
}
析构
析构倒没有构造那么多弯弯绕绕,我们只需要删除空间,指针置空,size和capacity都变为0即可
代码如下:
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
拷贝构造、赋值重载 和 swap
提及这两个,就不得不使用一下资本家的思想了(滑稽
对于资本家而言,要的就是榨干劳动者的所有价值,并且自己还很轻松
对于拷贝构造,我们可以先创建一个string类,拿参数做初始化,最后将两个类成员里面的指针互换即可
这种方法是很妙的,我们创建了一个类,生命周期是在该函数范围内的,出了函数就调用析构销毁了
但是创建出来的空间是在堆上的,如果不调用析构就不会销毁,我们只需要将一个临时的string构造出来,再让我们要构造的对象的指针指向这块空间,不让他调用析构即可
图示如下:
再举一个形象的例子,有两张银行卡,你和你老板各一张
你赚完了钱之后,你老板如何才能快速获得你的钱?
只需要让你的银行卡变成他的,他的没钱,但是那张卡变成你的,这样你老板就可以获得你的钱了
string(const string& s1)
{
string tmp(s1._str);
swap(tmp);
}
赋值重载同理
甚至,我们可以在参数部分就不使用引用,就让编译器复制一份,直接和参数的空间交换即可,如下:
string& operator=(string s1)
{
swap(s1);
return *this;
}
这里我们再将我们的swap实现一下
因为库里的swap默认会将空间一个一个交换,我们只需要交换指针即可,如下:
void swap(string& s1)
{
std::swap(_str, s1._str);
std::swap(_size, s1._size);
std::swap(_capacity, s1._capacity);
}
另外,为了防止有人不使用对象调用该函数(不使用的话还是会调用到库里的swap)
所以我们还需要在类外面再实现一个版本的swap,如下:
void swap(string& s1, string& s2)
{
s1.swap(s2);
}
size、c_str、operator[ ]
size:
由于我们的成员中有_size,所以我们直接返回即可,size如下:
size_t size()const
{
return _size;
}
c_str:
c_str的效果是使用的时候我们能将整个打印出来,所以我们直接返回成员中的指针即可,如下:
const char* c_str()const
{
return _str;
}
operator[ ]:
使用这个重载的目的就是为了返回某个位置的值,所以参数就是一个整形代表位置
返回值就用char&,因为[]使用完要返回一个字符类型,如下:
char& operator[](int pos)
{
assert(pos < _size && pos >= 0);
return _str[pos];
}
const char& operator[](int pos)const
{
assert(pos < _size && pos >= 0);
return _str[pos];
}
此处实现了加const与否的两个版本
string迭代器的简单实现
由于string的本质就是一个字符数组,所以我们不需要另外实现一个类,直接使用typedef即可
由于迭代器的作用就是模拟指针,我们这里的访问直接使用原生指针恰好可以,所以我们可以直接将char* typedef 为 iterator,const_iterator 同理
接着就是 begin 和 end,我们将这两个实现了之后,底层才会支持范围for(范围for的底层就是迭代器)
begin其实就是将指向头部的指针返回,也就是将我们成员中的指针返回即可
end就是指向尾部的下一个节点的指针,只需要让成员中的指针+size(有效个数)再返回即可
代码如下:
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;
}
扩容 reserve
我们的扩容逻辑相对简单,如果空间满了或者不够,我们就扩容
但是考虑到有一次插入一个字符的,也有一次插入一个字符串的,所以我们需要一个长度作为参数
如果这个参数的大小比我们的capacity都要大,我们就扩容,反之就不需要处理
而到了扩容逻辑里,我们需要先判断一下这个string本来的大小是否就为0
如果为0,就扩为4(这个大小随意,只不过我喜欢扩成4而已),但如果不为0,那么我们就二倍扩容
但是C++中的扩容不想C语言,这里涉及到一个迭代器失效的问题,所以C++的扩容需要自己开新空间,拷贝数据,释放久空间
代码如下:
void reserve(size_t len)
{
if (len > _capacity)
{
char* tmp = new char[len + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = len;
}
}
insert(插入字符和字符串)
单字符
insert需要两个参数,pos位置,和要插入的数据,意味pos位置后插入数据
insert的逻辑其实不难,单字符就是将pos位置的数据全部向后移动一格,如果大小会超就扩容
在数据全都向后移动了之后,将数据插入在pos位置(因为数组下标是从0开始的,所以pos位置就是pos的下一个位置)
代码如下:
void insert(size_t pos, const char s)
{
if (_size == _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
for (size_t i = _size + 1; i > pos; i--)
_str[i] = _str[i - 1];
_str[pos] = s;
_size++;
}
这里有一个大坑就是
我们for循环的 i 需要是_size+1,因为如果是_size的话,循环条件就需要是 >= pos
但是如果 >= pos 的话,如果我的pos就等于 _size,那就不会进入循环,在后期写istream的时候会发现这样会死循环,所以还是需要注意一下的
字符串
大体逻辑和插入单字符的一样,但是这里不一样的点在于,我们需要先求出参数(字符串的大小)(假设长度为len),然后再将这个数据和size相加看看是否会超过capacity,如果会超,再扩容
剩下的就是将pos位置的数据全都往后移len个大小,再用memcpy将数据拷贝过去
void insert(size_t pos, const char* s)
{
size_t len = strlen(s);
if (len + _size > _capacity)
reserve(len + _size);
for (size_t i = _size + len; i > pos + len - 1; i--)
_str[i] = _str[i - len];
memcpy(_str + pos, s, len);
_size += len;
}
push_back、append、+=
由于我们前面实现了insert,所以我们这里的这些函数都可以直接复用我们的insert
push_back就是尾插一个数据
append就是尾插一个字符串
+=就是某位置插入一个字符或一个字符串
+=单加一个字符其实效果就是push_back,加一段字符串就是append,都可以相互复用
代码如下:
void push_back(const char s)
{
insert(_size, s);
}
void append(const char* s)
{
insert(_size, s);
}
string& operator+=(const char s)
{
push_back(s);
return *this;
}
string& operator+=(const char* s)
{
append(s);
return *this;
}
erase 删除
删除的主逻辑就是,用后面的数据覆盖掉要删除的部分,再在尾部放上一个 \0(\0 为字符串结尾,后面即使有字符也不会查看到)
但是我们此时还需要使用for循环折磨自己吗(滑稽)
我们在这里可以直接使用 strcpy !!!
因为strcpy会将后面的所有数据都进行拷贝到前面,而memcpy则是指定大小
所以我们可以直接用strcpy将后面的数据拷贝到前面
图示如下:
代码如下:
void erase(size_t pos, size_t len = npos)
{
if (len > _size - 1 - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
ps:代码中的 len > _size - 1 - pos 代表的是包括pos位置在内的数据都要删除,而下文中有一个substr,那里的是不包括pos位置
find查找
查找单字符的话,我们就直接用一个for循环,从到到尾找一遍,这没什么好说的,代码如下:
size_t find(char s, size_t pos)
{
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == s)
{
return i;
}
}
return npos;
}
这里返回的npos指代的是整形最大值,意味你这字符串肯定没有这么长
查找字符串的话,我们可以直接使用库里面的strstr函数,我们就不需要自己写了(但其实我们自己写的话使用一个双指针,或者叫滑动窗口就解决了,本质就是整个string找一遍而已,并不是特别重点的知识)
strstr函数是找到字符数组中的相同的字符串,并将相同部分的起始位置返回
但由于库里面的find返回值是一个size_t,所以我们需要将strstr返回的结果(一个指针),与起始位置的指针相减得出的结构返回,意味从起始位置到要找到部分之间隔了多远
代码如下:
size_t find(char* s, size_t pos)
{
char* p = strstr(_str + pos, s);
return p - _str;
}
运算符 >、<、>=、<=、==、!=
这些部分其实就是将 < 和 ==实现出来,然后 != 就是 == 的结果取反,>= 就是 < 的结果取反,其他都类似,由于较为简单这里就不作过多讲解,本人前面也有写过一篇有详细讲解过该内容的博客,如果有需要的可以浏览一下,链接如下:
C++ | 日期类详解
代码如下:
bool operator<(const string& s) const
{
return strcmp(_str, s._str) < 0;
}
bool operator>(const string& s) const
{
return !(*this <= s);
}
bool operator<=(const string& s) const
{
return *this < s || *this == s;
}
bool operator>=(const string& s) const
{
return !(*this < s);
}
bool operator==(const string& s) const
{
return strcmp(_str, s._str) == 0;
}
bool operator!=(const string& s) const
{
return !(*this == s);
}
substr(重点)
substr的主要作用就是:将string里面的一部分内容单独抽出来构造成一个string
返回值也是一个string,参数就是从哪个位置开始(pos),往后多长一段距离的字符串(len)
而我们可以先做一个小判断,如果substr后面的长度比_size - pos(意味不包括pos位置)要长,就意味着pos位置后面的所有数据都要拿来构造成一个新的string并返回,如果不是的话,就走else的情况
如果是走else了,那就只能乖乖的开空间,拷贝数据,注意 \0 不能忘,调整新string的成员size
最后返回(注意,这里使用的拷贝数据的函数是memcpy,因为比起strcpy他有一个长度的参数,可以控制想要拷贝多长的数据)
代码如下:
string substr(size_t pos, size_t len = npos)
{
if (len > _size - pos)
{
string tmp(_str + pos);
return tmp;
}
else
{
string tmp;
tmp.reserve(len + 1);
memcpy(tmp._str, _str + pos, len);
tmp._str[len + 1] = '\0';
tmp._size = len;
return tmp;
}
}
operator<<
试想一下,我们打印string类的时候,一般情况下还是会用范围for,但是能不能这样呢:
string s1("hello world");
cout << s1 << endl;
hello world //结果
如果要实现这种效果的话,我们就需要用到我们的ostream
首先我们这个函数是不能在string类里面实现的
因为我们在类里面实现的话,类里面的函数都会默认有一个隐含的this指针,这个this指针会将第一个参数的位置给占掉
但是我们需要的是 <<s1 而不是 s1<<
所以我们只能在类外面实现
而我们这个的大体逻辑就是让一个ostream类型的参数不断地使用<<,里面其实主要的逻辑就是一层for循环,代码如下:
ostream& operator<<(ostream& out, const string& s1)
{
for (int i = 0; i < s1.size(); i++)
out << s1[i];
return out;
}
注意,这里面的返回值是由于我们现实中可能会出现这种情况:cout << s1 << s2 << s3
如果是这种情况的话,我们在调用完后还需要返回一个ostream类型的对象,这样下一个才能打印
operator>>
和operator<<相反,这个代表的作用是cin
而我们这个重载的主要逻辑就是一个一个输入(用istream参数的函数)
当遇到输入的是空格或者换行的时候,就停止
和上面一样的是,我们也需要一个返回值以防止出现 cin >> s1 >> s2 >> s3 这种情况
代码如下:
istream& operator>>(istream& in, string& s1)
{
s1.clean();
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s1 += ch;
ch = in.get();
}
return in;
}
结语
这篇文章到这里,就结束啦 ╰(*°▽°*)╯
如果觉得对你有帮助的话,希望可以多多支持作者喔(〃 ̄︶ ̄)人( ̄︶ ̄〃)