冰冰学习笔记: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类的模拟实现的成员就可以实现成下面的样子:

namespace lb
{
    class string
    {
    public:
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	public:
		const static size_t npos = -1;//const可以给值,当作定义-->定义初始化
	};
}

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

2.string类中默认成员函数

2.1构造函数与析构函数

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

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

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

//构造函数冗余版本
string()
	:_str(new char[1])
	, _size(0)
	, _capacity(0)
{
	_str[0] = '\0';
}
string(const char* str )
		:_str(new char[strlen(str)+1])
		, _size(strlen(str))
		, _capacity(strlen(str))
	{
		strcpy(_str, str);
	}

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

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

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

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

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

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

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

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

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

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

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

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

//--传统写法
		string(const string& s)//拷贝构造--深拷贝
			:_str(new char[s._capacity + 1])
			, _size (s._size)
			, _capacity (s._capacity)
		{
			strcpy(_str, s._str);
		}
		string& operator=(const string& s)//赋值
		{
			if (&s != this)
			{
				char* tmp = new char[s._capacity + 1];
				strcpy(tmp, s._str);
				//先拷贝再释放避免空间开辟失败
				delete[] _str;
				_str = tmp;
				_size = s._size;
				_capacity = s._capacity;
			}
			return *this;
		}

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

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

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

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

void swap(string& tmp)//--内部调用全局swap完成交换
		{
			::swap(_str, tmp._str);
			::swap(_size, tmp._size);
			::swap(_capacity, tmp._capacity);
		}
//--现代写法
		string(const string& s)//利用别人去拷贝
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			string tmp(s._str);
			swap(tmp);
		}

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

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

3.元素访问与容量函数

3.1[ ]的重载与at函数

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

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

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

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

3.2容量函数

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

        size_t size()const
		{
			return _size;
		}
		size_t capacity()const
		{
			return _capacity;
		}
		void clear()
		{
			_str[0] = '\0';
			_size = 0;
		}
		bool empty()const
		{
			return _size == 0;
		}

3.3reserve函数resize函数

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

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

        void reserve(size_t n)//开辟空间
		{
			if (n > _capacity)//
			{
				char* tmp = new char[n + 1];
				strcpy(tmp, _str);
				delete[] _str;
				_str = tmp;
				_capacity = n;
			}
		}

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

		void resize(size_t n, char ch = '\0')//开辟空间加初始化
		{
			if (n > _size)//增加数据
			{
				reserve(n);
				for (size_t i = _size; i < n; i++)
				{
					_str[i] = ch;
				}
				_str[n] = '\0';
				_size = n;
			}
			else//删除数据
			{
				_str[n] = '\0';
				_size = n;
			}
		}

4.迭代器的实现

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

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

        //迭代器---指针
		typedef char* iterator;
		iterator begin()
		{
			return _str;
		}
		iterator end()
		{
			return _str + _size;
		}
		const typedef char* const_iterator;
		const_iterator begin()const
		{
			return _str;
		}
		const_iterator end()const
		{
			return _str + _size;
		}

5.插入与删除函数

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

5.1push_back与pop_back

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

		void push_back(char ch)//插入
		{
			if (_size == _capacity)//检查空间是否足够
			{
				reserve(_capacity == 0 ? 4 : 2 * _capacity);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}
		void pop_back()
		{
			assert(_size > 0);
			_size--;
			_str[_size] = '\0';
		}

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

5.2append与+=

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

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

	    void append(const char* str)
		{
			size_t len = strlen(str);
			if (_size+len > _capacity)
			{
				reserve(_size+len);
			}
			strcpy(_str + _size, str);
			//strcat(_str , str);//效率低,因为要遍历找'\0'
			_size += len;
		}

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

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

		//插入一个string类
        void append(const string& s)
		{
			append(s._str);
		}

        //插入n个字符
		void append(size_t n, char ch)
		{
			reserve(_size + n);
			for (size_t i = 0; i < n; i++)
			{
				push_back(ch);
			}
		}

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

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

5.3insert与erase

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

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

		string& insert(size_t pos, char ch)//插入字符
		{
			assert(pos <= _size);
			if (_size == _capacity)
			{
				reserve(_capacity == 0 ? 4 : 2 * _capacity);
			}
			size_t end = _size + 1;
			while (end>pos)
			{
				_str[end] = _str[end - 1];
				--end;
			}
			_str[pos] = ch;
			++_size;
			return *this;
		}

插入过程如下所示: 

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

        string& insert(size_t pos, size_t n, char ch)//插入n个字符
		{
			assert(pos <= _size);
			if (n == 0)//插入0个字符
			{
				return *this;
			}
			if (_size+n > _capacity)
			{
				reserve(_size+n);
			}
			size_t end = _size + n;
	
			while (end>= pos+n)
			{
				_str[end] = _str[end-n];
				--end;
			}
			while (pos <= end)
			{
				_str[pos] = ch;
				++pos;
			}
			_size += n;
			_str[_size] = '\0';
			return *this;
		}
        string& insert(size_t pos, const char* str)
		{
			assert(pos <= _size);
			size_t len = strlen(str);
			if (len == 0)//极端情况插入空串
			{
				return *this;
			}
			if (_size+len > _capacity)
			{
				reserve(_size+len);
			}
			size_t end = _size + len;
			while (end>=pos+len)
			{
				_str[end] = _str[end - len];
				end--;
			}
			strncpy(_str + pos, str, len);
			_size += len;
			return *this;
		}

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

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

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

6.find与substr函数

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

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

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

		size_t find(const char ch, size_t pos = 0)const 
		{
			assert(pos < _size);
			size_t i = 0;
			for (i = pos; i < _size; i++)
			{
				if (_str[i] == ch)
				{
					return i;
				}
			}
			return npos;
		}
		size_t find(const char* sub, size_t pos=0)const
		{
			assert(sub);
			assert(pos < _size);
			const char* ptr = strstr(_str + pos, sub);
			if (ptr == nullptr)
			{
				return npos;
			}
			else
			{
				return ptr - _str;
			}
		}

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

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

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

		string substr(size_t pos, size_t len = npos) const
		{
			assert(pos < _size);
			size_t realen = len;
			if (len == npos || len + pos > _size)
			{
				realen = _size - pos;
			}
			string ret;
			for (size_t i = 0; i < realen; i++)
			{
				ret += _str[pos + i];
			}
			return ret;
		}

7.运算符重载

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

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

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

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

	ostream& operator<<(ostream& out, const string& s)
	{
		for (size_t i = 0; i < s.size(); ++i)
		{
			out << s[i];
		}
		return out;
	}
	//istream& operator>>(istream& in, string& s)//频繁扩容
	//{
	//	char ch = in.get();
	//	while (ch != ' ' && ch != '\n')
	//	{
	//		s += ch;
	//		ch = in.get();
	//	}
	//	return in;
	//}
	istream& operator>>(istream& in, string& s)//
	{
		s.clear();//清除原有字符
		char ch = in.get();
		const size_t N = 32;
		char buff[N];
		size_t i = 0;
		while (ch != ' ' && ch != '\n')
		{
			buff[i++] = ch;
			if (i == N - 1)
			{
				buff[i] = '\0';
				s += buff;
				i = 0;

			}
			ch = in.get();
		}
		buff[i] = '\0';
		s += buff;
		return in;
	}

总结

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bingbing~bang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值