C++第十六节课 万字详细手动实现string类!

std::basic_string

std::basic_string 是 C++ 标准库中定义的一个模板类,它用于表示字符串。C++ 中的 `std::string` 实际上是 `std::basic_string<char>` 的一个特化版本。也就是说,`std::string` 是 `std::basic_string` 这个模板类的一个具体实现,专门用于处理以字符为基础的字符串。

通过类模板实例化的出了string还是wstringu16stringu32string

其中u16string表示一个字符16个字节;

u32string表示一个字符32个字节;

GTP总结:

string类的模拟实现:

为了与标准库做区分,我们可以自定义一个命名空间,在命名空间内实现string类!

实现string的构造函数

  • _str不能直接直接等于参数中的str(_str是char*,str是const char*如果直接等于会出现权限的放大,且如果传入的参数是常量字符串,那么_str此时无法进行修改!)
  • size和capacity都不会考虑/0的结果;
  • 类中成员变量的声明应该和初始化列表的顺序一致;

最终我们实现第一版的string的构造函数

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

对于没有参数的默认构造函数:

		string()
			:_size(0)
			,_capacity(0)
			//, _str(nullptr)
			, _str(new char[1])
		{
			_str[0] = '/0';
		}
  • C 风格字符串的要求:C 风格字符串是由字符数组组成的,并且以空字符 ('\0') 结束。_str 必须指向有效的内存,以便能表示一个字符串。
  • 一个有效的空字符串应该至少包含一个字符:这个空字符 ('\0')。避免空指针解引用:如果 _str 被初始化为 nullptr,当任何试图访问 _str 的成员方法(例如 c_str())被调用时,程序将试图解引用空指针,造成未定义行为(runtime error 或 segmentation fault)。

接下来我们可以尝试利用缺省参数将两个string写到一个函数里面:(常量字符串末尾自带/0)

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

这两种写法都可以:第一种相当于里面有两个/0第二种相当于里面有一个/0,但是最好还是采用第二种写法!

实现string的析构函数

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

		const char *c_str()
		{
			return _str;
		}

实现string的一些接口函数:

c_str

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

size

		size_t size()const
		{
			return _size;
		}

一般希望c_str和size后面参数+上const:使得const对象和普通对象都可以传递!

operator[] 

首先,size和c_str可以只有一个版本(不需要const版本!)因为这两个函数只是返回参数,并不会对成员变量进行修改(只读不写)!

但是[]需要提供两个版本,因为[]需要提供修改变量的功能!

且编译器会选择最合适的版本:如果有两种实现,那次此时普通的变量会去调用普通的函数,被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];
		}

遍历对象

因为前面我们实现了size函数,我们可以直接用for循环来遍历对象:

	for (size_t i = 0; i < s1.size(); i++)
	{
		s1[i]++;
	}

	for (size_t i = 0; i < s1.size(); i++)
	{
		cout << s1[i] << " ";
	}
	//const只能读取不能修改
	const hello::string s3("hello world");
	s3[0];

其中要注意的是:const只能读取,不能修改!(且const会匹配最适合自己的函数!)

接下来我们尝试使用迭代器实现遍历:

string里面的迭代器中:begin指向第一个字符串,end指向有效字符的下一个位置:/0不是有效字符;

在这里:string的迭代器实际上就是一个char*的指针!

因此,我们尝试实现以下迭代器:、

		typedef char* iterator;
		iterator begin()
		{
			return _str;
		}
		iterator end()
		{
			return _str + _size;
		}

使用迭代器完成遍历的任务:

	hello::string::iterator it = s1.begin();
	while (it !=s1.end())
	{
		cout << *it << " ";
		++it;
	}
	cout << endl;
}

除此之外,还可以使用范围for实现任务:

	for (auto ch : s1)
	{
		cout << ch << " ";
	}
	cout << endl;

虽然我们的类没有实现范围for,但是我们还可以使用,这是因为范围for底层就是迭代器,使用范围for的时候,系统自动调用迭代器!

且如果将自定义的iterator修改名字,例如End,此时范围for就找不到对应的迭代器!(是一种傻瓜式的底层应用!-- 将迭代器换名字End,系统还是会自动去找end而出错!) 

