【C++】string类模拟实现


作者简介:一名在后端领域学习,并渴望能够学有所成的追梦人。
个人主页:不 良
系列专栏:C++  Linux
学习格言:博观而约取,厚积而薄发
欢迎进来的小伙伴,如果小伙伴们在学习的过程中,发现有需要纠正的地方,烦请指正,希望能够与诸君一同成长!


文章目录

  • 默认成员函数
    • 构造函数
    • 析构函数
    • 拷贝构造函数
    • 赋值运算符重载
  • 访问及遍历函数
    • 重载运算符[]
    • 迭代器函数begin和end
  • 容量相关函数
    • size函数
    • capacity函数
    • empty函数
    • clear函数
    • reserve函数
    • resize函数
  • 修改操作相关函数
    • push_back函数
    • append函数
    • +=运算符重载
    • c_str函数
    • find函数
    • insert函数
    • swap函数
    • erase函数
  • string类的非成员函数
    • 重载流插入<<和流提取>>
    • getline函数
  • 关系运算符重载

模拟实现的string类在库里面已经存在,为了避免命名冲突我们可以使用命名空间的方式来定义string类以示区分。

string类底层是字符数组,所以我们可以根据库中的string类来定义模拟实现的string类中的成员变量。

#pragma once
namespace Niu {
	class string {
	private:
        char* _str;
        size_t _size;
        size_t _capacity;
	};
}

默认成员函数

构造函数

string库中的构造函数:

string()//构造空的string类对象,即空字符串
string(const char* s)//用 C-string 来构造 string 类对象

模拟实现一个无参的构造函数和一个有参的构造函数,并且在初始化列表中显示定义:

//无参构造函数
string()
    :_str(nullptr)
    ,_size(0)
    ,_capacity(0)
{}
//有参构造函数
string(const char* str)
    :_str(str)//error,属于权限的放大问题
    ,_size(strlen(str))
    //VS下_capacity不包含\0的大小,所以用strlen,在cstring头文件中
    ,_capacity(strlen(str))
{}

但是_str变量为char*类型,而形参strconst char*类型,所以上述有参构造函数初始化列表中_str(str)存在权限的放大问题:

image-20230626171424712

此时可以将类成员变量改成const char*类型,但是虽然对于构造函数可以通过编译了,但是_str指向的内容位于常量区,常量区的内容无法再修改。而且如果_str为空指针,当我们想实现c_str函数时会出现错误,因为c_str函数是通过\0来打印字符串的,_str为空,解引用空指针就会报错,而string类是通过size大小打印字符串的。

那我们应该怎么修改呢?

**对于无参构造函数,初始化列表中初始化_str的时候不要给空指针,使用new char[1]开辟一个空间。**new的时候也可以不加[],但是为了保证析构时方便,最好加上[]。

//无参构造函数
string()
    :_str(new char[1])
     ,_size(0)
     ,_capacity(1)
{	
     //保证c_str返回值解引用时不为空指针
     _str[0] = '\0';//这里使用了重载运算符[]
}

对于有参数的构造函数,我们可以通过在函数体内开空间的方式+拷贝的方式来定义构造函数,注意初始化列表顺序和声明顺序:

我们可以先将str指向内容的长度用strlen函数记录在_size中,然后让_capacity等于_size,再开辟空间并且将str指向的内容拷贝到_str中。

string(const char* str)
	:_size(strlen(str))
{
    _capacity = _size;
    //_capacity 是存储的有效数据个数,所以要多开一个空间用来存储\0
    //这里多开的空间是_str指向的空间,和size和capacity没有关系
    _str = new char[_capacity + 1];
    //将str中的内容拷贝到_str
    strcpy(_str, str);//按字节将str指向的内容拷贝到_str中
}

上面两个构造函数我们可以使用一个全缺省的构造函数替代:

