Bootstrap

C++入门篇之string类的底层原理和模拟实现

目录

前言

1. string类构造(初始化)

2.string的容量操作

2.1 size()和capacity()

2.2 empty()和clear()

 2.3 resize()和reserve()

3.string的访问和遍历操作

3.1   operator[]

3.2   迭代器

3.3   范围for

4.string的查找和修改

4.1 push_back()

4.2 append()

4.3 operator+=

4.4 c_str()

4.5 find+npos+rfind()

​编辑

4.6 substr()

5.string类的模拟实现

5.1深浅拷贝

5.1.1传统写法 

5.2.2现代写法 

5.2string类的实现

5.2.1 定义结构

5.2.2 构造函数 和析构函数

5.2.3 拷贝构造(也叫复制构造)

5.2.4 赋值重载

5.2.5 运算符operator[]

5.2.6 改变容量reserve()和resize()

5.2.7 字符和字符串拼接

5.2.8 查询大小

5.2.9 迭代器

5.2.10插入和删除

5.2.11 clear() 

前言

我们进入string类之前还是得了解一下什么是STL,STL(standard template libaray-标准模板库)C++标准库的重要组成部分,不仅是一个可复用的组件库,而且 是一个包罗数据结构与算法的软件框架STL封装了很多实用的容器,省时省力,能够让你将更多心思放到解决问题的步骤上,而非费力去实现数据结构诸多细节上。接下来我们要学习的内容是关于string类的理解。先理解在使用,往往事倍功半!

1. string类构造(初始化)

在介绍string之前我们的通过查询文档发现,介绍string并不是直接介绍其使用而是引入一个basic_string的模版

template < class charT,
	class traits = char_traits<charT>,    // basic_string::traits_type
	class Alloc = allocator<charT>        // basic_string::allocator_type
> class basic_string;

然后我们继续看文档发现:

typedef basic_string<char> string;

我们知道这个basic_string其实就是string的模版,string就是其推演实例化出来的。那么我们知道了是调用了模版,接下来我们就需要了解string类到底是构造使用的。这里给大家介绍四种常用的构造函数模型。

成员函数功能
string()构造空的string类对象,即空字符串
string(const char s)*常量字符串或字符数组来构造string类对象
string(const string& s)拷贝构造函数
string(size_t n,char c)string类对象中包含n个字符c
#include<iostream>
#include<string>
using namespace std;
void Teststring()
	{
		string s1; // 构造空的string类对象s1
		string s2("hello ZQ"); // 用C格式字符串构造string类对象s2
		string s3(s2); // 拷贝构造s3
		string s4(5, 'Z');//把n个字符c组成的字符串赋给对象.
		cout << s1 << endl;
		cout << s2 << endl;
		cout << s3 << endl;
		cout << s4 << endl;
	}
int main()
{
	Teststring();
	return 0;
}

注意点:

这里将std的命名空间展开了,我们可以直接使用string和cout,如果没有展开我们需要在前面加入std::来限定作用域。 

2.string的容量操作

 容量操作就是对字符串的大小进行增删查改(可以联想到顺序表)等等,这里给大家介绍几种常用的函数接口。

函数名称功能
size()返回有效字符串长度
capacity()返回string对象的容量
empty()检测string对象是否为空
clear()情况对象内容
resize()
将有效字符的个数该成 n 个,多出的空间用字符 c 填充
reserve()预留容量

2.1 size()和capacity()

我们通过查询文档发现文档定义分别为:size_type size() const;size_type capacity() const;

string s("hello zq");
	cout << s.size() << endl;
	//cout << s.length() << endl;和size的作用相差不大,目前主流用法都是size
	cout << s.capacity() << endl;
	cout << s << endl;

 

 我们发现了这里的size和capacity大小不一样,其实这里的string数据的存储的底层原理就是顺序表。

2.2 empty()和clear()

 我们还是先看一下官方文档是怎么定义的bool empty() const;void clear(); 举了下面这个列子我们更容易理解这两函数接口。

