欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool
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大于字符串长度时则将后面的字符全部删除。
删除时我们使用strcpy函数进行了覆盖。
- 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
在拷贝构造和赋值重载时还有一种拷贝方式可以实现,即写时拷贝,有兴趣的可以观看其他博主的文章进行欣赏。