【C++】string类 模拟实现

发布于:2025-09-13 ⋅ 阅读:(20) ⋅ 点赞:(0)

作者主页:lightqjx

本文专栏:C++

目录

一、简单的条件准备

二、string类中的成员函数模拟实现

1. 构造函数

2. 析构函数

3. string的访问及遍历

4. 获得string类中的信息

5. string类的扩容

6. string类字符串的增加

7.string类字符串的删除

8. string类的查找

9. string类的比较

10. string类的赋值运算符重载

三、非成员函数的实现

四、关于string类中需要注意的问题

1. 深浅拷贝的问题

2. 拷贝构造函数和赋值运算符重载函数现代写法的问题

3. 关于std里面的sting类的字符串存储问题

五、写时拷贝(了解)


一、简单的条件准备

        为了模拟实现一个string类,首先,需要我们自己定义一个命名空间域来存放我们自己实现的string。strng类是一个存放字符串的类,所以,它的成员变量肯定是有字符串指针,有效数据个数,容量的大小这三个变量的,另外,在上一篇文章了解了关于strin的使用后,我们也可以知道strin类中其还有一个静态成员常变量npos的。所以就可以得到如下的代码:

namespace MyTest
{
	class string
	{
	public:

		//string类的成员函数实现.......


	private:
		char* _str;
		size_t size;
		size_t capacity;
	public:
		static const size_t npos; // 静态成员变量
	};
	const size_t MyTest::npos = -1; // 在类外定义
}

二、string类中的成员函数模拟实现

下面大部分的成员函数都是在函数声明和定义在一起的,即成员函数是直接在类中定义的

1. 构造函数

首先要注意,这里不能想着区调用默认构造函数而不写构造函数,因为这里都是内置类型,默认构造不会处理它们。当然构造函数也是有很多个重载函数的,这里我们来实现的是比较常用的,比如使用字符串来实例化、使用string对象来实例化(拷贝构造)。

//常规的构造函数
string(const char* str = "")
{
	_size = strlen(str);
	_capacity = _size;
	_str = new char[_capacity + 1]; // 多开一个,是用来存储'\0'的
	strcpy(_str, str); // 拷贝数据
}
//拷贝构造函数 - 深拷贝
string(const string& str)
{
	_str = new char[str._capacity + 1]; 
	strcpy(_str, str._str); // 拷贝数据
	_size = str._size;
	_capacity = str._capacity;
}

在这里,要注意一下深浅拷贝的问题,这里使用拷贝构造是深拷贝。在本文章的后面会详细解释。

2. 析构函数

析构就是要释放开辟的空间,在string类中是申请了空间(资源)的,就必须要我们自己写析构函数,除了指针,也要将其余成员变量初始一下。

//析构函数
~string()
{
	delete[] _str;
	_str = nullptr;
	_size = _capacity = 0;
}

3. string的访问及遍历

对string对象进行访问和遍历,有两种方法:1、[ ]+下标;2,、迭代器。接下来就要来实现它们。

  • [ ] +下标

这是一个方括号运算符重载,如下所示:

//访问与遍历
// []+下标
char& operator[](size_t pos)
{
	assert(pos < _size);//判空
	return _str[pos];
}
const char& operator[](size_t pos) const //对于 const对象和普通对象都可以使用
{
	assert(pos < _size);
	return _str[pos];
}
  • 迭代器

下面实现一个正向的迭代器:

typedef char* iterator;
typedef const char* const_iterator;
//普通对象
iterator begin()
{
	return _str;
}
iterator end()
{
	return _str + _size;
}
//const对象
const_iterator begin() const
{
	return _str;
}
const_iterator end() const
{
	return _str + _size;
}

    通过这种函数也可以对原数据进行修改。

    4. 获得string类中的信息

    string类中的主要信息就是字符串长度,对象此时的容量,以及字符串首元素地址等。它们都比较简单,只需要返回各自信息即可。

    //获得字符串长度
    size_t size() const
    {
    	return _size;
    }
    //获得字符串容量
    size_t capacity() const
    {
    	return _capacity;
    }
    //获得字符串首元素地址
    const char* c_str() const
    {
    	return _str;
    }

    5. string类的扩容

    reserve和resize都是有关扩容的函数。

            reserve主要是扩大容量的,它会传递一个参数n,当n小于当前对象的字符串容量时,不会缩小容量;但当n大于当前对象的字符串容量时,则会增大容量直到n或则更大(不同平台扩容大小不同)。

    void reserve(size_t n = 0)
    {
    	if (n > _capacity)
    	{
    		char* tmp = new char[n + 1];
    		memcpy(tmp, _str, _size + 1);//拷贝有效字符
    		delete[] _str;
    		_str = tmp;
    		_capacity = n;
    	}
    }

    这里使用内存拷贝memcpy可以防止需要的字符串中间有\0的情况发生。比如:假如对象中的字符串是abc\0de,则如果用strcpy,则只会拷贝abc这三个字符,就会有问题了。

            resize也是将容量改为n,但是resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。它有两个版本,如果n大于当前长度,一个版本会是将多余空间会用指定的字符c填充,另一个版本是将多余空间会用指定的字符'\0'填充,这里我们可以使用缺省值来来合二为一:

    void resize(size_t n, char ch = '\0')
    {
    	if (n < _size)
    	{
    		_size = n;
    		_str[_size] = '\0';
    	}
    	else
    	{
    		reserve(n);//和reserve逻辑一样
    
    		for (size_t i = _size; i < n; i++)
    		{
    			_str[i] = ch; // 将多余空间初始化
    		}
    		_size = n;
    		_str[_size] = '\0';
    	}
    }

    6. string类字符串的增加

    常用的增加字符的函数有:push_back、append、operator+=、insert,下面是对它们的实现:

    push_back、append、operator+=都是正在原字符串后追加字符的。

    push_back只能追加单个字符:

    void push_back(char c)
    {
    	if (_size == _capacity)
    	{
    		// 2倍扩容
    		reserve(_capacity == 0 ? 4 : _capacity * 2);
    	}
    
    	_str[_size] = c;
    	++_size;
    	_str[_size] = '\0';
    }

    append的函数重载有很多,它既可以追加字符,也可以追加字符串。这里我们就来实现一个可以追加字符串的函数:

    void append(const char* str)
    {
    	size_t len = strlen(str);
    	if (_size + len > _capacity)
    	{
    		// 至少扩容到_size + len
    		reserve(_size + len);
    	}
    	// 从_str的_size位置开始拷贝str的内容
    	memcpy(_str + _size, str, len + 1);
    	_size += len;
    }

    虽然上面两种函数可以实现追加,但常常我们并不是使用它们,而是使用+=的运算符重载函数,基于上面两个代码,那么+=的运算符重载函数实现就是如下所示:

    string& operator+=(char ch)
    {
    	push_back(ch);//逻辑是一样的,所以这里简化成直接调用push_back函数
    	return *this;
    }
    string& operator+=(const char* str)
    {
    	append(str);//逻辑是一样的,所以这里简化成直接调用append函数
    	return *this;
    }

    上面这三个函数外都是追加字符后字符串的函数。

    也可以自己选择位置进行插入,这就是函数insert的实现:

    //在pos位置插入n个字符ch
    void insert(size_t pos, size_t n, char ch)
    {
    	assert(pos <= _size);//判断位置合理性
    
    	if (_size + n > _capacity)
    	{
    		// 扩容
    		reserve(_size + n); //逻辑相同,这里也简化了一下
    	}
    
    	// 将pos位置后面的数据向后挪动
    	size_t end = _size;
    	while (end >= pos && end != npos)
    	{
    		_str[end + n] = _str[end];
    		--end;
    	}
    
    	// 在pos位置开始插入数据
    	for (size_t i = 0; i < n; i++)
    	{
    		_str[pos + i] = ch;
    	}
    
    	_size += n;
    }
    
    //在pos位置插入字符串
    void 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;
    	while (end >= pos && end != npos)
    	{
    		_str[end + len] = _str[end];
    		--end;
    	}
    
    	for (size_t i = 0; i < len; i++)
    	{
    		_str[pos + i] = str[i];
    	}
    	_size += len;
    }

    7.string类字符串的删除

    常用来删除sring类中字符串的函数就是erse。

    这里我们实现第一个在pos位置开始删除后面的len个字符。如果len超过了要删除的当前字符串长度,就将要删除的当前字符串全删了。

    void erase(size_t pos, size_t len = npos)
    {
    	assert(pos <= _size);
    	if (len == npos || pos + len >= _size)
    	{
    		_size = pos;
    		_str[_size] = '\0';
    	}
    	else
    	{
    		size_t end = pos + len;
    		while (end <= _size)
    		{
    			_str[pos++] = _str[end++];
    		}
    		_size -= len;
    	}
    }

    还有一个可以清除string类对象中的字符串数据,且不改变容量大小的函数:clear

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

    8. string类的查找

    常用的查找函数就是find函数

     这里来实现find的第二个,从pos位置开始找与s字符串匹配的字字符串,返回匹配的第一个位置。

    // 查找
    size_t find(const char* s, size_t pos = 0)
    {
    	assert(pos < _size);
    	const char* ptr = strstr(_str + pos, s); //相当于strstr的实现了
    	if (ptr)
    	{
    		return ptr - _str;
    	}
    	else
    	{
    		return npos;
    	}
    }

    9. string类的比较

    string类的比较使用通过运算符重载来实现的:

    bool operator<(const string& s) const
    {
    	int size = _size < s._size ? _size : s._size;
    	int ret = memcmp(_str, s._str, size);
    	return ret == 0 ? _size < s._size : ret < 0;
    }
    bool operator==(const string& s) const
    {
    	return _size == s._size && memcmp(_str, s._str, _size) == 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);
    }

    10. string类的赋值运算符重载

    赋值运算符重载是对于两个已经存在的对象之间的拷贝,这里有几种情况需要考虑:

    所以,由于这些原因,所以我们就可以统一重新开空间,再拷贝数据。所以代码实现如下:

    string& operator=(const string& s)
    {
    	if (this != &s)
    	{
    		char* tmp = new char[s._capacity + 1];//重新开辟空间
    		memcpy(tmp, s._str, s._size + 1);//拷贝数据
    		delete[] _str;//释放原空间
    		_str = tmp;//将空间归位
    		_size = s._size;
    		_capacity = s._capacity;
    	}
    	return *this;
    }

    这种写法可以说是传统写法。

    当然还有一个现代写法,通过交换它们的主要数据来完成,如以下代码:

    string& operator=(string s)
    {
    	if (this != &s)
    	{
    		std::swap(_str, s._str);
    		std::swap(_size, s._size);
    		std::swap(_capacity, s._capacity);
    	}
    	return *this;
    }

    三、非成员函数的实现

            string类中的比较重要的常用函数就是流插入(<<),流提取(>>)运算符重载的函数了,如果不是实现就不能直接对string类的对象进行输出,但可以使用c_str进行输出,如以下代码:

    #include<iostream>
    using namespace std;
    int main()
    {
    	MyTest::string s1("Hello world");
    	cout << s1.c_str() << endl;
    	return 0;
    }

    流插入(<<)的运算符重载函数的实现(注意在类外面定义来防止参数顺序的问题):

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

    流提取(>>)的运算符重载函数的实现:

    首先需要了解get这个函数:

    它的意思就是每次只读一个字符。

    流提取(>>)的运算符对于输入的字符串,他会遇到空格或换行符就停止读取

    istream& operator>>(istream& in, string& s)
    {
    	s.clear(); // 每次读取都清除上一次的空间
    	char ch = in.get();
    
    	// 处理前缓冲区前面的空格或者换行
    	while (ch == ' ' || ch == '\n')
    	{
    		ch = in.get();
    	}
    	char buff[128]; //提前开辟空间来存储,防止调用多次扩容函数(提供性能)
    	int i = 0;
    
    	while (ch != ' ' && ch != '\n')//遇到空格或换行就停止读取
    	{
    		buff[i++] = ch;
    		if (i == 127)//临时开辟的空间最后一个要存\0
    		{
    			buff[i] = '\0';
    			s += buff;
    			i = 0;
    		}
    		ch = in.get();
    	}
    	if (i != 0)
    	{
    		buff[i] = '\0';
    		s += buff;
    	}
    	return in;
    }

    四、关于string类中需要注意的问题

    1. 深浅拷贝的问题

            以拷贝构造函数为例,在我们使用一个对象来初始化里另一个对象时,往往会调用拷贝构造函数:对于内置类型,进行的就是值拷贝,也是浅拷贝,当如果对于指针也进行值拷贝,则会将两个对象中的指针指向同一块空间,那么当程序结束时,对于两个对象要调用两次析构函数,但在第一次析构时,两个对象指向的空间都被销毁了,所以在第二次析构时,程序就会崩溃(原因:对已经释放的空间再次释放空间)。

            所以,往往对于有空间开辟的对象,我们都采用深拷贝来进行实现。这里的深拷贝就是另开辟一个空间,再把需要的数据拷贝过去。

    2. 拷贝构造函数和赋值运算符重载函数现代写法的问题

            在上面的实现string类的拷贝构造函数都是传统写法实现的,传统写法具有通用性。在这里,我们来讲一下它们的另一种写法:通过交换来实现它的拷贝构造函数。它的方式就是先使用原数据对象来构造一个有一个一样的数据的对象,在将新构造的函数中的成员变量与需要得到数据的对象的成员变量进行交换,代码实现如下:

    // 拷贝构造函数
    string(const string& str)
    {
    	//先使用字符串来构造一个与s相同数据的对象
    	string tmp(str.c_str());
    
    	//再进行交换
    	std::swap(_str, tmp._str);
    	std::swap(_size, tmp._size);
    	std::swap(_capacity, tmp._capacity);
    }

    我们来调用上面来实现的函数:

    int main()
    {
    	MyTest::string s1("Hello world");
    	MyTest::string s2(s1);//调用现代写法的拷贝构造
    
    	MyTest::string s3("xxxxxxxxxxxxxxxxxxxx");
    	s1 = s3; //赋值重载函数
    	return 0;
    }
    

    对于这样的代码,如果你在新一些的编译器上进行运行,也许是不会崩溃的,这是因为在调用这个函数时,编译器它自己已经初始化了*this的内容,如下图所示已经初始化了

    但这种进行初始化的行为不是普遍的,只是某些编译器的个性化行为,若在其他编译器上进行运行,也需又会崩溃。因为如果其他编译器上的如果都是随机值,则当它与tmp进行交换了之后,tmp对象的内容就是随机值了,但tmp又是局部的对象,在函数结束时会调用析构函数,析构函数对随机值进行随机值进行析构就会有出现崩溃的情况了

    因此,我们为了避免这种情况,我们通常要在定义该种函数时,我们自己实现实现初始化。即:可以使用初始化列表来初始化:

    // 现代写法
    string(const string& str)
    	:_str(nullptr)
    	, _size(0)
    	, _capacity(0)
    {
    	//先使用字符串来构造一个与s相同数据的对象
    	string tmp(str.c_str());
    
    	//再进行交换
    	std::swap(_str, tmp._str);
    	std::swap(_size, tmp._size);
    	std::swap(_capacity, tmp._capacity);
    }

    3. 关于std里面的sting类的字符串存储问题

    对于不同长度的字符串,string类的存储是不同的。

    上图是在VS编译器中对于s1和s2对象的监视窗口的部分显示,可以看到,其中的在_Bax中,有两个需要注意的数据:_Buf和_Ptr,_Buf是一个16个字节点数组,_Ptr是一个指针。虽然还有一个_Alias,但实际上并没有这个东西,这里只是VS窗口的显示了罢了,因为监视窗口看到的也不一定是真实的,它是被编译器处理过的。

            在进一步看,可以看到s1中的字符串是存储在_Buf中的,而s2中的字符串是存储在_Ptr中的,这是因为在Vs这个平台中string类的存储也有一个规律:

    1. 当size小于16时,字符串是存储在数组中的;
    2. 当size大于等于16时,字符串是存储在_Ptr指向的空间中的。

    当然在不同编译器的处理是不同的,上面的规律是在VS上的结果。所以这也是需要注意的一点。


    五、写时拷贝(了解)

            写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。

    为什么会有写实拷贝?

            当我们用一个对象来创建了另一个对象,如果新创建的对象我们如果不怎么用,那么如果使用深拷贝,那么就会出现一些时间上的浪费消耗,但是如果使用浅拷贝,那么就会出现的问题:

    1. 析构两次统一空间的对象,会出错
    2. 修改一个对象,会影响另一个对象

            所以,对于这样的一些情况,引入了写时拷贝的概念。写实拷贝是通过引用计数来实现的(引用计数:用来记录资源使用者的个数)。

            在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象是资源的最后一个使用者,才将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。所以如果计数不是1的时候,如果要对指向该资源的对象进行写入等修改操作时,这时才需要去进行深拷贝,再对其进行修改。

            当然,现阶段,这里对其进行了了解即可,不用深究。


    最后

            string类便就介绍到这里了,以上便是对string类大概的一个模拟实现,相信我们对类和对象也有了更加进一步的理解。

    感谢各位观看!希望能多多支持!


    网站公告

    今日签到

    点亮在社区的每一天
    去签到