在底层的汇编中:还是调用对应的迭代器函数!

接下来我们实现一个const版本的迭代器:

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

使用const遍历对象只能读不能改写,如下所示:

	const hello::string s3("hello world");
	s3[0];

	hello::string::const_iterator cit = s3.begin();
	while (cit != s3.end())
	{
		cout << *cit << "";
		++cit;
	}
	cout << endl;

在这里,如果我们需要实现push_back和append,那么我们需要先实现resreve!即先完成扩容的功能! 

实现reserve

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

实现push_back

		void push_back(char ch)
		{
			if (_size == _capacity)
			{
				// 2倍扩容
				// 如果初始为空字符串,那么给4;否则为原来的2倍
				reserve(_capacity == 0 ? 4 : _capacity * 2);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}

实现append

		void append(const char* str)
		{
			size_t len = strlen(str);
			if (_size + len >= _capacity)
			{
				// 进行扩容
				// 至少扩容到_size + len
				reserve(_size + len);
			}
			strcpy(_str + _size, str);
			_size += len;
		}

同理!在我们实现了上述三种接口函数之后,我们可以复用功能来实现+=的运算符重载!

实现opeartot+=

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

在这里我们通过函数重载实现operator+=!

实现Insert

这里我们实现两种类型的insert:在pos位置插入n个字符和在pos位置插入毅哥字符串!

实现insert之前我们需要考虑下面几点:

  • pos位置是否在[0,size]之内;
  • 首先先进行扩容;
  • 数据向后面移动;

接下来我们考虑向pos位置插入n个字符的情况:

		void insert(size_t pos, size_t n, char ch)
		{
			assert(pos <= _size);
			// 扩容
			if (_size + n >_capacity)
			{
			reserve(_size + n);
			}
			// 挪动数据
			// pos位置插入一个数据
			// (size-pos)个数据整体向后面移动1位;
			size_t end = _size;
			while (pos <= end)
			{
				_str[end + n] = _str[end];
				end--;
			}
			// 插入数据
			for (size_t i = 0; i < n; i++)
			{
				_str[pos + i] = ch;
			}
		}

其中,因为我们在pos位置插入数据,因此我们需要把pos位置之后的数据向后移动,即将(size-pos)个数据向后移动n位;

最终我们可以得到上面的第一代实现!

但是上面的函数有一个问题:当pos = 0时,此时pos位置永远小于end!程序会进入死循环!

这个地方,end是size_t的,pos也是size_t的,当pos为0时,end >=0 是永远成立的,虽然end在一直- -。其实每次到0的时候又变成一个非常大的数。就死循环了!

如果只把end变为int类型,当end = -1的时候还会进入循环!

int end = 0;

这是因为有符号和无符号进行比较,此时有符号会转化为无符号的数字,然后再进行比较!

此时可以考虑将end和pos都改为int类型,但是库里面的实现都是size_t类型!因为我们还是尽量按照库的标准来实现!

解决方法一:

强转类型:

			int end = _size;
			while ((int)pos <= end)

此时是两个整形进行比较;

解决方法二:

通过定义npos:

	private:
		size_t _size;
		size_t _capacity;
		char* _str;
		static size_t npos;

此时我们在类外定义npos的值:

静态成员变量不能在类中给出缺省值,静态成员变量不属于某个类中,不走初始化列表;

但是,注意:

const修饰的静态成员变量可以在类中给缺省值(只有整形)!

	private:
		size_t _size;
		size_t _capacity;
		char* _str;
		const static size_t npos = -1;
	size_t string::npos = -1;

但是double类型的const static修饰的成员变量又不能在类中给缺省值!

			size_t end = _size;
			while (pos <= end && pos != npos)
			{
				_str[end + n] = _str[end];
				end--;
			}

在循环处增添判断条件:pos<=end && pos!= npos

此时如果pos为空则不会进入循环,而直接进行插入!

解决方法三:

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

通过使end = _size+1;则循环判断条件没有=的时候,当pos = end的时候循环结束!

接下来我们考虑插入字符串的类型:

		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 + len;
			while (pos < end)
			{
				_str[end] = _str[end - len];
				end--;
			}
			// 插入数据
			for (size_t i = 0; i < len; i++)
			{
				_str[pos + i] = str[i];
			}
			_size += len;
		}