s.clear();
	cout << s.size() << endl;
	cout << s.capacity() << endl;
	cout << "s.clear()后,是否为空呢?"<<s.empty() << endl;

 注意点:

clear()清理的是size,即有效字符长度,而capacity并没有变。

 2.3 resize()和reserve()

 我们还是通过文档来了解一下其基本构造形式,

对于resize来说,官方给了其两个重载函数,分别定义如下:

  • void resize (size_t n); 让对象有效字符串大小变为n.
  • void resize (size_t n, char c); 让对象有效字符串大小变为n.

对于reserve()官方文档定义如下: void reserve (size_t n = 0);

其作用是预先改变capacity,,这里和resize不一样,并且只有n大于原来capacity才起效果,效果指的是编译器可能会把capacity调整到大于等于n的地步。

s.resize(10, 'a');
	cout << s.size() << endl;
	cout << s.capacity() << endl;
	// 将s中有效字符个数增加到15个,多出位置用缺省值'\0'进行填充
	// "aaaaaaaaaa\0\0\0\0\0"
	// 注意此时s中有效字符个数已经增加到15个
	s.resize(15);
	cout << s.size() << endl;
	cout << s.capacity() << endl;
	cout << s << endl;
	// 将s中有效字符个数缩小到5个
	s.resize(5);
	cout << s.size() << endl;
	cout << s.capacity() << endl;
	cout << s << endl;
	s.reserve(30);
	cout << s.capacity() << endl;

 我们通过上面的例子清楚的知道resize其实就是重新定义string的大小,而capacity是只给string容量进行初始化。利用reserve提高插入数据的效率,避免增容带来的开销

注意点:

     resize()该表的大小并不是capacity哦,而是size,即有效字符串大小,至于capacity是否会改变则是根据实际情况而定.如果我们没有'a',这种后面的空间都是'\0',

     resize 在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大
小,如果是将元素个数减少,底层空间总大小不变。
     reserve(size_t res_arg=0) :为 string 预留空间,不改变有效元素个数,当 reserve 的参数小于 string的底层空间总大小时, reserver 不会改变容量大小。  

3.string的访问和遍历操作

学到这里了,我们再来介绍一下string的三种遍历方式。

函数名称功能说明
operator[]
返回 pos 位置的字符, const string 类对象调用
迭代器
begin 获取一个字符的迭代器 + end 获取最后一个字符下一个位置的迭 代器
范围for
C++11 支持更简洁的范围 for 的新遍历方式

3.1   operator[]

官方文档定义了两个重载:

  • char& operator[] (size_t pos);
  • const char& operator[] (size_t pos) const;

大部分时候我们调用的是第一个,但如果是const修饰的string对象,则会调用第二个.需要注意的是用const修饰了就不能改变string里面的值了。

void  Teststring3()
{
		 string s1("hello zb");
		 string s2("Hello zq");
		cout << s1 << " " << s2 << endl;
		cout << s1[0] << " " << s2[0] << endl;

		s1[0] = 'H';
		cout << s1 << endl;
}
int main()
{
	Teststring3();
	return 0;
}

3.2   迭代器

 刚接触迭代器的时候我们会很陌生,这里先介绍一下他们的用法。目前我们最常用的迭代器有4个,分别是begin(),end(),rbegin(),rend(),这里会用s.begin()这种方式来 进行迭代器。rbegin()则是反方向的意思。

string::iterator itb = s.begin();
string::iterator ite = s.end();

void  Teststring3()
{
		 string s("hello zb");
		string::iterator itb = s.begin();
		string::iterator ite = s.end();
		while (itb != ite)
		{
			cout << *itb << " ";
			++itb;
		}
		cout << endl;
		string::reverse_iterator rit = s.rbegin();
		while (rit != s.rend())
		{
			cout << *rit << " ";
				++rit;
		}
}

int main()
{
	Teststring3();
	return 0;
}

 

3.3   范围for

 范围for的格式:这里的type可以是int,double,char,不过博主推荐使用auto,因为真的很方便!