//全缺省构造函数,可以使用一个空的字符串来代替
string(const char* str = "")
	:_size(strlen(str))
{
    _capacity = _size;
    //_capacity 是存储的有效数据个数,所以要多开一个空间用来存储\0
    //这里多开的空间是_str指向的空间,和size和capacity没有关系
    _str = new char[_capacity + 1];
    //将str中的内容拷贝到_str
    strcpy(_str, str);//按字节将str指向的内容拷贝到_str中
}

注意:默认构造函数在类中只能存在一个,所以无参的构造函数和全缺省的构造函数不能同时存在,最好使用全缺省的构造函数。

析构函数

string类的析构函数需要我们自己实现,因为申请的空间是在堆区,当对象销毁时堆区空间,不会自动销毁,为了避免内存泄漏,需要使用delete[]手动释放堆区的空间。

//析构函数
~string()
{
    delete[] _str;//使用delete[]进行析构
    _str = nullptr;//置空
    _capacity = _size = 0;
}

拷贝构造函数

在实现拷贝构造函数之前,我们先了解一下深浅拷贝

浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。

深拷贝:给每个对象独立分配资源,保证多个对象之间不会因共享资源而造成多次释放造成程序崩溃问题。

当我们使用编译器自动生成的拷贝构造函数运行如下代码时会出现报错,原因就是因为浅拷贝问题。

void test_string1()
{
    string s1;
    string s2("hello world");
    string s3(s2); //拷贝构造s3

    cout << s1.c_str() << endl;
    cout << s2.c_str() << endl;
    cout << s3.c_str() << endl;
}

拷贝构造时,浅拷贝只是按照字节将s2中的指针_str_size_capacity拷贝到s3中,并没有在堆上开辟新的空间,也就是说s3中的指针_str指针和s2中的_str指针相同,指向的是同一块堆空间。

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

示意图:

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

两个指针指向同一块空间会有两个大问题:

1.一个修改会影响另一个

2.要析构两次

所以,为了避免出现上述问题,我们在实现拷贝构造的时候必须在堆上开空间,让新的对象有自己的空间:

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

此时要在拷贝构造中开空间从而使新对象有自己独立空间,称为深拷贝。

这里提供两种深拷贝的方法:

传统写法:先在堆上申请一份新空间,让新对象的_str指针指向这份空间,将原有对象_str指向的内容拷贝到新空间之后再给新对象的_size_capacity赋值使其与原有对象相等。

string(const string& s)
//多开一个空间用来存放\0
	:_str(new char[strlen(s._str) + 1])
{
    //strcpy(_str, s._str);
    //也可以使用memcpy函数,+1将原字符串中的\0也拷贝过来
    memcpy(_str, s._str, strlen(s._str) + 1);
    _size = s._size;
    _capacity = s._capacity;
}

现代写法:利用构造函数先将s对象_str指针指向的内容生成一个新的对象,然后利用swap函数将两个对象进行数据交换,交换之后新对象的_str指针指向的内容就是原有对象指针指向的内容,完成之后函数中创建的对象调用析构函数完成对象的销毁。

string(const string& s)
    :_size(s._size)
    ,_capacity(s._capacity)
{
    string s1(s._str);
    //使用尚未实现的swap函数交换两个指针
    //swap(s1);
    //也可以使用算法库中的swap函数
    std::swap(s1._str,_str);
}

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

赋值运算符重载

赋值运算符重载也存在深浅拷贝问题,所以实现赋值运算符重载时也要进行深拷贝。

传统写法:先将目标对象的原有空间释放,避免过大造成空间的浪费,然后再开空间,使用strcpy函数或者memcpy函数将_str指针指向的内容拷贝过去并将_size_capacity赋值。

//传统写法
string& operator=(const string& s)
{
	//if避免自己给自己赋值
    if (this != &s)
    {
        delete[] _str;//先将原来的空间释放,避免原有空间过大造成空间的浪费
        _str = new char[s._capacity + 1];//申请新的空间,+1是为了\0
        //strcpy(_str, s._str);//拷贝
        memcpy(_str, s._str, strlen(s._str) + 1);
        _size = s._size;
        _capacity = s._capacity;
    }
}