这里字符串类型和字符类型的思想基本一致!

实现earse

如果我们需要实现earse我们需要考虑两种情况:

  • 直接将pos位置后面的字符全部删除;
  • 删除pos位置后来的有限个字符;

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

			}
		}

实现find

find函数我们主要实现两种:从pos位置开始,查找一个字符和

从pos位置开始,查找字符串!找到后返回下标

如果没找到,则返回npos;

查找一个字符:

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

查找一个字符串:

		size_t find(const char* str, size_t pos)
		{
			assert(pos < _size);
			const char* ptr = strstr(_str + pos, str);
			if (ptr) // 此时查找到了对应的字符串
			{
				return ptr - _str;
			}
			else
			{
				return npos;
			}
		}

此时我们巧妙用C语言的strstr函数来实现:如果查找到对应的字符串,则会返回查找的字符串的指针;

实现substr

从pos位置开始取一个长度为n的字串,如果找到则返回字串,返回类型为string;

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

但是对于上面代码,存在一个问题:最后返回临时变量tmp,但是tmp是一个自定义类型,需要调用拷贝构造函数(然后继续调用析构函数),但是此时我们没有写对应的拷贝构造函数,所以默认是浅拷贝,返回的临时对象所在的空间已经被销毁,再调用对应的接口函数会出现问题!

解决方法:构造一个深拷贝!

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

开辟一个同样大的空间,然后再将值拷贝过去!

实现resize

resize官方给了两种类型:我们可以通过缺省参数将两种类型合并一起!

接下来我们考虑三种情况:

假如_size = 10,_capacity = 15;

分别考虑上述三种情况:

		void resize(size_t n, char ch = '\0')
		{
			if (n < _size)
			{
				_size = n;
				_str[_size] = '\0';
			}
			else {
				reserve(n);   //如果需要扩容会自动扩容
				for (size_t i = _size; i < n; i++)
				{
					_str[i] = ch;
				}
				_size = n;
				_str[_size] = '\0';
			}
		}

实现流插入<<

流插入和流提取的函数我们要写在类外面,我们我们想要第一个参数为ostream/istream;

ostream设置的有防拷贝,因此返回类型和传参类型必须是返回类型,防止我们使用拷贝构造(后面会详细讲)

下面为流插入的代码实现:

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

注意点:这里的ostream是std里面的,所以我们用的时候需要将命名空间展开或者指定作用域! 

这里打印C类型的字符串和打印string类型的区别:C类型字符串本质是打印const char*,遇到 \0 就停止,但是打印s是在使用循环,打印完size()就停止!

下面两种情况下打印出现差别: c_str()遇到 \0 就停止,即使后面又添加了字符也不会打印,但是流插入依然会打印!(vs13系列对待中间\0会按照空格打印;vs19以后会直接不打印)

s._str这种打印方式不可取!(内置成员变量为私有的!)

因此,上述我们实现的函数,当我们开辟一个空间,想把原来的内容拷贝进去,我们使用的是strcpy,但是strcpy遇到 \0 就中止,\0后面如果还有数据则无法拷贝,因此我们建议替换为memcpy!

复习下memcpy的用法:

void *memcpy(void *str1, const void *str2, size_t n)

参数

  • str1 -- 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
  • str2 -- 指向要复制的数据源,类型强制转换为 void* 指针。
  • n -- 要被复制的字节数。

尤其是拷贝构造:需要将strcpy换成memcpy (应该将string全部内容都拷贝过来,如果使用strcpy则中间遇到 \0 就停止 --- string对象中间包含又 \0);

总结:

  • c的字符数组,以 \0 为终止算长度;
  • string不看 \0,以size为终止算长度;

实现流提取>>

将向终端控制台输入的数据提取出来!

输入的数据不能+const!因为我们输入的数据会传入终端控制台,+const数据不能修改;

问题一:能否直接输入s._str?