for(type i : object)
{}
void  Teststring3()
{
	string s("zqzbyyzyq");
	for (auto i : s)
	{
		cout << i << ' ';
	}
	cout << endl;
	cout << s << endl;
	//如果要修改数据的话就要传引用
	for (auto& i : s)
	{
		i += 1;             //现在有用了
	}
	cout << s << endl;
}

int main()
{
	Teststring3();
	return 0;
}

4.string的查找和修改

 上面学的是初始化一个string操作,其实也就是输入输出,到现在这里了呢,我们就要进行数据的修改啦!到这了string基本上就介绍完啦。接下来我们来看看C++换繁就简的操作吧!

函数名称作用
push_back()尾插

append()

对象末尾添加字符串
operator+=拼接字符串
c_str()string对象转化为C格式字符串
find+npos从某个位置像后查找
rfind()从某一个位置向前查找
substr()获取子串

4.1 push_back()

 查看文档,看一下函数定义void push_back (char c);,即在原对象末尾添加一个字符.

 调用接口使用:

void Teststring4()
{
	string s1("qqz ");
	s1.push_back('a');
	s1.push_back('i');
	s1.push_back('z');
	s1.push_back('b');
	cout << s1 << endl;
}
int main()
{
	Teststring4();
	return 0;
}

 

4.2 append()

对于append,我们查询官方文档给出了7个重载,博主这里便挑选出最常用的几个,分别是:

string& append (const string& str);    末尾添加string对象.
string& append (const char* s);      末尾添加c格式字符串
string& append (const char* s, size_t n);      末尾添加字符串的前n个字符.
string& append (size_t n, char c);     末尾添加n个字符c。

 下面介绍了这些测试用例,来了解这些函数接口的用法。

void Teststring5()
{
	string s1("qqz ");
	string s2("bbz ");
	s1.append(s2);//s2添加到s1
	cout << s1 << endl;
	s2.append(4, '6');//添加n个字符
	cout << s2 << endl;
	s1.append("666");//添加字符串
	cout << s1 << endl;
	char s3[] ="123456";
	s1.append(s3, 3);//添加前N个字符
	cout << s1 << endl;
}
int main()
{
	Teststring5();
	return 0;
}

 

4.3 operator+=

关于+=官方文档定义如下:

string& operator+= (const string& str);

string& operator+= (const char* s);

string& operator+= (char c);

我们可以通过这些函数接口的构造,来实现调用。

void Teststring6()
{
	string s1("abcd");
	string s2("qqq");
	s1 += ' ';                //   拼接一个空格
	s2 += "1234 ";            //   拼接字符串
	s1 += s2;  //   拼接string对象,其实也是字符串
	s1 += s1;//也可以调用自己来实现加等
	cout << s1 << endl;
}
int main()
{
	Teststring6();
	return 0;
}

 

4.4 c_str()

 有时候函数形参并不是string对象,而是c格式字符串,那么传入string对象则会报错,为了解决这种麻烦,便提供了该接口。

void Teststring7()
{
	string s("abcdef");
	char* str = new char[s.size() + 1];
	strcpy(str, s.c_str());
	cout << str << endl;
	delete[]str;
}
int main()
{
	Teststring7();
	return 0;
}

4.5 find+npos+rfind()

先介绍string中的一个值npos,其定义为static const size_t npos = -1;,也就是说npos是整型最大值.而find()虽然有很多重载,但是其返回值为,如果找到目标,就返回目标出现的第一个位置.如果找不到就返回size_t类型的-1,也就是npos.

size_t find (const string& str, size_t pos = 0) const;

size_t find (const char* s, size_t pos = 0) const;

size_t find (char c, size_t pos = 0) const;

上面3种大家可以记忆成,只有3个参数,一个是传字符串或者字符,一个是传开始查找的位置,如果不穿,默认从0开始.

size_t find (const char* s, size_t pos, size_t n) const;

传C格式的字符串,从pos位置开始,查找s的前n个字符.

