• 冰冰学习笔记:string类的简单模拟


    欢迎各位大佬光临本文章!!!

    还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。

    本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。

    我的博客地址: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类的模拟实现。

    1.string类的基本结构

            string类的内部函数非常的多,总共大约有106个函数,但是常用的也就是十几个,我们平常使用时可以查看文档进行学习。string类是处理字符和字符串的容器,底层的存储与我们之前实现的顺序表一致,一个字符指针指向存储的内容,以及size和capacity记录大小和容量。因此我们模拟实现时就需要完成string类中最基本的功能即可,不需要对其面面俱到。例如在访问string类中存储字符串的内容时,string类提供了[ ]和at函数两种访问形式,都是返回pos位置处的字符,两者都会对越界进行检查,但是at抛出异常,[ ]则直接报错,除此之外并没有什么不同,并且基本都会使用[ ]进行访问。

    string类中部分函数接口:

            string类中还含有一个静态成员变量npos, 其表示最大可能值,在内部函数的拷贝和删除时通常作为缺省值出现,代表操作范围未指定或者大于字符串本身长度时将“直到字符串结束”。通常将其定义为无符号整型-1。

      所以string类的模拟实现的成员就可以实现成下面的样子:

    1. namespace lb
    2. {
    3. class string
    4. {
    5. public:
    6. private:
    7. char* _str;
    8. size_t _size;
    9. size_t _capacity;
    10. public:
    11. const static size_t npos = -1;//const可以给值,当作定义-->定义初始化
    12. };
    13. }

            这里我们将自己实现的string类放在自己的命名空间中,避免与库中实现的出现冲突。这里还涉及到一个知识点,我们之前学习的静态成员变量需要在类内进行声明,类外实现定义。但是C++允许被const修饰的静态成员变量在类中声明的时候直接给予定义,如上面代码所示,npos=-1此时的n-1不再是缺省值,而是直接就是定义形式。当然这与常规变量看起来格格不入,这明明是声明的地方,结果const成员还能给出定义,这的确也是值得吐槽的地方。

    2.string类中默认成员函数

    2.1构造函数与析构函数

            string类中的构造函数多达七个,但是常用的也就三个构造函数,构造一个空类,使用字符串进行构造,使用string类进行构造。

            但是我们要注意到,当我们使用string类创造一个对象时,实际开辟的空间并非是我们字符数量的空间,总是会多开一个空间来存放'\0'作为结束标识。而里面的size指向的也是最后一个字符的下一个位置。所以我们在搭建构造函数时就需要注意空串的情况,对其赋予缺省值来确保存储一个'\0'。

    经过讨论我们写出了第一个版本的构造函数:

    1. //构造函数冗余版本
    2. string()
    3. :_str(new char[1])
    4. , _size(0)
    5. , _capacity(0)
    6. {
    7. _str[0] = '\0';
    8. }
    9. string(const char* str )
    10. :_str(new char[strlen(str)+1])
    11. , _size(strlen(str))
    12. , _capacity(strlen(str))
    13. {
    14. strcpy(_str, str);
    15. }

            这里使用了函数重载,对于空类型则开辟一个空间,并将'\0'放入_str[0]中。对于字符串类型则调用另一个构造函数。但是此种方式并非最优方式,当传入空字符时,我们需要的是将第一个位置放入'\0',空字符串" "天生带有'\0',因此我们可以将其作为缺省值赋予构造函数。

            对于字符串的构造函数,我们连续调用了三次strlen()函数来计算,strlen函数是一个O(N)的算法,多次调用难免会出现效率低下的结果。但是我们不能像下面那样优化:原因在于初始化列表的构建顺序与声明顺序有关,而与列表顺序无关。

    1. //构造函数错误修改版本
    2. string(const char* str )
    3. : _size(strlen(str))
    4. ,_str(new char[_size+1])
    5. , _capacity(_size)
    6. {
    7. strcpy(_str, str);
    8. }

            因此我们不如统统将其放入到函数体中进行初始化,因此改进后的构造函数如下所示

    1. string(const char* str = "")//构造
    2. {
    3. _size = strlen(str);
    4. _capacity = _size;
    5. _str = new char[_capacity + 1];
    6. strcpy(_str, str);//数据拷贝
    7. }

            string类中析构函数的实现比较简单,只需要将开辟的内存进行释放即可。

    1. ~string()//析构
    2. {
    3. delete[] _str;
    4. _str = nullptr;
    5. _size = _capacity = 0;
    6. }

    2.2拷贝构造与赋值运算符重载

            拷贝构造函数和赋值运算重载是会默认生成的一种成员函数,在日期类的模拟实现中我们也讲解过,对于没有开辟空间的类,使用默认生成的拷贝构造与赋值没有问题,但是在涉及到内存空间分配时,在使用简单的浅拷贝就会出现问题。

            浅拷贝只是简单的值拷贝,两个string类对象进行浅拷贝后,原本两个_str指向的不同空间变为指向同一个空间,将会造成两个后果:1)内存泄漏,拷贝对象原先指向的空间没有得到释放,_str指向了被拷贝的空间,原先的地址空间丢失,通常在赋值时会出现。2)析构报错,在调用析构函数时,由于两个类将会指向同一块空间,析构函数将会对同一块空间进行析构两次,程序报错。

            因此在string类中实现的拷贝函数和赋值运算符重载应该是深拷贝,需要开辟一块相同大小的空间并且将原先指向的空间进行释放,然后将被拷贝字符串中的内容赋予拷贝的字符串。

    因此,拷贝构造与赋值重载的传统写法如下:

    1. //--传统写法
    2. string(const string& s)//拷贝构造--深拷贝
    3. :_str(new char[s._capacity + 1])
    4. , _size (s._size)
    5. , _capacity (s._capacity)
    6. {
    7. strcpy(_str, s._str);
    8. }
    9. string& operator=(const string& s)//赋值
    10. {
    11. if (&s != this)
    12. {
    13. char* tmp = new char[s._capacity + 1];
    14. strcpy(tmp, s._str);
    15. //先拷贝再释放避免空间开辟失败
    16. delete[] _str;
    17. _str = tmp;
    18. _size = s._size;
    19. _capacity = s._capacity;
    20. }
    21. return *this;
    22. }

            在传统写法中,拷贝函数的逻辑比较好理解,开辟空间并将原先内容一一拷贝即可。赋值重载需要注意以下几点:1)需要避免自己与自己赋值,自己与自己赋值并没有意义,因此需要对其进行条件判断。2)一定要先开辟空间拷贝之后在释放原先空间,如果先释放再拷贝,放拷贝失败时,将导致原先空间被收回,_str变为野指针。

            既然称前面的写法为传统写法,那么就有现代写法。现代写法真是将“资本家”思维运用到了极致。我们并没有自己去实现拷贝过程,而是利用“打工仔”tmp去调用构造函数来完成拷贝构造。当打工仔tmp利用构造函数构造完毕后,我们在调用string类中提供的swap交换两个类的内容即可。

            string类内部提供了swap函数接口,swap函数交换string类中的内容,例如创建string s1("abc"),s2("123");将s2的内容交换到s1中可以如下调用:s1.swap(s2);

            因此,现代写法的拷贝构造实际上是复用了构造函数,tmp调用构造函数来初始化,初始化内容就是需要拷贝的内容s._str。构造完毕后,我们交换对象即完成拷贝构造。 

    1. void swap(string& tmp)//--内部调用全局swap完成交换
    2. {
    3. ::swap(_str, tmp._str);
    4. ::swap(_size, tmp._size);
    5. ::swap(_capacity, tmp._capacity);
    6. }
    7. //--现代写法
    8. string(const string& s)//利用别人去拷贝
    9. :_str(nullptr)
    10. , _size(0)
    11. , _capacity(0)
    12. {
    13. string tmp(s._str);
    14. swap(tmp);
    15. }

            而对于赋值重载的现代写法更是巧妙,在传参的时候使用的是传值传参,直接就会进行一份拷贝构造,然后进行交换,交换过后将this指针指向的内容进行返回,而出了函数作用域后,拷贝构造的临时对象将被释放。因此我们即完成了赋值又完成了空间释放,一举两得。

    1. string& operator=(string s)//--参数采用传值完成拷贝
    2. {
    3. swap(s);
    4. return *this;
    5. }

    3.元素访问与容量函数

    3.1[ ]的重载与at函数

            前面就提到了string类可以向数组那样使用[ ]来进行元素访问,这得益于运算符的重载,string类将 [ ]进行重载,可以实现直接利用下标来访问元素。C++库中的[ ]实现了两种,congt类型和普通类型。

            at函数和[ ]的重载类似,二者唯一的区别就是报错的方式不同。

            因此两个函数的实现如下:此处实现时对于月结的处理均为报错

    1. char& operator[](size_t pos)
    2. {
    3. assert(pos >= 0 && pos < _size);
    4. return _str[pos];
    5. }
    6. const char& operator[](size_t pos)const
    7. {
    8. assert(pos >= 0 && pos < _size);
    9. return _str[pos];
    10. }
    11. char& at(size_t pos)
    12. {
    13. assert(pos >= 0 && pos < _size);
    14. return _str[pos];
    15. }
    16. const char& at(size_t pos)const
    17. {
    18. assert(pos>=0&&pos < _size);
    19. return _str[pos];
    20. }

    3.2容量函数

            string类中容量函数具有很多,但是常用的就那么几个。其中length是string类中特有的函数,其与size功能完全相同。

    1. size_t size()const
    2. {
    3. return _size;
    4. }
    5. size_t capacity()const
    6. {
    7. return _capacity;
    8. }
    9. void clear()
    10. {
    11. _str[0] = '\0';
    12. _size = 0;
    13. }
    14. bool empty()const
    15. {
    16. return _size == 0;
    17. }

    3.3reserve函数resize函数

            这两个函数比较重要,两个函数都具备开辟空间的作用,不同的是resize函数还会对开辟的空间进行初始化为 '\0' 。两个函数都会接受一个空间大小的n,并将空间扩展到n,但是当n

            因此reserve函数的实现逻辑与以前实现的增容逻辑没有变化,都是开辟新空间,并拷贝内容,然后释放旧空间。

    1. void reserve(size_t n)//开辟空间
    2. {
    3. if (n > _capacity)//
    4. {
    5. char* tmp = new char[n + 1];
    6. strcpy(tmp, _str);
    7. delete[] _str;
    8. _str = tmp;
    9. _capacity = n;
    10. }
    11. }

            而resize函数就需要分情况来讨论,当n大于capacity时复用reserve进行空间开辟,并将新拓展的空间初始化为'\0';当n小于capacity时我们需要将空间缩小,实际就是控制size的大小。

    1. void resize(size_t n, char ch = '\0')//开辟空间加初始化
    2. {
    3. if (n > _size)//增加数据
    4. {
    5. reserve(n);
    6. for (size_t i = _size; i < n; i++)
    7. {
    8. _str[i] = ch;
    9. }
    10. _str[n] = '\0';
    11. _size = n;
    12. }
    13. else//删除数据
    14. {
    15. _str[n] = '\0';
    16. _size = n;
    17. }
    18. }

    4.迭代器的实现

            迭代器是容器中一种类似于指针访问元素的工具,但是迭代器并不是指针。对于空间连续的string类和vector类我们可以使用指针直接实现迭代器,但是对于空间不连续的链表来说,迭代器就不能使用原生指针。

            迭代器一般会提供begin,end,rbegin,rend等函数接口。begin,end等接口还分为只读类型的const_iterator。

    1. //迭代器---指针
    2. typedef char* iterator;
    3. iterator begin()
    4. {
    5. return _str;
    6. }
    7. iterator end()
    8. {
    9. return _str + _size;
    10. }
    11. const typedef char* const_iterator;
    12. const_iterator begin()const
    13. {
    14. return _str;
    15. }
    16. const_iterator end()const
    17. {
    18. return _str + _size;
    19. }

    5.插入与删除函数

            string类中对于字符的插入与删除提供的接口也很多,但是常用的接口却不多,+=操作符的重载最常用,该操作符可以实现对字符,字符串,string类进行+=操作。在实现+=运算符时,可以先实现push_back,append等函数,然后复用这些函数进行实现。

    5.1push_back与pop_back

            push_back,pop_back函数就是在末尾增减一个字符,与顺序表实现的逻辑一样。

    1. void push_back(char ch)//插入
    2. {
    3. if (_size == _capacity)//检查空间是否足够
    4. {
    5. reserve(_capacity == 0 ? 4 : 2 * _capacity);
    6. }
    7. _str[_size] = ch;
    8. ++_size;
    9. _str[_size] = '\0';
    10. }
    11. void pop_back()
    12. {
    13. assert(_size > 0);
    14. _size--;
    15. _str[_size] = '\0';
    16. }

            值得注意的一点是无论插入还是删除,都需要重新在字符串后面显示添加'\0'作为字符串结束标记。 

    5.2append与+=

            append函数实现的重载比较多,但是常用的并不多。通常使用都是对字符串的增加操作。

            对于使用append增加一个字符串,我们首先需要知道字符串的长度,然后检查剩余容量是否能够容纳新增字符串的长度,如果不能还需要扩容处理。剩下的就是将字符连接到原先字符串的后面即可,我们可以使用strcpy函数进行操作。

    1. void append(const char* str)
    2. {
    3. size_t len = strlen(str);
    4. if (_size+len > _capacity)
    5. {
    6. reserve(_size+len);
    7. }
    8. strcpy(_str + _size, str);
    9. //strcat(_str , str);//效率低,因为要遍历找'\0'
    10. _size += len;
    11. }

            这里需要注意,很多人再想到字符串连接时需要使用strcat函数,这样使用是没有问题的,与strcpy函数一样:strcat(_str+_size,str);但是如果我们这样使用效率会很低: strcat(_str,str);原因在于strcat需要从头寻找 '\0' 浪费时间。

            在append实现字符串的插入后,就可以顺手实现两个重载函数。

    1. //插入一个string类
    2. void append(const string& s)
    3. {
    4. append(s._str);
    5. }
    6. //插入n个字符
    7. void append(size_t n, char ch)
    8. {
    9. reserve(_size + n);
    10. for (size_t i = 0; i < n; i++)
    11. {
    12. push_back(ch);
    13. }
    14. }

            对于+=操作符的实现,就是将push_back与append进行了合并。

    1. string& operator+=(char ch)
    2. {
    3. push_back(ch);
    4. return *this;
    5. }
    6. string& operator+=(const char* ch)
    7. {
    8. append(ch);
    9. return *this;
    10. }
    11. string& operator+=(const string s)
    12. {
    13. append(s);
    14. return *this;
    15. }

    5.3insert与erase

            insert函数库中也提供很多的重载版本,我们只需要模拟实现常用的即可。insert函数的逻辑就是在pos位置之前插入字符或者字符串。

            在模拟实现时我们还是需要先检查pos是否符合条件,并且判断空间是否够用。在满足这些条件后就需要对pos及pos之后的元素向后移动,将pos指向的空间空出来,然后插入新字符。

    1. string& insert(size_t pos, char ch)//插入字符
    2. {
    3. assert(pos <= _size);
    4. if (_size == _capacity)
    5. {
    6. reserve(_capacity == 0 ? 4 : 2 * _capacity);
    7. }
    8. size_t end = _size + 1;
    9. while (end>pos)
    10. {
    11. _str[end] = _str[end - 1];
    12. --end;
    13. }
    14. _str[pos] = ch;
    15. ++_size;
    16. return *this;
    17. }

    插入过程如下所示: 

            而对于插入n个字符的insert就需要先移出n个字符的位置出来,但是我们需要先对n进行判断,如果n等于0,则说明没有插入,直接返回即可,不然将会出现错误。移动完毕后将字符插入进去即可。而插入一个字符串就可以先计算出字符串的长度,然后与插入n个字符时一样挪动数据即可。

    1. string& insert(size_t pos, size_t n, char ch)//插入n个字符
    2. {
    3. assert(pos <= _size);
    4. if (n == 0)//插入0个字符
    5. {
    6. return *this;
    7. }
    8. if (_size+n > _capacity)
    9. {
    10. reserve(_size+n);
    11. }
    12. size_t end = _size + n;
    13. while (end>= pos+n)
    14. {
    15. _str[end] = _str[end-n];
    16. --end;
    17. }
    18. while (pos <= end)
    19. {
    20. _str[pos] = ch;
    21. ++pos;
    22. }
    23. _size += n;
    24. _str[_size] = '\0';
    25. return *this;
    26. }
    27. string& insert(size_t pos, const char* str)
    28. {
    29. assert(pos <= _size);
    30. size_t len = strlen(str);
    31. if (len == 0)//极端情况插入空串
    32. {
    33. return *this;
    34. }
    35. if (_size+len > _capacity)
    36. {
    37. reserve(_size+len);
    38. }
    39. size_t end = _size + len;
    40. while (end>=pos+len)
    41. {
    42. _str[end] = _str[end - len];
    43. end--;
    44. }
    45. strncpy(_str + pos, str, len);
    46. _size += len;
    47. return *this;
    48. }

            erase函数我们只需要实现删除pos位置开始后的n个字符即可,如果n大于字符串长度时则将后面的字符全部删除。

    删除时我们使用strcpy函数进行了覆盖。

    1. void erase(size_t pos, size_t len = npos)
    2. {
    3. assert(pos < _size);
    4. if (len == npos || pos + len >= _size)
    5. {
    6. _str[pos] = '\0';
    7. _size = pos;
    8. }
    9. else
    10. {
    11. strcpy(_str + pos, _str + pos + len);
    12. _size -= len;
    13. }
    14. }

    6.find与substr函数

            find函数在string类中给出了,但是在库中还有一个全局的find函数。

            string类中提供的find函数在找到时返回的是字符的下标或者字符串首字母的下标,找不到返回的是npos。

            查找字符我们只需要遍历然后比对即可,找到就返回下标,找不到返回npos,而对于字符串的查找,可以复用库函数strstr,只不过strstr找到返回字符串的指针,找不到返回的是nullptr。

    1. size_t find(const char ch, size_t pos = 0)const
    2. {
    3. assert(pos < _size);
    4. size_t i = 0;
    5. for (i = pos; i < _size; i++)
    6. {
    7. if (_str[i] == ch)
    8. {
    9. return i;
    10. }
    11. }
    12. return npos;
    13. }
    14. size_t find(const char* sub, size_t pos=0)const
    15. {
    16. assert(sub);
    17. assert(pos < _size);
    18. const char* ptr = strstr(_str + pos, sub);
    19. if (ptr == nullptr)
    20. {
    21. return npos;
    22. }
    23. else
    24. {
    25. return ptr - _str;
    26. }
    27. }

            有时我们还需兼顾C语言的环境,C语言中没有string类,只有字符串,因此我们还需要实现接口c_str。

    1. const char* c_str()const
    2. {
    3. return _str;
    4. }

            substr函数是取出string类中pos之后的n个字符的函数,当n没有显示提供或者大于size时,则将pos之后的字符全部取出并存放在string类中,然后返回。

    1. string substr(size_t pos, size_t len = npos) const
    2. {
    3. assert(pos < _size);
    4. size_t realen = len;
    5. if (len == npos || len + pos > _size)
    6. {
    7. realen = _size - pos;
    8. }
    9. string ret;
    10. for (size_t i = 0; i < realen; i++)
    11. {
    12. ret += _str[pos + i];
    13. }
    14. return ret;
    15. }

    7.运算符重载

            运算符的重载没有什么难点,和日期类一样,只需要实现==和>运算符的重载后其他的进行复用实现即可。

    1. bool operator>(const string& s) const
    2. {
    3. return strcmp(_str, s._str)>0;
    4. }
    5. bool operator==(const string& s) const
    6. {
    7. return strcmp(_str, s._str) == 0;
    8. }
    9. bool operator >=(const string& s)const
    10. {
    11. return *this > s || *this == s;
    12. }
    13. bool operator <(const string& s)const
    14. {
    15. return !(*this >= s);
    16. }
    17. bool operator <=(const string& s)const
    18. {
    19. return !(*this > s);
    20. }
    21. bool operator !=(const string& s)const
    22. {
    23. return !(*this == s);
    24. }

            而运算符重载中稍微需要注意的就是流提取和流插入运算符的重载,这两个需要在类外进行实现,但不一定非要设计成string类的友元函数,例如我们将string类在命名空间lb中实现,那我们在使用string类时需要指定命名空间lb,将流提取函数实现为lb空间中的全局函数也可以进行操作。

            流插入运算符中我们需要考虑效率问题,为了避免频繁的插入,我们需要实现一个类似缓冲区的容器进行临时存放函数。并且插入字符时通常用空格或者'\r'来作为间隔符,cin是不识别这两个字符的,因此我们需要使用istream中的接口函数get()。

    1. ostream& operator<<(ostream& out, const string& s)
    2. {
    3. for (size_t i = 0; i < s.size(); ++i)
    4. {
    5. out << s[i];
    6. }
    7. return out;
    8. }
    9. //istream& operator>>(istream& in, string& s)//频繁扩容
    10. //{
    11. // char ch = in.get();
    12. // while (ch != ' ' && ch != '\n')
    13. // {
    14. // s += ch;
    15. // ch = in.get();
    16. // }
    17. // return in;
    18. //}
    19. istream& operator>>(istream& in, string& s)//
    20. {
    21. s.clear();//清除原有字符
    22. char ch = in.get();
    23. const size_t N = 32;
    24. char buff[N];
    25. size_t i = 0;
    26. while (ch != ' ' && ch != '\n')
    27. {
    28. buff[i++] = ch;
    29. if (i == N - 1)
    30. {
    31. buff[i] = '\0';
    32. s += buff;
    33. i = 0;
    34. }
    35. ch = in.get();
    36. }
    37. buff[i] = '\0';
    38. s += buff;
    39. return in;
    40. }

    总结

            string类的模拟实现并不难,就是一些基础知识的复用与堆叠,博主实现的string类仅仅是简单的练习版本,函数接口的实现仁者见仁智者见智,大家可以根据自己的想法进行模拟实现。代码链接:完成string的模拟实现 · 5b291e4 · 冰冰棒/C++ - Gitee.com

            在拷贝构造和赋值重载时还有一种拷贝方式可以实现,即写时拷贝,有兴趣的可以观看其他博主的文章进行欣赏。

  • 相关阅读:
    Word控件Spire.Doc 【页面设置】教程(3):在 C#、VB.NET 中设置 Word 页边距
    优雅书写Controller(参数验证+统一异常处理)
    java项目之宠物管理系统(ssm框架)
    如何使用phpstudy在服务器上发布Discuz_X3.4_SC_UTF8_20220811和zzcms2023
    如何使用java语言下载https的网络文件
    Day657.思考题解答⑤ -Java业务开发常见错误
    【广州华锐互动】关于物理力学的3D实验实操平台
    前 K 个高频元素
    nodejs使用es-batis
    Web测试如何让IT门外汉更好的入门篇
  • 原文地址:https://blog.csdn.net/bingbing_bang/article/details/126466043