现代写法:通过采用传值传参的方法接收右值,让编译器自动调用拷贝构造函数,然后我们再将拷贝出来的对象与左值进行交换。

string& operator=(string s)
{
    //尚未实现的swap函数
    //swap(s);
    //也可以通过交换str指针的方式
    std::swap(_str, s._str);
    _size = s._size;
    _capacity = s._capacity;
    return *this;
}

但是这种写法无法避免自己给自己赋值这种情况。

访问及遍历函数

重载运算符[]

重载运算符[]使其可以通过访问下标的方式来访问和修改字符。

实现[] 运算符重载时先判断下标是否合法,然后返回对象C字符串对应位置字符的引用,便能实现对该位置的字符进行读取或者修改操作。

//返回引用可以修改字符串内容,可以读取和修改字符串内容
char& operator[](size_t pos)
{
    assert(pos < _size);//要判断下标是否合法
    return _str[pos];
}

//此函数只能读取字符串内容不可修改
const char& operator[] (size_t pos) const
{
    assert(pos < _size);//要判断下标是否合法
    return _str[pos];
}

迭代器函数begin和end

在string和vector中数据是连续的,所以我们可以将迭代器认为是指针。

string类中的迭代器可以认为是字符指针,只是给字符指针起了一个别名叫iterator。

typedef char* iterator;
typedef const char* const_iterator;

begin函数的作用就是返回字符串的第一个字符的地址。

//两个重载函数
iterator begin() //返回之后指向的内容可以修改
{
    return _str;
}
const_iterator begin() const //返回之后指向的内容不可以修改
{
    return _str;
}

end函数的作用就是返回字符串最后一个字符的下一个字符,即\0

//end函数
iterator end()
{
    return _str + _size;
}
const_iterator end() const
{
    return _str + _size;
}

范围for是由迭代器支持的,如果迭代器begin函数名改为Begin函数名,则范围for就不能支持了。

实际运用:

string s2("hello world");
const string s3 = s2;

string::iterator it = s2.begin();
while (it != s2.end())
{
    (*it)++;//修改内容
    cout << *it;
    it++;
}
cout << endl;
//范围for
for (auto& e : s2)
{
    e++;//修改字符串内容
}
cout << s2.c_str() << endl;

//const对象,不支持范围for修改字符串内容
string::const_iterator cit = s3.begin();
while (cit != s3.end())
{
    //(*cit)++;//error,不能修改内容
    cout << *cit ;
    cit++;
}

容量相关函数

size函数

返回字符串中已经存在的字符串个数,不包含\0

size_t size() const
{
    return _size;
}

capacity函数

返回字符串中当前容量。

size_t capacity() const
{
    return _capacity;
}

empty函数

string类可以通过_size是否为0来判断字符串是否为空。

//判空
bool empty() const
{
    return _size == 0;
}

clear函数

clear函数可以将已有字符串内容清空,string类打印时是根据size大小打印的,我们可以将size更改为0,然后将字符串首个字符改为\0即可。

//将字符串清空
void clear()
{
    //只用将首字母更改为\0,然后将_size改为0即可
    _str[0] = '\0';
    _size = 0;
}

reserve函数

reserve是扩容函数。

只有当n大于_capacity时才取扩容,当n小于等于_capacity时不需要扩容。

//扩容函数
void reserve(size_t n)
{
    //当n大于capacity时再扩容,否则不用扩容
    if (n > _capacity)
    {
        char* tmp = new char[n + 1];//开一个空间用来放\0
        strcpy(tmp, _str);//将_str指向的内容拷贝到tmp指向的空间中
        delete[] _str;//释放_str指向的空间
        _str = tmp;//让_str也指向tmp指向的空间
        _capacity = n;//改变_capacity大小
    }
}

resize函数

