【c++】:模拟实现STL模板中的string

简介: 【c++】:模拟实现STL模板中的string

前言



上一篇文章我们详细介绍了STL中的string的一些常用的接口,这一篇文章我们将从底层实现string类,当然我们只是实现一些重要的,经常使用的接口,并且不是完全按照STL中的string去走的。


一、string的模拟实现



首先我们为了防止我们写的string类与库中的string产生命名冲突,所以我们将要实现的string写在我们自己的命名空间中,如下图:

514cb193f46e46c4a7cb4d88af571b4d.png

首先我们创建需要的变量,众所周知string是一个字符串类,所以底层我们就用char* str搞定,然后还需要capacity来查看字符串的容量,size就是字符串的长度了,如下图所示:

b1a73cbf7bae4207bdc854819134e8e8.png


接下来我们就需要搞定构造函数和析构函数了,构造函数我们要实现的功能有:可以直接用const char*类型构造一个字符串,如果是一个空串必须给string开一个空间用来存放\0,下面我们来实现一下:

namespace sxy
{
  class string
{
  public:
        string()
      :_str(new char[1])
      , _size(0)
      ,_capacity(0)
    {
            _str[0] = '\0';
    }
    string(const char* str)
      :_size(strlen(str))
    {
      _capacity = _size;
      _str = new char[_capacity + 1];
      strcpy(_str, str);
    }
  private:
       char* _str;
       size_t _capacity;
       size_t _size;
}
}


刚刚我们说到空串必须开一个空间用来放\0,所以我们在初始化列表直接开了1个char类型的空间,那么看到上面的代码会不会有一个疑问,我们完全可以直接用空串代表\0并且用缺省值的方式完成空串的实现,所以我们将这两个合二为一:

string(const char* str = "")
      :_size(strlen(str))
    {
      _capacity = _size;
      _str = new char[_capacity + 1];
      strcpy(_str, str);
    }


这样我们就完成了构造函数的实现,我们先实现一下析构函数然后再测试:

~string()
    {
      delete[] _str;
      _str = nullptr;
      _capacity = _size = 0;
    }


析构函数实现起来就非常简单了,我们只需要把给_str开的空间释放掉,这里必须要注意的是,我们开空间用的new []在释放空间的时候一定要用delete[] ,对于c++动态内存我们前面一篇文章当重点讲过,详细的说明了不匹配的危害。释放完空间后将指针置为空,最后再将capacity和size置为0即可。

8159b1205b46497da3b75e9d8a5fdcba.png744c7f46586c49509345cee26998f42c.png74a46d70b9334961a85cceeda46d6cad.png


可以看到空串确实有一个\0并且用字符串构造也是正常的,在s2中的capacity为4是因为后面实现reserve接口有一个小bug我们实现的时候再说。c_str是返回字符串类型,这样方便我们去打印因为我们还没有重载流输出操作符。

const char* c_str() const
    {
      return _str;
    }


接下来实现一个[]接口,因为这个接口分const成员和非const成员,所以我们要实现两个[]接口,如下图:

char& operator[](size_t pos)
    {
      assert(pos < _size);
      return _str[pos];
    }
    const char& operator[](size_t pos) const
    {
      assert(pos < _size);
      return _str[pos];
    }


const的类型是为了防止数据被修改这里就不详细介绍了。我们之前说过[]与at的区别在于,[]会报错,at访问会抛异常,所以我们用assert断言下标小于_size.

接下来我们实现拷贝构造的接口:

string(const string& str)
      :_capacity(str._capacity)
      ,_size(str._size)
    {
      _str = new char[_capacity + 1];
      strcpy(_str, str._str);
    }

拷贝构造的实现其实就是深拷贝问题,我们先用初始化列表将str的size和capacity给_str的size和capacity初始化,然后直接开和str的capacity一样的空间最后将数据拷贝到_str中即可。

接下来我们实现一下赋值重载:

string& operator=(const string& str)
    {
      if (this != &str)
      {
        char* tmp = new char[str._capacity + 1];
        strcpy(tmp, str._str);
        delete[] _str;
        _str = tmp;
        _capacity = str._capacity;
        _size = str._size;
      }
      return *this;
    }


赋值重载是对两个已经初始化过的对象进行使用的,这样就会有三种情况,如下图:

a8b60d27193641ba8ce68121b2c0d864.png