rfind()和find()查找方向不一样,rfnd()是从右往左来查。

static const size_t npos = -1;
void Teststring9()
{
	string s("hello string");
	string s1("ing");
	cout << s.find("o") << endl;        //从0位置开始查找
	cout << s.find(s1, 4) << endl;         //从4位置开始查找
	cout << s.find('h', 3) << endl;        //从3位置开始查找,找不到'h'返回最大的值
	cout << s.find("stringfind", 5,2) << endl;     //从5位置开始,查找是否存在"wo"
}

 这里再举一个例子,一个是获取文件名的后缀,用这个函数接口,来实现域名和URL的查询。

void Teststring8()
{
	// 获取file的后缀
	string file1("string.cpp");
	size_t pos = file1.rfind('.');
	string suffix(file1.substr(pos, file1.size() - pos));
	cout << suffix << endl;

	// npos是string里面的一个静态成员变量
	// static const size_t npos = -1;

	// 取出url中的域名
	string url("http://www.cplusplus.com/reference/string/string/find/");
	cout << url << endl;
	size_t start = url.find("://");
	if (start == string::npos)
	{
		cout << "invalid url:" << endl;
		return;
	}
	start += 3;
	size_t finish = url.find('/', start);
	string address = url.substr(start, finish - start);
	cout << "域名:" << address << endl;

	pos = url.find("://");
	url.erase(0, pos + 3);
	cout << "url:"<<url << endl;
}

 

 由此可见对于数据的查询操作在string类里面非常频繁使用。

4.6 substr()

我们查询官方定义的文档理解到即返回对象从pos位置开始,长度为len个的子串.len如果不写,则默认是从pos位置开始,一直到末尾.在一个长的字符串里面获得子串。

官方文档定义如下:string substr (size_t pos = 0, size_t len = npos) const;

void Teststring10()
{
		string str = "We think in generalities, but we live in details.";
		string str2 = str.substr(3, 5);     // 输出"think"
		size_t pos = str.find("live");      // position of "live" in str
		string str3 = str.substr(pos);     // get from "live" to the end
		cout << str2 << ' ' << str3 << '\n';
}

 

5.string类的模拟实现

 前面我们对于string里面的很多函数接口进行了理解和调用,下面我们就来模拟实现一下string类的一些函数功能,更加深刻理解到string的底层逻辑,这个知识形成一个闭环。

5.1深浅拷贝

 深浅拷贝在之前的C++类与对象进阶篇拷贝构造里面有提出过,这里再提出来再温故一下。

先介绍一下深浅拷贝的概念:

浅拷贝只将对象的值拷贝过来,存在一定的隐患.

深拷贝给每个对象单独分配资源,一般对象涉及资源管理都会用深拷贝.

老样子,咱们来看一个例子。

class String
	{
	public:
		/*String()
		:_str(new char[1])
		{*_str = '\0';}
		*/
		//String(const char* str = "\0") 错误示范
		//String(const char* str = nullptr) 错误示范
		String(const char* str = "")
		{
			// 构造String类对象时,如果传递nullptr指针,认为程序非法,此处断言下
			if (nullptr == str)
			{
				assert(false);
				return;
			}

			_str = new char[strlen(str) + 1];
			strcpy(_str, str);
		}
		~String()
		{
			if (_str)
			{
				delete[] _str;
				_str = nullptr;
			}
		}
		
	private:
		char* _str;
	};
	// 测试
	void Teststring()
	{
		String s1("hello future!!!");
		String s2(s1);
	}
}

 

 看过之前类与对象的读者,这个时候振臂高呼,我知道我知道,这里析构函数调用了两次,s2在传值拷贝完了之后会进行调用析构函数,又因为s1和s2共用一快空间,这里就会将s1的空间释放了,导致s1进行不了析构触发断点。这个时候,我们想到了要给s2也开一块空间,他两就不会因为一块空间而“打架”咯,传引用,也就是我们说的深拷贝。这里呢,介绍两种写法,一种传统写法,一种现代写法。

