欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool
冰冰学习笔记:《运算符重载---日期类的实现》
冰冰学习笔记:《一步一步带你实现《顺序表》》
系列文章推荐
前言
1.string类的基本结构
2.string类中默认成员函数
2.1构造函数与析构函数
2.2拷贝构造与赋值运算符重载
3.元素访问与容量函数
3.1[ ]的重载与at函数
3.2容量函数
3.3resize函数与reserve函数
4.迭代器的实现
5.插入与删除函数
5.1push_back与pop_back
5.2append与+=
5.3insert与erase
6.find与substr函数
7.运算符重载
总结
string类是C++库中管理字符串的类,里面将字符串的处理函数进行了封装,可以方便我们进行调用。相比于C语言中字符串的表达方式,C++的string类管理起来更加方便,快捷。但是string类的设计也一直受到吐槽,原因很多,比如复杂冗余,很多接口的设计根本用不到,因此很多人都会去自己实现好用的string类,一些面试题中也经常会让我们简单模拟实现string类,今天我们就盘一盘string类的模拟实现。
string类的内部函数非常的多,总共大约有106个函数,但是常用的也就是十几个,我们平常使用时可以查看文档进行学习。string类是处理字符和字符串的容器,底层的存储与我们之前实现的顺序表一致,一个字符指针指向存储的内容,以及size和capacity记录大小和容量。因此我们模拟实现时就需要完成string类中最基本的功能即可,不需要对其面面俱到。例如在访问string类中存储字符串的内容时,string类提供了[ ]和at函数两种访问形式,都是返回pos位置处的字符,两者都会对越界进行检查,但是at抛出异常,[ ]则直接报错,除此之外并没有什么不同,并且基本都会使用[ ]进行访问。
string类中部分函数接口:
string类中还含有一个静态成员变量npos, 其表示最大可能值,在内部函数的拷贝和删除时通常作为缺省值出现,代表操作范围未指定或者大于字符串本身长度时将“直到字符串结束”。通常将其定义为无符号整型-1。
所以string类的模拟实现的成员就可以实现成下面的样子:
namespace lb
{
class string
{
public:
private:
char* _str;
size_t _size;
size_t _capacity;
public:
const static size_t npos = -1;//const可以给值,当作定义-->定义初始化
};
}
这里我们将自己实现的string类放在自己的命名空间中,避免与库中实现的出现冲突。这里还涉及到一个知识点,我们之前学习的静态成员变量需要在类内进行声明,类外实现定义。但是C++允许被const修饰的静态成员变量在类中声明的时候直接给予定义,如上面代码所示,npos=-1此时的n-1不再是缺省值,而是直接就是定义形式。当然这与常规变量看起来格格不入,这明明是声明的地方,结果const成员还能给出定义,这的确也是值得吐槽的地方。
string类中的构造函数多达七个,但是常用的也就三个构造函数,构造一个空类,使用字符串进行构造,使用string类进行构造。
但是我们要注意到,当我们使用string类创造一个对象时,实际开辟的空间并非是我们字符数量的空间,总是会多开一个空间来存放'\0'作为结束标识。而里面的size指向的也是最后一个字符的下一个位置。所以我们在搭建构造函数时就需要注意空串的情况,对其赋予缺省值来确保存储一个'\0'。
经过讨论我们写出了第一个版本的构造函数:
//构造函数冗余版本
string()
:_str(new char[1])
, _size(0)
, _capacity(0)
{
_str[0] = '\0';
}
string(const char* str )
:_str(new char[strlen(str)+1])
, _size(strlen(str))
, _capacity(strlen(str))
{
strcpy(_str, str);
}
这里使用了函数重载,对于空类型则开辟一个空间,并将'\0'放入_str[0]中。对于字符串类型则调用另一个构造函数。但是此种方式并非最优方式,当传入空字符时,我们需要的是将第一个位置放入'\0',空字符串" "天生带有'\0',因此我们可以将其作为缺省值赋予构造函数。
对于字符串的构造函数,我们连续调用了三次strlen()函数来计算,strlen函数是一个O(N)的算法,多次调用难免会出现效率低下的结果。但是我们不能像下面那样优化:原因在于初始化列表的构建顺序与声明顺序有关,而与列表顺序无关。
//构造函数错误修改版本
string(const char* str )
: _size(strlen(str))
,_str(new char[_size+1])
, _capacity(_size)
{
strcpy(_str, str);
}
因此我们不如统统将其放入到函数体中进行初始化,因此改进后的构造函数如下所示:
string(const char* str = "")//构造
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);//数据拷贝
}
string类中析构函数的实现比较简单,只需要将开辟的内存进行释放即可。
~string()//析构
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
拷贝构造函数和赋值运算重载是会默认生成的一种成员函数,在日期类的模拟实现中我们也讲解过,对于没有开辟空间的类,使用默认生成的拷贝构造与赋值没有问题,但是在涉及到内存空间分配时,在使用简单的浅拷贝就会出现问题。
浅拷贝只是简单的值拷贝,两个string类对象进行浅拷贝后,原本两个_str指向的不同空间变为指向同一个空间,将会造成两个后果:1)内存泄漏,拷贝对象原先指向的空间没有得到释放,_str指向了被拷贝的空间,原先的地址空间丢失,通常在赋值时会出现。2)析构报错,在调用析构函数时,由于两个类将会指向同一块空间,析构函数将会对同一块空间进行析构两次,程序报错。
因此在string类中实现的拷贝函数和赋值运算符重载应该是深拷贝,需要开辟一块相同大小的空间并且将原先指向的空间进行释放,然后将被拷贝字符串中的内容赋予拷贝的字符串。
因此,拷贝构造与赋值重载的传统写法如下:
//--传统写法
string(const string& s)//拷贝构造--深拷贝
:_str(new char[s._capacity + 1])
, _size (s._size)
, _capacity (s._capacity)
{
strcpy(_str, s._str);
}
string& operator=(const string& s)//赋值
{
if (&s != this)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
//先拷贝再释放避免空间开辟失败
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
在传统写法中,拷贝函数的逻辑比较好理解,开辟空间并将原先内容一一拷贝即可。赋值重载需要注意以下几点:1)需要避免自己与自己赋值,自己与自己赋值并没有意义,因此需要对其进行条件判断。2)一定要先开辟空间拷贝之后在释放原先空间,如果先释放再拷贝,放拷贝失败时,将导致原先空间被收回,_str变为野指针。
既然称前面的写法为传统写法,那么就有现代写法。现代写法真是将“资本家”思维运用到了极致。我们并没有自己去实现拷贝过程,而是利用“打工仔”tmp去调用构造函数来完成拷贝构造。当打工仔tmp利用构造函数构造完毕后,我们在调用string类中提供的swap交换两个类的内容即可。
string类内部提供了swap函数接口,swap函数交换string类中的内容,例如创建string s1("abc"),s2("123");将s2的内容交换到s1中可以如下调用:s1.swap(s2);
因此,现代写法的拷贝构造实际上是复用了构造函数,tmp调用构造函数来初始化,初始化内容就是需要拷贝的内容s._str。构造完毕后,我们交换对象即完成拷贝构造。
void swap(string& tmp)//--内部调用全局swap完成交换
{
::swap(_str, tmp._str);
::swap(_size, tmp._size);
::swap(_capacity, tmp._capacity);
}
//--现代写法
string(const string& s)//利用别人去拷贝
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
swap(tmp);
}
而对于赋值重载的现代写法更是巧妙,在传参的时候使用的是传值传参,直接就会进行一份拷贝构造,然后进行交换,交换过后将this指针指向的内容进行返回,而出了函数作用域后,拷贝构造的临时对象将被释放。因此我们即完成了赋值又完成了空间释放,一举两得。
string& operator=(string s)//--参数采用传值完成拷贝
{
swap(s);
return *this;
}
前面就提到了string类可以向数组那样使用[ ]来进行元素访问,这得益于运算符的重载,string类将 [ ]进行重载,可以实现直接利用下标来访问元素。C++库中的[ ]实现了两种,congt类型和普通类型。
at函数和[ ]的重载类似,二者唯一的区别就是报错的方式不同。
因此两个函数的实现如下:此处实现时对于月结的处理均为报错
char& operator[](size_t pos)
{
assert(pos >= 0 && pos < _size);
return _str[pos];
}
const char& operator[](size_t pos)const
{
assert(pos >= 0 && pos < _size);
return _str[pos];
}
char& at(size_t pos)
{
assert(pos >= 0 && pos < _size);
return _str[pos];
}
const char& at(size_t pos)const
{
assert(pos>=0&&pos < _size);
return _str[pos];
}
string类中容量函数具有很多,但是常用的就那么几个。其中length是string类中特有的函数,其与size功能完全相同。
size_t size()const
{
return _size;
}
size_t capacity()const
{
return _capacity;
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
bool empty()const
{
return _size == 0;
}
这两个函数比较重要,两个函数都具备开辟空间的作用,不同的是resize函数还会对开辟的空间进行初始化为 '\0' 。两个函数都会接受一个空间大小的n,并将空间扩展到n,但是当n
因此reserve函数的实现逻辑与以前实现的增容逻辑没有变化,都是开辟新空间,并拷贝内容,然后释放旧空间。
void reserve(size_t n)//开辟空间
{
if (n > _capacity)//
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
而resize函数就需要分情况来讨论,当n大于capacity时复用reserve进行空间开辟,并将新拓展的空间初始化为'\0';当n小于capacity时我们需要将空间缩小,实际就是控制size的大小。
void resize(size_t n, char ch = '\0')//开辟空间加初始化
{
if (n > _size)//增加数据
{
reserve(n);
for (size_t i = _size; i < n; i++)
{
_str[i] = ch;
}
_str[n] = '\0';
_size = n;
}
else//删除数据
{
_str[n] = '\0';
_size = n;
}
}
迭代器是容器中一种类似于指针访问元素的工具,但是迭代器并不是指针。对于空间连续的string类和vector类我们可以使用指针直接实现迭代器,但是对于空间不连续的链表来说,迭代器就不能使用原生指针。
迭代器一般会提供begin,end,rbegin,rend等函数接口。begin,end等接口还分为只读类型的const_iterator。
//迭代器---指针
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const typedef char* const_iterator;
const_iterator begin()const
{
return _str;
}
const_iterator end()const
{
return _str + _size;
}
string类中对于字符的插入与删除提供的接口也很多,但是常用的接口却不多,+=操作符的重载最常用,该操作符可以实现对字符,字符串,string类进行+=操作。在实现+=运算符时,可以先实现push_back,append等函数,然后复用这些函数进行实现。
push_back,pop_back函数就是在末尾增减一个字符,与顺序表实现的逻辑一样。
void push_back(char ch)//插入
{
if (_size == _capacity)//检查空间是否足够
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
void pop_back()
{
assert(_size > 0);
_size--;
_str[_size] = '\0';
}
值得注意的一点是无论插入还是删除,都需要重新在字符串后面显示添加'\0'作为字符串结束标记。
append函数实现的重载比较多,但是常用的并不多。通常使用都是对字符串的增加操作。
对于使用append增加一个字符串,我们首先需要知道字符串的长度,然后检查剩余容量是否能够容纳新增字符串的长度,如果不能还需要扩容处理。剩下的就是将字符连接到原先字符串的后面即可,我们可以使用strcpy函数进行操作。
void append(const char* str)
{
size_t len = strlen(str);
if (_size+len > _capacity)
{
reserve(_size+len);
}
strcpy(_str + _size, str);
//strcat(_str , str);//效率低,因为要遍历找'\0'
_size += len;
}
这里需要注意,很多人再想到字符串连接时需要使用strcat函数,这样使用是没有问题的,与strcpy函数一样:strcat(_str+_size,str);但是如果我们这样使用效率会很低: strcat(_str,str);原因在于strcat需要从头寻找 '\0' 浪费时间。
在append实现字符串的插入后,就可以顺手实现两个重载函数。
//插入一个string类
void append(const string& s)
{
append(s._str);
}
//插入n个字符
void append(size_t n, char ch)
{
reserve(_size + n);
for (size_t i = 0; i < n; i++)
{
push_back(ch);
}
}
对于+=操作符的实现,就是将push_back与append进行了合并。
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* ch)
{
append(ch);
return *this;
}
string& operator+=(const string s)
{
append(s);
return *this;
}
insert函数库中也提供很多的重载版本,我们只需要模拟实现常用的即可。insert函数的逻辑就是在pos位置之前插入字符或者字符串。
在模拟实现时我们还是需要先检查pos是否符合条件,并且判断空间是否够用。在满足这些条件后就需要对pos及pos之后的元素向后移动,将pos指向的空间空出来,然后插入新字符。
string& insert(size_t pos, char ch)//插入字符
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
size_t end = _size + 1;
while (end>pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
return *this;
}
插入过程如下所示:
而对于插入n个字符的insert就需要先移出n个字符的位置出来,但是我们需要先对n进行判断,如果n等于0,则说明没有插入,直接返回即可,不然将会出现错误。移动完毕后将字符插入进去即可。而插入一个字符串就可以先计算出字符串的长度,然后与插入n个字符时一样挪动数据即可。
string& insert(size_t pos, size_t n, char ch)//插入n个字符
{
assert(pos <= _size);
if (n == 0)//插入0个字符
{
return *this;
}
if (_size+n > _capacity)
{
reserve(_size+n);
}
size_t end = _size + n;
while (end>= pos+n)
{
_str[end] = _str[end-n];
--end;
}
while (pos <= end)
{
_str[pos] = ch;
++pos;
}
_size += n;
_str[_size] = '\0';
return *this;
}
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (len == 0)//极端情况插入空串
{
return *this;
}
if (_size+len > _capacity)
{
reserve(_size+len);
}
size_t end = _size + len;
while (end>=pos+len)
{
_str[end] = _str[end - len];
end--;
}
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
erase函数我们只需要实现删除pos位置开始后的n个字符即可,如果n大于字符串长度时则将后面的字符全部删除。
void erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
find函数在string类中给出了,但是在库中还有一个全局的find函数。
string类中提供的find函数在找到时返回的是字符的下标或者字符串首字母的下标,找不到返回的是npos。
查找字符我们只需要遍历然后比对即可,找到就返回下标,找不到返回npos,而对于字符串的查找,可以复用库函数strstr,只不过strstr找到返回字符串的指针,找不到返回的是nullptr。
size_t find(const char ch, size_t pos = 0)const
{
assert(pos < _size);
size_t i = 0;
for (i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
size_t find(const char* sub, size_t pos=0)const
{
assert(sub);
assert(pos < _size);
const char* ptr = strstr(_str + pos, sub);
if (ptr == nullptr)
{
return npos;
}
else
{
return ptr - _str;
}
}
有时我们还需兼顾C语言的环境,C语言中没有string类,只有字符串,因此我们还需要实现接口c_str。
const char* c_str()const
{
return _str;
}
substr函数是取出string类中pos之后的n个字符的函数,当n没有显示提供或者大于size时,则将pos之后的字符全部取出并存放在string类中,然后返回。
string substr(size_t pos, size_t len = npos) const
{
assert(pos < _size);
size_t realen = len;
if (len == npos || len + pos > _size)
{
realen = _size - pos;
}
string ret;
for (size_t i = 0; i < realen; i++)
{
ret += _str[pos + i];
}
return ret;
}
运算符的重载没有什么难点,和日期类一样,只需要实现==和>运算符的重载后其他的进行复用实现即可。
bool operator>(const string& s) const
{
return strcmp(_str, s._str)>0;
}
bool operator==(const string& s) const
{
return strcmp(_str, s._str) == 0;
}
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 !(*this > s);
}
bool operator !=(const string& s)const
{
return !(*this == s);
}
而运算符重载中稍微需要注意的就是流提取和流插入运算符的重载,这两个需要在类外进行实现,但不一定非要设计成string类的友元函数,例如我们将string类在命名空间lb中实现,那我们在使用string类时需要指定命名空间lb,将流提取函数实现为lb空间中的全局函数也可以进行操作。
流插入运算符中我们需要考虑效率问题,为了避免频繁的插入,我们需要实现一个类似缓冲区的容器进行临时存放函数。并且插入字符时通常用空格或者'\r'来作为间隔符,cin是不识别这两个字符的,因此我们需要使用istream中的接口函数get()。
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
out << s[i];
}
return out;
}
//istream& operator>>(istream& in, string& s)//频繁扩容
//{
// char ch = in.get();
// while (ch != ' ' && ch != '\n')
// {
// s += ch;
// ch = in.get();
// }
// return in;
//}
istream& operator>>(istream& in, string& s)//
{
s.clear();//清除原有字符
char ch = in.get();
const size_t N = 32;
char buff[N];
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
buff[i] = '\0';
s += buff;
return in;
}
string类的模拟实现并不难,就是一些基础知识的复用与堆叠,博主实现的string类仅仅是简单的练习版本,函数接口的实现仁者见仁智者见智,大家可以根据自己的想法进行模拟实现。代码链接:完成string的模拟实现 · 5b291e4 · 冰冰棒/C++ - Gitee.com
在拷贝构造和赋值重载时还有一种拷贝方式可以实现,即写时拷贝,有兴趣的可以观看其他博主的文章进行欣赏。