第一种是str的空间远大于_str,第二种是str的空间远小于_str,第三种是两个空间相差不多。如果我们如上图细细划分去赋值会很麻烦,所以我们干脆直接开和用来赋值对象一样大小的空间(这里每次开空间我们都多开一个用来存放\0),当然由于开空间可能会失败,我们为了防止一旦开空间失败了原先字符串里的空间也没有了,所以我们先用开一个临时变量去开空间,然后将数据拷贝到临时变量中,然后再将原来的旧空间释放掉,让_str指向刚刚临时变量开的空间,再将用来赋值的变量的capacity和size给被赋值的变量,为了实现连续赋值所以我们返回*this,同时为了避免有人在使用的时候写错自己给自己赋值了,所以我们做一下判断只能不相同时才能成功赋值。

下面我们来实现一下迭代器,迭代器分为无const和const版本,分别是针对普通对象和const对象的。如下:

        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;
    }


首先typedef一下char*类型和const char*类型,然后实现begin(),begin就是指向首元素的指针所以返回数组名,数组名就是首元素地址。end我们前面说过,由于迭代器是左闭右开所以end只能指向\0的位置,而首元素的位置加上字符串长度size刚好指向\0.const迭代器也同理,只不过const迭代器不支持修改罢了。测试如下图:

void func(const string& s)
  {
    string::const_iterator it = s.begin();
    while (it != s.end())
    {
      cout << *it << " ";
      it++;
    }
    cout << endl;
  }
  void test4()
  {
    string s("hello world");
    string::iterator it = s.begin();
    while (it != s.end())
    {
      (*it)++;
      cout << *it << " ";
      it++;
    }
    cout << endl;
    func(s);
  }


98199961a8e24f13a43634953cc2cd1d.png


搞定迭代器后我们再去搞定字符串比较的运算符重载,这里就非常简单了,只需要用strcmp字符串比较即可,如下:

        bool operator==(const string& str) const
    {
      return strcmp(_str, str._str) == 0;
    }
    bool operator>(const string& str) const 
    {
      return strcmp(_str, str._str) > 0;
    }
    bool operator<(const string& str) const
    {
      return strcmp(_str, str._str) < 0;
    }
    bool operator!=(const string& str) const
    {
      return !(*this == str);
    }
    bool operator<=(const string& str) const
    {
      return !(*this > str);
    }
    bool operator>=(const string& str) const
    {
      return !(*this < str);
    }


strcmp这个函数是依次比较两个字符串的ascll值,当返回值大于0时,第一个字符串大于第二个字符串,当返回值等于0时,第一个字符串等于第二个字符串,当返回值小于0时,第一个字符串小于第二个字符串。建议:1.在我们写代码的时候,有些函数能复用就尽量去复用。2.对于不涉及修改的成员函数建议给函数加上const这样一来非const成员调用这个函数只是权限的缩小不会放大,const成员调用权限一致也可以调用。如果我们不加const,那么const成员调用函数的时候就无法调用,因为权限放大了。


接下来我们实现push_back接口,push_back本身实现起来并不复杂,但是每次尾插一个字符都要涉及到是否需要扩容,所以重点是实现扩容函数,如下图:

        void reserve(size_t n)
    {
            if (n>capacity)
      {
            char* tmp = new char[n + 1];
      strcpy(tmp, _str);
      delete[] _str;
      _str = tmp;
      _capacity = n;
            } 
    }
    void push_back(char ch)
    {
      if (_size + 1 > _capacity)
      {
        reserve(2 * _capacity);
      }
      _str[_size] = ch;
      _size++;
      _str[_size] = '\0';
    }


是否扩容的条件很简单,我们在实现的时候每次都会多开一个空间用来放\0,所以当size+1>capacity的时候我们就扩容,并且扩为原来容量的两倍:

df7a04305629450a9f4bf5fbd220ca9e.png


我们在空间的时候还是延续之前的传统每次多开一个空间。看过c++内存管理的都知道c++是没有办法在已经有空间的基础上继续扩容的,所以我们只能和实现赋值运算符重载一样,我们先用一个临时变量开空间,然后将原先字符串的数据拷贝到新空间内,再将旧空间释放掉,然后让_str指向刚刚开好的新空间,因为reserve只改变容量所以我们让容量为n,这里为什么不是两倍的capacity呢?因为用户也要用reserve空间,reserve是根据用户的需求开空间的不是只能开2倍capacity。最后我们一定要加上一个判断条件,只有当n大于capacity的时候我们才进行扩容。这样做是因为c++中很少会去进行缩容,因为缩容是有缺点的,所以要避免缩容。push_back的原理我们在图上已经画出来了,当我们尾插一个字符后字符串长度加1就让size++,然后size的位置放上\0即可。