5.1.1传统写法 

String(const String& s)
			: _str(new char[strlen(s._str) + 1])
		{
			strcpy(_str, s._str);
		}

5.2.2现代写法 

String(const String& s)
			: _str(nullptr)
		{
			String strTmp(s._str);
			swap(_str, strTmp._str);
		}

这两种写法都是为了解决浅拷贝指向同一块空间的问题,两者的写法,我比较偏向于后面现在的写法更加巧妙的简洁。

5.2string类的实现

接下来,我们来实现一下string类吧!

5.2.1 定义结构

 定义string,其中涉及到长度,容量操作时候,博主之前说过了string的底层实现其实是顺序表.那么我们可以将结构定义成这样:

class mystring
{

public:

private:
	char* _str;
	int _size;
	int _capacity;
};

5.2.2 构造函数 和析构函数

这里实现的是构造函数和析构函数 

 对于构造函数,此篇文章开头就写了关于构造函数的几个函数模版,下面的实现也会一 一对应上面介绍的函数,这里实现了连个一个是空字符串,一个是字符串常量的初始化。

#include<iostream>
#include<string>
#include<assert.h>
using namespace std;
//定义结构
class mystring
{
public:
	//实现构造函数
	mystring()//空
		:_str(new char[1])
	{
		*_str = '\0';
		_size = _capacity = 1;
	}
	mystring(const char* s)
		:_size(strlen(s))   //给_size有效长度
	{
		_str = new char[_size + 1];      //给_str一个strlen(s)+1长度的空间
		strcpy(_str, s);               //拷贝字符串
		_capacity = _size;           //更新容量
		_str[_size] = '\0';
	}
	//析构函数
	~mystring()
	{
		delete[] _str;
		_str = nullptr;
		_size = _capacity = 0;
	}

private:
	char* _str;
	int _size;
	int _capacity;
};

int main()
{
	mystring s1;
	mystring s2("zqqqz");
	return 0;
}

5.2.3 拷贝构造(也叫复制构造)

接下来我们实现拷贝构造,上面讲深浅构造就是为了实现这我们不能调用系统默认的拷贝也就是浅拷贝,我们需要自己定义一下。

	void Swap(mystring& a, mystring& b)
	{//这里为了代码简洁性和可阅读性,定义了一个Swap封装一下
		swap(a._str, b._str);
		swap(a._size, b._size);
		swap(a._capacity, b._capacity);
	}

	mystring(const mystring& t) :_str(nullptr)  
	{//必须有这一置空操作,因为_str开始是个随机数,交换给tmp._str后,被释放会引起大问题
		mystring tmp(t._str);  //直接利用构造函数,给tmp对象开辟了一块空间,并把值传进去.
		Swap(*this, tmp);
	}

 

可以明显看见拷贝构造成功! 

5.2.4 赋值重载

这里可以直接借用现代的写法,调用上面封装的

	mystring& operator=(mystring t)
	{
		Swap(*this, t);  //注意哦,是Swap,而不是swap.
		return *this;
	}

5.2.5 运算符operator[]

对于这个运算符[]重载,我们在上文介绍了在c++中,string的operator[]有两个重载,分别是:

  • char& operator[] (size_t pos); //支持读和写
  • const char& operator[] (size_t pos) const; //只支持读
	char& operator[](size_t pos)
	{//支持读写
		return _str[pos];
	}
	const char& operator[](size_t pos) const
	{//支持只读
		return _str[pos];
	}

 

5.2.6 改变容量reserve()和resize()