resize函数可以改变当前对象的有效字符的个数。

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

但是当n<=_size的时候只会改变_size的大小,不会改变_capacity的大小,因为缩容的代价很大,要开空间还要挪动数据。

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

    }
}

修改操作相关函数

push_back函数

在字符串后尾插字符c

_size == _capacity时,说明空间已经满了,此时需要扩容,可以使用reserve函数进行扩容,然后将要插入的字符插入到字符串结尾部分,并++_sizepush_back的时候还要注意当_capacity等于0的情况,此时可以修改构造函数或者添加判断条件。

void push_back(char c)
{
    //如果空间已经满了就要扩容,2倍扩容
    if (_size == _capacity)
    {
        //判断_capacity是否为0
		_capacity == 0 ? 4 : _capacity;
        reserve(_capacity * 2);
    }
    _str[_size] = c;
    ++_size;
    //还要在后面加一个\0
    _str[_size] = '\0';
}

append函数

在字符串后追加一个字符串

void append(const char* str)
{
    size_t len = strlen(str); //str字符串的大小,不包含\0
    if (_size + len > _capacity)
    {
        reserve(_size + len);//扩容
    }
    //strcat追加函数
    //strcat(_str, str);
    strcpy(_str + _size, str);//插入到_str字符串的后面

    //也可以使用循环插入
    /*size_t i = 0;
			while (i <= len) //=len是将\0也插入
			{
				_str[_size] = str[i];
				_size++;
				i++;
			}*/
    _size += len;
}

推荐使用strcpy函数,因为strcat是自己去找\0,找到了才在后面追加;strcpy拷贝字符串的时候会把\0拷贝到后面,不需要单独处理。

+=运算符重载

对字符串进行插入操作的时候更喜欢使用+=,实现可以复用push_back和append函数。

//重载+=
//返回自定义类型尽量用引用,减少拷贝
string& operator+=(char ch)//实现连接字符
{
    push_back(ch);//复用push_back
    return *this;
}
string& operator+=(const char* str)//实现连接字符串
{
    append(str);//复用append
    return *this;
}

c_str函数

函数作用就是返回C格式字符串,C格式字符串打印时候是遇到\0就停止打印,string类字符串打印的时候是按照size大小去打印的。

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

find函数

从字符串pos位置开始往后找字符c,返回该字符在字符串中的位置,如果找到了返回第一个字符匹配出现的位置,如果没有找到返回npos(因为字符串不会有npos那么长,所以不用担心比字符串npos大)。

npos是string类的一个静态成员变量,其值为整型最大值。此时在模拟实现类中的成员变量中加一个静态成员变量npos,并在类外定义。

#pragma once
namespace Niu {
	class string {
    private:
        char* _str;
        size_t _size;
        size_t _capacity;
        static size_t npos;
    };
	size_t string::npos = -1;
}

库中函数定义:

//从pos位置向后找与str匹配的第一个位置
size_t find (const string& str, size_t pos = 0) const;
//从pos位置向后找与s匹配的第一个位置
size_t find (const char* s, size_t pos = 0) const;
//从pos位置向后找与字符c匹配的第一个位置
size_t find (char c, size_t pos = 0) const;

查找字符串中指定字符的位置

先判断pos位置是否合法,然后通过遍历字符串找到等于ch的字符位置,返回字符所在位置的下标;如果没有找到就返回npos。

//find函数查找字符
size_t find(char ch,size_t pos = 0) const
{
    assert(pos < _size);
    size_t i = pos;
    while (i < _size)
    {
        if (_str[i] == ch)
        {
            return i;
        }
    }
    return npos;
}

查找字符串中指定子串的位置

先判断pos位置是否合法,然后通过strstr函数找到子串的首个字符的下标位置,如果没有找到返回npos,找到了返回首个字符的下标位置。

size_t find(const char* str, size_t pos = 0) const
{
    char* p = strstr(_str + pos, str);//strstr是暴力匹配
    if (p == nullptr)
    {
        return npos;
    }
    else
    {
        return p - _str;//指针-指针得到的是两者之间的数据个数
    }
}