到了这里我们前面的那个关于capacity的小bug就可以解释了,我们发现当一个字符串为空串时,这个时候size为0,capacity也是0,然后我们尾插一个字符,0+1>0就进去扩容了,扩容为2倍capacity但是capacity还是0所以开的空间为0,这个时候放入数据字符串就为随机值,所以我们在构造函数的时候判断当size为0的时候capacity给个初始值4,只有当size不为0再将size给capacity,如下:

        string(const char* str = "")
      :_size(strlen(str))
    {
      _capacity = _size == 0 ? 4 : _size;
      _str = new char[_capacity + 1];
      strcpy(_str, str);
    }


这也就解释了为什么我前面演示空串的时候capacity为4.

接下来我们实现append接口:

        void append(const char* str)
    {
      size_t len = strlen(str);
      if (_size + len > _capacity)
      {
        reserve(_size + len);
      }
      strcpy(_str + _size, str);
      _size += len;
    }


为了大家理解的更清楚我们画个图:

02ea7648923f4f07a3519a39e6be5ece.png

所以我们第一步是计算要插入的字符串的长度,然后判断是否需要重新开空间,有足够的空间后我们将要插入的字符串拷贝到指定的位置,通过上图我们可以看到此位置是_str+size,最后不要忘记了将len加给size。


下面我们通过push_back和append接口去复用运算符重载中的+=接口:

        string& operator+=(const char* str)
    {
      append(str);
      return *this;
    }
    string& operator+=(char ch)
    {
      push_back(ch);
      return *this;
    }


下面我们来实现resize这个接口:

resize的功能有:开空间并且初始化,如果所开空间小于capacity,就将原来多出来的数据删除,也就是说resize会改变size和capacity:

        void resize(size_t n, char ch = '\0')
    {
      if (n < _capacity)
      {
        _str[n] = '\0';
        _size = n;
      }
      else if (n > _capacity)
      {
        reserve(n);
        size_t pos = _size;
        while (pos < n)
        {
          _str[pos++] = ch;
        }
        _size = n;
        _str[_size] = '\0';
      }
    }


当所开空间小于capacity的时候我们直接在n这个位置放入\0,这样就相当于删除这个字符串n后面的字符了,然后我们将size置为n。这里是不需要真的将后面的字符删除的,因为析构函数会自己释放我们所开的空间。当要开的空间大于容量时,我们就用reserve开n个大小的空间,用一个变量去记录刚刚字符串\0的位置从这个位置到n依次放入我们要初始化的字符,这里用了缺省值如果我们不输入指定初始化的字符那么就用\0。到最后记得将size置为n并且把\0放到size的位置。


下面实现insert接口:

        string& insert(size_t pos, char c)
    {
      assert(pos >= 0 && pos <= _size);
      if (_size + 1 > _capacity)
      {
        reserve(2 * _capacity);
      }
      size_t end = _size + 1;
      while (end > pos)
      {
        _str[end] = _str[end-1];
        end--;
      }
      _str[pos] = c;
      _size++;
      return *this;
    }


插入一个字符同样要判断是否扩容,只是需要注意的是下面这样:

1e9c9ade997b48dd86d6145d4de10133.png


如果按照上图中这样实现的话是有问题的,因为我们的end变量是size_t类型,是大于等于0的,当我们要插入的位置是0时,end>=pos这个条件永远会使循环永远不会停止,所以为了避免这个问题我们用str[end] = str[end-1]来实现,如下图:

84b7dbbe9d9047169d968e92df700c1d.png


这样实现的好处是循环结束条件是end>pos,只要不是等于就不会出现上面我们说的那种情况,将字符插入后记得将size++,并且我们可以让别人用这个接口可以接收到插入后的字符串所以返回*this.

下面我们实现插入一个字符串:

        string& insert(size_t pos, const char* str)
    {
      assert(pos >= 0 && pos <= _size);
      size_t len = strlen(str);
      if (_size + len > _capacity)
      {
        reserve(_size + len);
      }
      size_t end = _size + len;
      while (end >= pos + len)
      {
        _str[end] = _str[end - len];
        end--;
      }
      memcpy(_str + pos, str, sizeof(char) * len);
      _size += len;
      return *this;
    }