不能!首先_str是私有的成员变量,我们不能使用;其实,如果我们想进行输入s._str,那么这个变量应该占用多少空间呢?直接进行输入的话系统无法给出确切的空间大小!

问题二:流提取 / scanf怎么进行输入数据的分割?

遇到' '或者'\n'自动进行分割!(默认不会读取空格 / 换行)

注意点:这里我们不能使用>>!因为>>不会区分空格和换行,会使得程序一直进行,没办法停止!

在这里我们使用get()函数,get函数的作用是遇到' '和'\0'就会自动停止!

get 是istream 中的成员函数,用于读取单个字符

	istream& operator>>(istream& in, string& s)
	{
		//char ch;
		char ch = in.get();
		in>>ch;
		while (ch != ' ' && ch != '\n')
		{
			s += ch;
			//in >> ch;
			ch = in.get();
		}
		return in;
	}

输出的结果遇到空格或者 \0 会自动停止,与库中的实现的效果一样!(且这里的+=会自动完成扩容)

但是上面的代码还存在一些问题,我们先看一些库里面的实现:

void test10()
{
	std::string s;
	cin >> s;
	cout << s;
	cin >> s;
	cout << s;
}

我们发现两次连续的输入,库里面的流输入会将之前的内容清空! 

接下来尝试我们自己的实现:

void test10()
{
	shy::string s;
	cin >> s;
	cout << s << endl;;
	cin >> s;
	cout << s << endl;;
}

如果连续进行输入,库里面的实现是对之前的内容进行覆盖!

但是我们自己实现的函数中,内容并没有被覆盖!

因此我们自己完成一个清除数据的接口函数:

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

然后再对之前的流提取进行修改:

	istream& operator>>(istream& in, string& s)
	{
		s.clear();
		//char ch;
		char ch = in.get();
		//in>>ch;
		while (ch != ' ' && ch != '\n')
		{
			s += ch;
			//in >> ch;
			ch = in.get();
		}
		return in;
	}

改进点:

当我们使用+=的时候,如果我们初始给定的字符串很长,那么函数会持续扩容,使得整体效率不高!接下来我们来查看一下:在扩容函数中增加打印语句查看调用多少次

 输入上面这么长的字符串,经历了6次扩容,效率较低!

解决方法:使用一个数组;

	istream& operator>>(istream& in, string& s)
	{
		s.clear();
		char ch = in.get();
		//创建一个类似桶的东西存储数据
		char buff[128];
		int i = 0;
		while (ch != ' ' && ch != '\n')
		{
			// 用通存储数据
			buff[i++] = ch;
			if (i == 127)
			{
				// 此时桶装满了,将数据导出,再重接接数据
				buff[i] = '\0';
				s += buff;
				i = 0;
			}
			ch = in.get();
		}
		// 此时桶还没装满,提前退出
		if (i != 0)
		{
			buff[i] = '\0';
			s += buff;
		}		
		return in;
	}

可以发现,此时输入超长的字符串,也只进行了两次扩容;

接下来还有一个问题:

当我们先输入空格或者换行的时,接下来输入的内容无法显示:

 这是因为get函数的作用是遇到' '和'\0'就会自动停止!

我们试验std库的实现,发现可以打印

接下来我们对自己的库进行改进:

	istream& operator>>(istream& in, string& s)
	{
		s.clear();
		char ch = in.get();
		// 处理缓存区前面的空格或者换行
		while (ch == ' ' && ch == '\n')
		{
			char ch = in.get();
		}
		//创建一个类似桶的东西存储数据
		char buff[128];
		int i = 0;
		while (ch != ' ' && ch != '\n')
		{
			// 用通存储数据
			buff[i++] = ch;
			if (i == 127)
			{
				// 此时桶装满了,将数据导出,再重接接数据
				buff[i] = '\0';
				s += buff;
				i = 0;
			}
			ch = in.get();
		}
		// 此时桶还没装满,提前退出
		if (i != 0)
		{
			buff[i] = '\0';
			s += buff;
		}		
		return in;
	}

实现比较<

我们给出第一种写法:

		bool operator<(const string& s)
		{
			return strcmy(_str, s._str) < 0;
		}