对于reserve,上文介绍了当给他传入一个n,且n大于该对象的capacity时候,才会增容.我们来实现一下他。

	void reserve(size_t n)
	{
		if (n > _capacity)
		{
			char* tmp = new char[n + 1];//先开一个块我们需要的空间需要加1放\0
			strcpy(tmp,_str);//这里将_str里面的值先拷贝进来
            delete[]_str;//然后将原来的_str空间释放掉
			_str = tmp;//将我们开好的空间赋值给_str
			_str[n] = '\0';//这里可以先tmp赋值也可以最后赋值
			_capacity = n;//改变容量
		}
	}

 resize比reserve复杂,毕竟是重新定义大小嘛,听着就比reserve高级,且其分为三种情况

  • 当n小于size,则size等于n.
  • 当n>size但是小于capacity时,size仍等于n,但是这个时候即使你传入另一个参数ch,也没有用
  • 当n大于size时候,会增容,然后多出的空间会用ch初始化,ch如果不传,就是\0,最后size等于n,(主要)

我们来实现一下,

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

	}

 

 看到这块代码,大家是不是很有疑惑呀,有三中情况为什么这里直接一个if就解决了呢?

看上面无论哪种情况,我们都最后 的n都等于size,其实是三种情况一起处理了。

5.2.7 字符和字符串拼接

对于字符串的拼接我们主要使用的是+=因为很形象直接交互性好。

但其实对于字符和字符串拼接,用的做多有以下几个:

  • +=
  • push_back
  • append

接下来我们也来实现一下他们吧。

	void push_back(char c)
	{//如果里面并没有容量我们需要增容,还有防止空插入
		if (_size >= _capacity)
		{
			int newcapacity = _capacity == 0 ? 4 : _capacity * 2;
			reserve(newcapacity);
		}
		_str[_size] = c;//将值尾插
		_size++;
		_str[_size] = '\0';
	}

 

 append()函数有很多重载形式,这里介绍最常用的两个。

  • void append(const char* s)

  • void append(const string s)

//拼接
	void append(const char* s)
	{
		int len = strlen(s);
		if (_size + len > _capacity)
		{
			
			reserve((_size + len) * 2);//2倍增容
		}
		strcpy(_str + _size, s);//将字符串拷贝进去
		_size += len;
		_str[_size] = '\0';
	}

string直接把char*改成string就行。

下面来实现+=。

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

5.2.8 查询大小

这里直接调用成员变量就可以。

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

 

5.2.9 迭代器

我们在介绍的时候讲了三种循环遍历的方式,使用过迭代器,现在我们来具体实现一下迭代器。迭代器类似于指针的用法,所以这样定义下来。

typedef char* iterator;
	typedef const char* const_iterator;

	iterator begin() { return _str; }
	iterator end() { return _str + _size; }
	iterator begin() const { return _str; }
	iterator end() const { return _str + _size; }

正常调用时

mystring s2("zqqqz");
	s2 += 'b';
	s2 += "666";
	cout << s2.capacity() << endl;

	mystring s("123456");
	mystring::iterator itb = s2.begin();
	mystring::iterator ite = s2.end();
	while (itb != ite)
	{
		cout << *itb << endl;
		++itb;
	}

5.2.10插入和删除

  • insert(),有两个重载,分别是在某位置插入字符和字符串
  • erase(),从pos位置开始删除,len个,如果len不写,末尾删除pos后的全部

下面我们接着来实现这两板块。

	void insert(size_t pos, const char c)
	{
		if (_size >= _capacity)
		{//增容防空
			int newcapacity = _capacity == 0 ? 4 : _capacity * 2;
			reserve(newcapacity);
		}
		for (int i = _size; i > pos; i--)
		{//将pos位置后面数据移动一位
			_str[i] = _str[i -1];
		}
		_str[pos] = c;//插入
		_size++;
		_str[_size] = '\0';
	}
	void insert(size_t pos, const char* s)
	{
		size_t len = strlen(s);
		if (_size+len > _capacity)
		{
			reserve(len+_size);
		}
		for (int i = _size + len - 1; i > pos+len-1; i--)
		{
			_str[i] = _str[i - len];
		}
		for (int i = pos + len - 1, j = len - 1; j >= 0; i--, j--)
		{
			_str[i] = s[j];
		}
		_size += len;
		_str[_size] = '\0';
	}

 

5.2.11 clear()

清空直接置零就行。

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

;