思想与我们插入一个字符一样,如下图:

6a5ea563d80d4aa6acab81a19837cdd1.png


在这里一定要看好循环结束的条件,当end==pos+len的时候还有最后一个字符没移动所以循环不能结束,在这里我们就不能再用strcpy了,因为strcpy会拷贝字符串结尾的\0,所以我们必须用能按字节拷贝的函数,这里我用了memcpy,大小就是sizeof(char)*len。实现了插入接口我们就可以用插入复用push_back和append了,如下图:

1. void push_back(char ch)
2.    {
3.      insert(_size, ch);
4.      _str[_size] = '\0';
5.    }


        void append(const char* str)
    {
      insert(_size, str);
    }

接下来我们实现find接口:

        size_t find(char ch, size_t pos = 0)
    {
      for (int i = 0; i < _size; i++)
      {
        if (_str[i] == ch)
        {
          return i;
        }
      }
      return npos;
    }


我们上一篇中看了库中find函数的实现,当找不到字符时会返回npos,现在我们先去定义一个npos.如下图:

18cf843b754a49fba6d86ea844a2a5e3.png

因为npos这个变量是string类中公共的并不是每个对象私有的,所以我们定义为静态变量,静态变量的初始化必须在类外初始化,然后我们将npos初始化为-1,在这里一定要加域名限定符,不然就新定义了一个npos。find接口的实现很简单,依次去遍历只要遇到要查找的字符就停下返回下标,如果找不到就返回npos。

下面实现查找一个子串的接口:

        size_t find(const char* str, size_t pos = 0)
    {
      char* p = strstr(_str, str);
      if (p == NULL)
      {
        return npos;
      }
      else
      {
        return p - _str;
      }
    }


查找一个子串我们用strstr函数即可,如果忘了我们可以看一下strstr的说明:


0033d514e19f4ef591b8eb532fcfc17b.png


strstr的第一个参数是要查找子串的字符串,第二个参数是要查找的子串,比如在"hello world"中查找world,那么就会返回w的下标,如果没找到返回空指针。当返回空指针说明找不到子串,那么就返回npos即可,返回值为size_t类型是无法返回指针的,这个时候我们想到指针-指针就是指针之间的元素个数,如下图:

d95d3084d0d0487c9c9ad59dc57b3d14.png


两个指针之间的元素为4个,而4正好是查找到的子串的第一个字符的下标,返回即可。

下面我们再来实现一些简单的接口,比如返回size,返回capacity。

        size_t size() const
    {
      return _size;
    }
        size_t capacity() const
    {
      return _capacity;
    }

clear这个接口是清空字符串,这个接口实现很简单直接在_str[0]的位置放入一个\0,然后将size置为0即可。

1. void clear()
2.    {
3.      _str[0] = '\0';
4.      _size = 0;
5.    }


empty接口是判断字符串是否为空,如下:

1. bool empty() const
2.    {
3.      return _size == 0;
4.    }

接下来我们在实现一个swap接口,直接用库函数即可:

        void swap(string& s)
    {
      std::swap(_str, s._str);
      std::swap(_capacity, s._capacity);
      std::swap(_size, s._size);
    }


string中的交换是非常简单的,只需要将两个字符串的指针指向互换,再将capacity和size互换即可,如果我们直接用一个swap去交换两个字符串就会发现效率非常低,因为tmp会调一次拷贝构造,剩下的两个变量也会调用拷贝构造也就是三次拷贝构造。

接下来我们在实现一个erase接口:

        string& erase(size_t pos, size_t len = npos)
    {
      assert(pos >= 0&&pos<_size);
      if (len==npos||pos + len >= _size)
      {
        _str[pos] = '\0';
        _size = pos;
      }
      else
      {
        strcpy(_str + pos, _str + pos + len);
        _size -= len;
      }
      return *this;
    }