insert函数

在字符串中插入字符或者字符串。

1.在字符串的下标为pos位置插入字符

我们先观察下面的程序中是否存在问题?

//在下标为pos的位置插入ch
string& insert(size_t pos , char ch)
{
    assert(pos <= _size);//检测下标合法性
    if (_size  == _capacity)
    {
        _capacity = 0 ? 4 : _capacity;//避免_capacity等于0
        reserve(_capacity * 2);//使用reserver函数扩容
    }
    //但是这种写法当pos为0时会出现错误
    size_t end = _size; //下标为_size位置是\0
    while (end >= pos)
    {
        _str[end + 1] = _str[end];
        --end;
    }
    _str[pos] = ch;
    ++_size;
    return *this;
}

代码中当pos = 0 时,循环条件end = pos时仍然能够进入循环,然而因为endsize_t(无符号整数)类型,end不可能会出现小于0的情况,while循环条件将永远成立,所以我们可以将end和pos都改为int类型或者使用下面的代码:

string& insert(size_t pos, char ch)
{
    assert(pos <= _size);//检测下标合法性
    if (_size == _capacity)
    {
        _capacity = 0 ? 4 : _capacity;//避免_capacity等于0
        reserve(_capacity * 2);//使用reserver函数扩容
    }
    size_t end = _size + 1; //下标为_size位置是\0
    while (end > pos)
    {
        _str[end] = _str[end - 1];
        --end;
    }
    _str[pos] = ch;
    ++_size;
    return *this;
}

2.在字符串的下标为pos位置之前插入字符串

string& insert(size_t pos, const char* str)
{
    assert(pos <= _size);
    size_t len = strlen(str);//记录要插入字符串的长度
    if (_size + len > _capacity)
    {
        reserve(_size + len);//扩容
    }
    size_t end = _size + len;
    while (end > pos + len - 1)
    {
        _str[end] = _str[end - len];
        end--;
    }
    还可以这样进行操作
    //size_t end = _size;	
    //for (size_t i = 0; i < _size + 1; i++)
    //{
    //	_str[end + len] = _str[end];
    //	--end;
    //}
    //strncpy(_str + pos, str, len);//在pos位置后连接上字符串str,包含pos位置
    //_size += len;
    //return *this;

    strncpy(_str + pos, str, len);//在pos位置后连接上字符串str,包含pos位置
    _size += len;
    return *this;
}

插入字符串和插入字符有相同之处,我们可以将插入字符看作是len = 1,那么插入字符串的代码同样使用于插入字符。

插入字符串的时候使用strncpy,不能使用strcpy,否则会将待插入的字符串后面的\0也插入到字符串中。

实现insert之后,push_back函数和append函数都可以复用insert:

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

swap函数

swap就是将对象中的指针,_size_capacity进行交换。

void swap(string& s)
{
    std::swap(_str, s._str);//交换指针
    std::swap(_size, s._size);//交换_size
    std::swap(_capacity, s._capacity);//交换_capacity
}

erase函数

erase函数的作用是删除字符串任意位置开始的长度为len的字符。删除前也需要判断pos的合法性,进行删除操作的时候分两种情况:

1、pos位置和之后的有效字符都需要被删除,这时只需要在pos位置放上\0,然后将对象的_size更新即可。

2、pos位置开始需要删除的字符只是字符串中的一部分,此时我们可以用strcpy函数将后方需要保留的有效字符覆盖前面需要删除的有效字符即可。strcpy会将字符串结尾处的\0也覆盖过去。

void erase(size_t pos = 0, size_t len = npos)
{
    assert(pos < _size);
    assert(pos >= 0);
    //当len大于字符串长度时直接在pos位置加上\0
    if (len == npos || pos + len > _size) //pos位置及其后面的字符都被删除
    {
        _size = pos;//_size更新
        _str[_size] = '\0';//在pos处加上\0
    }
    else
    {
        strcpy(_str + pos, _str + pos + len);//将后面的内容覆盖过去
        _size -= len;
    }
}