这个代码看似没有问题,但是如果字符串中间又 \0 的话,那么无法正常进行比较! 

因此我们使用memcpy来实现,复习下memcpy函数:

int memcmp(const void *str1, const void *str2, size_t n)

参数

  • str1 -- 指向内存块的指针。
  • str2 -- 指向内存块的指针。
  • n -- 要被比较的字节数。

返回值

  • 如果返回值 < 0,则表示 str1 小于 str2。
  • 如果返回值 > 0,则表示 str1 大于 str2。
  • 如果返回值 = 0,则表示 str1 等于 str2。

但是这里使用memcmp比较会出现一个问题:按照谁的_size大小来比较?

应该按照两个字符串中较短的来进行比较!

但是如果按照小的进行比较,那么上述情况又会出现问题!

因此,这里我们可以不借用库中的函数,自己来实现:

		bool operator<(const string& s)
		{
			size_t i1 = 0;
			size_t i2 = 0;
			while (i1 <_size && i2 <s._size)
			{
				if (_str[i1] < s._str[i2])
				{
					return true;
				}
				else if (_str[i1] > s._str[i2])
				{
					return false;
				}
				else {
					i1++;
					i2++;
				}
			}
		// 出循环后,此时说明两端字符串肯定有一段执行完了
		// 可能有下面三种情况:
		// "hello" "hello" --> false
		// "hello" "helloxxx"  -->true
		// "helloxxx" "hello  --->false
			// 写法一:
			//if (i1 == _size && i2 != s._size)
			//{
			//	return false;
			//}
			//else 
			//{
			//	return true;
			//}
			// 写法二:
			return _size < s._size;
		}

接下来我们再来提供一个复用库函数来实现:

		bool operator<(const string& s)
		{
			// 内存比较:按照两个字符串中长度较短的进行比较
			// s1 < s2
			bool ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
			// 可能有下面三种情况:
			// "hello" "hello" --> false
			// "helloxxx" "hello  --->false
			// "hello" "helloxxx"  -->true
			return ret == 0 ? _size < s._size:ret<0;
			// 其中_size<s._size代表上面三种情况
			// ret < 0    ---> 如果按内存比较s1<s2 == ret<0		
			}

实现其他比较运算符

实现了上面的<运算比较的时候,我们可以进行服用实现其他函数:

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

且因为比较运算符都是可读的,我们可以加上const进行修饰!(const 对象和普通对象都可以调用) 

实现赋值运算符=

进行s1 = s3;此时如果是浅拷贝,那么s1指向的空间直接指向s3,最终同一块空间进行两次析构而报错;

此时正确的做法是:s1再开辟一段新的空间,将原来的空间释放掉,再将s3的内容拷贝过去;

与拷贝构造不同的是,拷贝构造直接开辟与原对象一样大的空间,再将数据拷贝过去;

传统的赋值运算符=写法:
		string& operator=(const string& s)
		{
			if (*this != s)
			{
				char* tmp = new char[s._capacity + 1];
				memcpy(_str, s._str, s._size + 1);
				delete[] _str;
				_str = tmp;
				_size = s._size;
				_capacity = s._capacity;
			}
		return *this;
		}
现代的赋值运算符=写法: 

现代赋值运算是借用拷贝构造,拷贝一个一样的对象,再将其值复制过去;

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

tmp是一个局部对象,出了作用域就会被销毁!

考虑下面一种问题:能否这样子调用swap函数?

 不可以!会造成死循环递归而导致栈溢出!

swap就会调用赋值运算符,而这里我们就是实现赋值运算符的!

实现swap交换

string库里面实现的有string类型的交换:

接下来我们尝试写一下:

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

接下来我们可以尝试修改下上面实现的代码:

写法三:

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

写法四:

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

但是写法四中的参数不能是const修饰的,因为此时tmp会被修改!

这里的tmp是s3的深拷贝,然后再调用tmp进行转换;

此时s3的值不会发生改变;tmp的值会发生改变!

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

一道秘制的小菜

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

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

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

打赏作者

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

抵扣说明:

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

余额充值