【C++】string类的模拟实现

文章目录

  • 1.主要结构
  • 2.构造、拷贝构造、赋值重载和析构
    • 1.构造函数
    • 2. 拷贝构造
    • 3. 赋值重载
    • 4. 析构函数
  • 2.迭代器
  • 3.容量相关
    • 1.size和capacity
    • 2. reserve
    • 3. resize
    • 4. clear和empty
  • 4.数据访问
  • 5.数据操作
    • 1.插入数据
    • 2.数据删除
    • 3.数据查找
  • 6.流插入和流提取
  • 7.其他接口

在上一篇博客中,我们讲到了string的使用,现在,我们来讲讲string的模拟实现。

1.主要结构

一个类的构建首先要明确的就是类里面的成员函数,在string类中的结构是什么样的呢?

首先,string类是一个用char类型实例化的模板,类模板是basic_string,所以我们需要实现的是basic_string这个类模板,然后使用typedef basic_string string将实例化出来的类型重命名为string。由于其中对于其他编码方式的处理较为复杂,这里就不考虑,我们直接以string的方式实现basic_string

接下来要考虑的事情就是string中的成员变量,string类需要存放的是字符数组,结合之前在数据结构中学的动态顺序表的知识,所以需要一个指针用来管理数组,需要两个整型用来存放数据个数和容量,所以string类的主要结构如下:

class string
{
public:
	//类中的各种成员函数
private:
	char* _str;
	size_t _size;
	size_t _capacity;
};

2.构造、拷贝构造、赋值重载和析构

1.构造函数

构造函数分为无参和带参两种,对于无参的构造函数,默认构造空字符串“”,所以我们给个缺省值即可。

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

这里对于capacity的处理,我们选择只关注存放的有效字符个数,不算上\0

2. 拷贝构造

在之前的类和对象章节中,我们讲到,拷贝构造和赋值重载是默认成员函数,如果我们不显示写,编译器会自动生成。那么为什么对于string类,我们要写呢?

默认生成的拷贝构造对自定义类型调用其拷贝构造,对于内置类型完成值拷贝。如果使用默认生成的构造函数的话不能完成所需要的任务,而且会出现对同一块空间析构两次的问题。

【C++】string类的模拟实现_第1张图片

如果执行浅拷贝,将会出现上图中的情况,两个对象指向了同一块空间,当其中任何一个对象析构之后,另一个对象就失效了,所以我们需要手动实现深拷贝

1.传统写法

针对我们的需求,我们很容易想到,按照构造一个对象的顺序一步一步来,先开辟一块大小相同的空间,然后将数据拷贝过去,再把_size和_capacity拷贝过去。这就是传统写法

string(const string& s)
{
    _str = new T[s.capacity + 1];
    strcpy(_str, s._str);
    _size = s.size();
    _capacity = s.capacity;
}

2.现代写法

在STL的发展中,总会有些人另辟蹊径,给出不同于常人的想法,正是这些脑洞大开的想法,让社会进步,下面,就让我们一起看看现代写法是什么样的

string(const string& s)
    :_str(nullptr)
        , _size(0)
        , _capacity(0)
    {
        string tmp(s._str);
        swap(tmp);
    }

对于现代写法,我们可以这么理解,我们找了个替身工具人替我们完成任务,这个工具人就是tmp,他替我们完成了创建一个对象的任务,然后我们将tmp和this进行交换,那么在拷贝构造执行完毕之后,tmp对象将会自动调用析构函数,我们就不用管他了,此时this指向的对象中的内容就是与传入的s相同,也就是完成了深拷贝。

3. 赋值重载

默认生成的赋值重载也是只执行值拷贝,所以对于string类,我们需要手动写赋值重载

1. 传统写法

对于赋值重载,我们需要实现的功能就是将this指向的类对象的值全部转换成传入的右操作数的值,按部就班的来做就是首先开辟一块空间,将s指向的数据依次拷贝到新空间中,然后释放掉原左操作数指向的空间,将其指向新空间,然后更新size和capacity。最后,其实需要进行一个判断,防止出现自己给自己赋值。

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

2. 现代写法1

和拷贝构造的现代写法类似,我们找一个工具人tmp来替我们构造出一个新的对象,然后与this执行交换,使得原this指向的对象在函数结束时被析构。

string& operator=(const string& s)
{
    if (this != &s)
    {
        string tmp(s);
        swap(tmp);
    }
    return *this;
}

3. 现代写法2

对于上述的现代写法,其实可以更加简化

string& operator=(string s)
{
    swap(s);
    return *this;
}

在传参的时候直接使用传值传参,在函数栈帧创建的时候构造一个对象s,这个s是传入的参数的拷贝,然后将this和这个s进行交换即可完成任务

注:在现代写法2中,我们发现好像没有进行自己给自己的赋值判断,这里说一下,使用这种写法,对于自己给自己赋值的情况,直接执行不会出现错误,只是效率有点低,但是这种场景出现的情况很少,所以可以不考虑。