string类的非成员函数

重载流插入<<和流提取>>

重载流插入<<

重载<<运算符是为了让string对象能够像内置类型一样使用<<运算符直接输出打印,可以使用范围for进行遍历。

ostream& operator<<(ostream& out, const string& s)
{
    for (size_t i = 0; i < s._size; i++)
    {
        out << s._str[i];
    }
    //使用范围for进行遍历
    //for (auto e : s)
    //{
    //	out << e;
    //}
    return out;
}

重载流提取>>

使用>>时字符和字符之间的分隔符就是空格或者换行符,所以我们可以设置直到读取到空格或是换行符时便停止读取。输入的时候是输入到缓冲区里面,我们可以使用in.get()读取字符。

istream& operator>>(istream& in, string& s)
{
    //先清空字符串
    s.clear();
    char ch = in.get();
    while (ch != ' ' && ch != '\n') //当读取到的字符不是空格或\n的时候继续读取
    {
        s += ch;//将读取到的字符尾插到字符串后面
        ch = in.get(); //继续读取字符
    }
    return in;
}

+=要不断的扩容,我们可以开一个字符数组用来存储字符,等达到一定数量的字符后,再放到对象中,可以一定程度增加效率。

istream& operator>>(istream& in, string& s)
{
    //先清空字符串
    s.clear();
    char ch = in.get();
    char buff[128];
    size_t i = 0;
    while (ch != ' ' && ch != '\n') //当读取到的字符不是空格或\n的时候继续读取
    {
        buff[i++] = ch;
        if (i == 127)
        {
            buff[128] = '\0';
            s += buff;//将读取到的字符尾插到字符串后面
            i = 0;
        }
        ch = in.get();
    }
    if (i != 0)
    {
        buff[i] = '\0';
        s += buff;
    }
    return in;
}

扩展:

对象中指针指向的字符串内容是存在堆上的,不占用对象的空间,所以计算对象大小时只当作一个指针的大小。

在VS下string库中的对象类的大小为28,因为还有一个buff数组,当指针指向的内容小时就在自己的buff数组中,大的话存到堆里面。

image-20230628114328704

s2对象中还有一个char [16]的buff数组:

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

在 Linux 的 g++ 编译器中32位下是4个字节,64位下是8个字节,只有一个指针,指针指向的空间存有_size_capacity_refcount等,这与他们的底层实现有关,g++编译器下的string类的结构如下图所示:

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

string类中只有一个指针 _ptr ,其他属性以及数据都存放在堆区开辟的空间中,如果后面进行了拷贝构造,则直接实例化出一个新的对象,并把指针指向同一个空间,同时将 _refcount +1:

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

如果想要修改 s1 或 s2 的内容,修改哪个都会触发写时拷贝,再在堆区上另外开辟出一块空间,进行写操作:

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

在析构时,如果_refcount的值大于 1 ,则进行--操作;如果等于 1 ,就把该空间释放掉。

getline函数

getline函数的实现我们只要将流提取的while循环条件改为ch != ' '就实现getline函数。

istream& getline(istream& in, string& s)
{
    s.clear(); //清空字符串
    char ch = in.get();
    while (ch != '\n') //只要不等于\n就继续
    {
        s += ch;
        ch = in.get();
    }
    return in;
}

关系运算符重载

string类比较大小时,比较的是ASCII码的大小。当我们实现了 > 和 == 之后可以通过复用来实现其他运算符重载。

不修改成员变量数据的情况下,最好都加上const。

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 s == *this || *this > s;
}
bool operator<(const string& s) const
{
    return !(*this >= s);
}
bool operator<=(const string& s) const
{
    return !(*this > s);
}

你可能感兴趣的:(C++,c++,开发语言,后端)