删除从某个位置起的len个字符,如果没有给len,那么len默认是npos也就是整形的最大值意思就是说将pos位置后的全部字符都删除,再使用前我们断言一下pos指针只能大于等于0并且小于size。这里分两种情况,当pos+len大于等于_size的时候我们就将pos位置后面的数全部删除,并且把size置为pos,在这里为什么条件是(len==npos||pos+len>=size)呢,是因为如果我们不写len==npos这个条件的话一旦有人没有给len,那么len就是整形的最大值加上pos就溢出了。当pos+len小于size就说明要删除的字符是在范围内的,在这里我们也不用将数据挨个去移动,只需要将后面的字符拷贝到str+pos位置,然后让size将要删除的len个字符减掉。如下图:

6c932a3ade35442080d83b0a91536998.png


接下来我们实现流插入函数:

对于流插入的函数重载,我们在Date类的时候就说过要将流插入的实现放到类外,否则第一个参数是*this无法直接cout<<打印,cout打印string和C_str是不一样的,c_str是按照字符串进行打印,遇到\0就停止,而流插入是按照size进行打印的,也就是说不管你字符串里面有没有\0,所以实现如下图:

    ostream& operator<<(ostream& out, const string& str)
  {
    for (int i = 0; i < str.size(); i++)
    {
      out << str[i];
    }
    return out;
  }


我们直接用for循环依次去打印str中的每个字符即可,最后记得返回out即可。

流插入的实现很简单,下面我们来实现流提取:

    istream& operator>>(istream& in, string& str)
  {
    str.clear();
    char ch = in.get();
    char buf[128];
    size_t i = 0;
    while (ch != ' ' && ch != '\n')
    {
      buf[i++] = ch;
      if (i == 127)
      {
        buf[i] = '\0';
        str += buf;
        i = 0;
      }
      ch = in.get();
    }
    if (i != 0)
    {
      buf[i] = '\0';
      str += buf;
    }
    return in;
  }


首先如果原先的string中有数据的话我们要将原来的数据清除,如果单单用in的话我们发现无法结束,因为流提取是从缓冲区中拿字符,而空格和换行是不会进入缓冲区的,因为输入多个字符而中间的空格和换行是区分字符的间隔,由于C语言和c++的缓冲区不一样,c中的是getchar,c++中有get和getline,getline是遇到换行符才停,而get函数则是遇到空格和换行停。如下图:

3bc0fb74e8c44fdeab2ce7efd2a1a70b.png

由于我们不确定用户输入的字符串是多少,如果字符串很短就需要频繁的开空间释放空间,这一定会让我们的效率大幅度下降,所以我们直接开一个数组用来存放较短的字符串,定义一个变量i用来访问数组中的字符,只要数组没有满我们就将读取到的字符放入数组中,由于数组中要留一个空间放\0所以在最后一个位置停下放入\0并且将字符串给string,让i重置为0,循环中需要连续读取字符所以要写ch = in.get().如果数组没有满就遇到空格或者换行符,我们就在数组中i的位置放入\0,然后把字符串加到string中去最后返回in即可。


到这里我们就将string中常用的接口全都实现了一遍,这样也能加深我们对string的理解并且可以复习到我们学习的c++6个默认函数。


总结



string由于c++历史原因很多接口都是功能相近的,一共一百多个接口显得太冗余,通过我们的模拟实现string能加深我们对string中常用接口的认识,并且在使用的过程中也能更加游刃有余,下一篇我们将讲解STL中vector的常用接口。

目录
相关文章
|
1月前
|
C++ 容器
|
1月前
|
存储 程序员 C++
C++常用基础知识—STL库(2)
C++常用基础知识—STL库(2)
71 5
|
1月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
22 1
|
1月前
|
编译器 程序员 C++
【C++打怪之路Lv7】-- 模板初阶
【C++打怪之路Lv7】-- 模板初阶
18 1
|
1月前
|
存储 自然语言处理 程序员
C++常用基础知识—STL库(1)
C++常用基础知识—STL库(1)
58 1
|
1月前
|
C++ 容器
|
1月前
|
C++ 容器
|
1月前
|
存储 C++ 容器
|
1月前
|
算法 安全 Linux
【C++STL简介】——我与C++的不解之缘(八)
【C++STL简介】——我与C++的不解之缘(八)
|
1月前
|
算法 数据处理 C++
c++ STL划分算法;partition()、partition_copy()、stable_partition()、partition_point()详解
这些算法是C++ STL中处理和组织数据的强大工具,能够高效地实现复杂的数据处理逻辑。理解它们的差异和应用场景,将有助于编写更加高效和清晰的C++代码。
25 0