4. 析构函数

析构函数就非常简单了,使用delete[]释放空间即可

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

2.迭代器

对于string的迭代器,原生指针就能很好的支持迭代器行为,所以我们直接用原生指针。这里我们只实现正向迭代器的const和非const版本。

typedef char* iterator;
typedef const char* const_iterator;
iterator beign()
{
    return _str;
}
iterator end()
{
    return _str + _size;
}
const_iterator beign() const
{
    return _str;
}
const_iterator end() const
{
    return _str + _size;
}

3.容量相关

1.size和capacity

size和capacity直接返回对象中的成员变量即可

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

2. reserve

reserve是对string对象的容量进行操控的,当容量小于传入的值时,将会扩容,如果容量大于传入的值将不会做任何操作

{
    if (n > _capacity)
    {
        char* tmp = new char[n + 1];
        strcpy(tmp, _str);
        delete[] _str;
        _str = tmp;
        _capacity = n;
    }
}

3. resize

resize的功能就是对string对象的size重新规划,当传入的n > capacity时,需要扩容,然后使用‘\0’来填充(默认情况下),当n小于元素个数时,将会把元素个数直接变成n,当n在size和capacity之间时,将会把后续的内容填充能‘\0’(默认情况下)

void reseize(size_t n, char ch = '\0')
{
    if (n > _capacity)
    {
        reserve(n);
    }
    if (n > _size)
    {
        for (size_t i = _size; i < n; ++i)
        {
            _str[i] = ch;
        }
    }
    _size = n;
    _str[n] = '\0';

4. clear和empty

clear是清除所有数据,但是不销毁的函数,empty是判断是否为空的函数

void clear()
{
    _size = 0;
    _str[0] = '\0';
}
bool empty()
{
    return _size == 0;
}

4.数据访问

对于string的数据访问,我们一般有两种方式,第一就是通过迭代器(范围for也是迭代器的方式),第二种就是通过operator[],所以这里我们只实现一下operator[]

//非const版本,可读可写
char operator[](size_t pos)
{
    assert(pos < _size);
    return _str[pos];
}
//const版本,可读不可写
const char operator[](size_t pos) const
{
    assert(pos < _size);
    return _str[pos];
}

5.数据操作

在学习数据结构的时候,我们一般学习的就是这种数据结构的增删查改四种操作,对于string类也是这样,所以在数据操作中,也分为增删查改四种。

1.插入数据

1. push_back

尾插一个字符,在增加数据的时候,我们需要考虑是否需要扩容

void push_back(char ch)
{
    if (_capacity == _size)
    {
        size_t newCapacity = _capacity == 0 ? 4 : 2 * _capacity;//扩容扩二倍
        reserve(newCapacity);
    }
    _str[_size] = ch;
    ++_size;
    _str[_size] = '\0';//这里注意尾插之后要对字符串结尾的\0做一下补充
}

2. append

尾插一串字符(一个字符串),这里有很多重载,我们只实现一个C-string类型的。

void append(const char* str)
{
    //这里需要判断一下需不需要扩容,如果需要的话要扩多大
    size_t len = strlen(str);
    if (_size + len > _capacity)
    {
        reserve(_size + len);
    }
    strcpy(_str + _size, str);
    _size += len;
}

3. operator+=

对于上述的两种插入方式,我们可以使用一个运算符重载全部解决,直接复用即可

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

4.insert

1. 插入字符

按照insert的逻辑,我们很容易的可以写出以下代码

string insert(size_t pos, char ch)
{
    //判断位置是否合法
    assert(pos <= _size);
    //扩容
    if (_size == _capacity)
    {
        size_t newCapacity = _capacity == 0 ? 4 : 2 * _capacity;
        reserve(newCapacity);
    }
    //挪动数据
    size_t end = _size;
    while (pos <= end)
    {
        _str[end + 1] = _str[end];
        --end;
    }
    _str[pos] = ch;
    ++_size;
    return *this;
}

但是,如果当传入的pos==0时,将会出现死循环的问题,因为pos的类型是size_t,所以在进行比较的时候,编译器会进行隐式类型转换,把end转换成size_t的类型进行比较,所以如果传入的pos==0,就不可能出现pos > end的情况,也就是死循环了,那么我们要怎么解决这个问题呢?其实有两种解决方案

  • 方案一:强转

    将end定义成int类型,然后while中的判断把pos强转成int,这样就可以避免隐式类型转换,从而规避死循环的情况发生

    int end = _size;
    while ((int)pos <= end)
    {
        _str[end + 1] = _str[end];
        --end;
    }
    

​ 但是这种方法看起来有点不太高级,所以这里我们有了另一种方法

  • 方案二:将end指向\0后面的位置,这样就不会出现end<0的情况

    size_t end = _size + 1;
    while (pos < end)
    {
        _str[end] = _str[end - 1];
        --end;
    }
    

2. 插入字符串

插入字符串之前,我们同样要判断容量是否充足。这里在拷贝的过程中,我们要注意使用strcpy的时候,会把\0拷贝进去,所以我们不能使用strcpy而是使用strncpy。

string& insert(size_t pos, const char* str)
{
    //判断位置是否合法
    assert(pos <= _size);
    size_t len = strlen(str);
    //扩容
    if (_capacity < len + _size)
    {
        reserve(len + _size);
    }
    //挪动数据
    size_t end = _size + pos;
    while (pos + len - 1 < end)
    {
        _str[end] = _str[end - len];
        --end;
    }

    strncpy(_str + pos, str, len);
    _size += len;
    return *this;
}

2.数据删除

对于数据的删除,一般来说就是尾删和在任意位置删除。

1. pop_back

尾删很简单,我们只需要把最后一个有效字符的值替换成\0,然后更改_size即可

void pop_back()
{
    _str[--_size] = '\0';
}

2. erase

erase的实现分为两种情况

  1. 当删除的长度大于pos位置之后的长度,或者为npos时,直接删除后续所有的值
  2. 当len小于后续长度时,挪动数据
string& erase(size_t pos, size_t len = npos)
{
    assert(pos < _size);
    if (len == npos || pos + len >= _size)
    {
        _str[pos] = '\0';
        _size = 0;
    }
    //挪动数据
    else
    {
        strcpy(_str + pos, _str + pos + len);
        _size -= len;
    }
    return *this;
}

3.数据查找

find:从pos位置开始查找字符或字符串,找到返回下标,找不到返回npos

1. 查找字符

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

2. 查找字符串

对于字符串的查找我们可以借用strstr函数实现

size_t find(size_t pos, const char* str)
{
    assert(pos < _size);
    const char* ptr = strstr(_str, str);
    if (ptr == nullptr)
    {
        return npos;
    }
    else
    {
        return ptr - _str;
    }
}

注:修改是建立在查找上的,所以这里就不专门讲解了

6.流插入和流提取

对于流插入和流提取的重载,我们在日期类的时候就已经讲过,不能重载成成员函数,如果重载成成员函数会导致this指针抢占第一个位置。所以需要重载成全局函数,然后在类里面设成友元。

1. 流插入

ostream& operator<<(ostream& _cout, const zht::string& s)
{
    for (size_t i = 0; i < s.size(); i++)
    {
        _cout << s[i];
    }
    return _cout;
}

2. 流提取

对于流提取,我们使用cin或者scanf是没有办法拿到空格和\0的,所以这里需要使用get函数来拿到

istream& operator>>(istream& _cin, zht::string& s)
{
    s.clear();
    char ch = _cin.get();
    while (ch != ' ' && ch != '\0')
    {
        s += ch;
		ch = _cin.get();
    }
    return _cin;
}

但是,对于上述的情况,如果需要扩容的话,会导致频繁扩容,所以我们在这里使用一个临时数组来存放,然后统一尾插到string中去。

istream& operator>>(istream& _cin, zht::string& s)
{
    s.clear();
    char buff[128] = { '\0' };
    size_t i = 0;
    char ch = _cin.get();
    while (ch != ' ' && ch != '\0')
    {
        if (i == 127)
        {
            s += buff;
            i = 0;
        }
        buff[i++] = ch;
        ch = _cin.get();
    }
    if (i > 0)
    {
        buff[i] = '\0';
        s += buff;
    }
    return _cin;
}

7.其他接口

在上文中,我们提到了一个swap函数,这个swap函数在使用的时候,形参只有一个,这个swap函数就是在string类中重载的一个函数。

为什么要在string里面重新重载一个swap函数呢?

这是因为算法库里面实现的swap函数是一个模板

【C++】string类的模拟实现_第2张图片

可以看到,无论对于什么类型,他都会无脑的拷贝构造,这样其实对于string这种使用new在堆上开辟空间的类效率是不高的,所以需要在类内重新写一个swap函数,将类内的成员变量进行交换即可,这样就能省去new和delete的消耗。

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

2. c_str

对于Linux操作系统,它是使用C语言写的,所以它的系统接口也都是C形式的接口,而不支持string,所以我们在进行系统调用的时候,需要将string转换成C字符串的形式传参。c_str就是将string类返回成C字符串形式。

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

载一个swap函数呢?

这是因为算法库里面实现的swap函数是一个模板

[外链图片转存中…(img-CP2s20jF-1681724759328)]

可以看到,无论对于什么类型,他都会无脑的拷贝构造,这样其实对于string这种使用new在堆上开辟空间的类效率是不高的,所以需要在类内重新写一个swap函数,将类内的成员变量进行交换即可,这样就能省去new和delete的消耗。

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

2. c_str

对于Linux操作系统,它是使用C语言写的,所以它的系统接口也都是C形式的接口,而不支持string,所以我们在进行系统调用的时候,需要将string转换成C字符串的形式传参。c_str就是将string类返回成C字符串形式。

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

你可能感兴趣的:(c++,c语言)