【C++】C++11线程库 和 C++IO流

春风若有怜花意,可否许我再少年。

在这里插入图片描述



一、C++11线程库

1.thread类介绍

1.
C++11的线程库实际封装了windows和linux底层的原生线程库接口,在不同的操作系统下运行时,C++11线程库可以通过条件编译的方式来适配的使用不同的接口,比如在linux下,就用封装POSIX线程库的接口来进行多线程编程,在windows下,就用封装WinAPI线程库的接口来进行多线程编程。所以C++11线程库为我们带来了可移植性编程。

下面是thread类的默认成员函数,与POSIX不同的是,利用无参构造创建出来的线程并不会运行,而是只有给线程分配可调用对象之后,该线程才会运行,而POSIX中,只要你调用了pthread_create接口,线程就会立马运行起来。经常使用的thread构造函数就是传一个可调用对象,然后可以选择给可调用对象传参或者不传参数都行,也就是第二个构造函数,该函数不允许隐式类型转换,所以我们应该用()的方式构造出对象,而不是用=的方式来构造对象。第二个参数是可变参数模板,专门用来给调用对象传参用的。
thread不允许拷贝和赋值,这两个函数都被delete掉了,但thread允许移动构造和移动赋值。
this_thread是std中的一个子命名空间,其中包含了当前线程的相关属性接口,例如get_id获取线程tid值,yield让出当前线程的CPU时间片,yield仅仅是一种提示,操作系统是否执行这个提示,这是不确定的,操作系统可以选择忽略,也可以选择执行,其余两个接口是让线程休眠,可以休眠一段时间,也可以休眠到指定的时间点为止。

在这里插入图片描述
2.
在编写线程代码之前,再来回顾重新认识一下进程和线程的关系,以及linux下的进程结构等知识,站在上层的角度把知识串一串。
线程和进程最大的区别就是分配资源和通信这两个方面,linux下的线程是一种轻量级进程,分配的资源是较轻的,只需要分配一个PCB结构体即可,多个线程之间共享地址空间,页表等内核结构。而进程分配的资源是较重的,进程之间具有独立性,每个进程都有自己独立的内核数据结构。
所以由于分配资源的不同进而导致了通信成本的不同,线程由于共享地址空间,所以天然的就可以看到同一份资源,因为我们知道地址空间是资源的窗口,无论是线程还是进程,他们都无法直接操纵物理地址,只能通过资源窗口来访问,所以共享地址空间本身就是共享大部分资源。而通信的前提是让不同的进程或线程看到同一份资源,线程天然的就完成了这个工作,自然线程间通信的成本就会低很多,而进程之间具有独立性,无法天然的完成这个工作,所以进程间通信的成本一定是要比线程高的。
实际上linux下的进程就是一个族谱,从0号进程开始,一直fork出进程,所以整个linux下的进程都是父子关系。0号进程通常指的是操作系统内核,1号进程是由内核创建出的第一个用户空间进程,1号进程也叫init进程,他是所有其他用户进程的祖先进程。

在这里插入图片描述
在这里插入图片描述

3.
下面代码是经典的利用C++11线程库实现的线程池,即用一个vector来管理创建出的多个线程,除直接存放线程对象外,我们也可以new出来thread对象,然后把指向对象的指针存到vector里面,存指针的方式POSIX比较偏爱,但今天我们就用vector来直接存储线程对象。在对线程扩容的时候,有个坑,我们不能显示的写出来thread的无参构造函数,因为vector的resize接口,对于第二个参数thread()匿名对象会进行拷贝,而我们知道线程是不允许被拷贝的,所以在调用resize初始化vector里面的每个线程时,不要显示的给resize传第二个参数,而是直接用resize的缺省参数即可。
为了给每个线程一个可调用对象,我们遍历threads数组进行移动赋值,将匿名的具有可调用对象的线程移动赋值给vector里面的线程对象。可调用对象除了下面使用lambda这样的方式之外,还可以用包装器,函数指针,仿函数对象等等,下面让num个线程打印cnt次自己的线程id,获取线程id就可以通过this_thread命名空间中的get_id接口来获取。
如果未detach线程,那么一定要join线程,如果不join线程,线程资源就得不到回收,此时程序会异常终止。如果设置线程为detach,该线程会被分离出地址空间,操作系统会立即回收该线程的资源。所以要保证创建出线程之后,线程运行完之后,一定要join或detach线程,否则会导致程序异常崩溃。

int main()
{
    
    
	//C++11线程库封装了windows和linux的线程库,通过条件编译来区分用封装linux的,还是windows的接口,
	//C++11线程库面向对象

	int num, cnt;
	cin >> num >> cnt;
	vector<thread> threads;
	//threads.resize(num, thread());//不要显示的传匿名对象,因为resize的第二个参数会调用拷贝构造
	threads.resize(num);

	for (auto& t : threads)
	{
    
    
		t = thread([&cnt]() 
			{
    
    
				for (int i = 0; i < cnt; i++)
				{
    
    
					//这里无法通过线程对象调用get_id(),通过this_thread命名空间来调用get_id()
					cout << std::this_thread::get_id() << "->" << i << endl;

				}
			} );//这里直接调用移动赋值
	}

	for (auto& t : threads)
	{
    
    
		t.join();//阻塞式的回收线程资源 
	}

	return 0;
}

2.mutex互斥锁 和 CAS原子操作(compare and set)

1.
当多个线程操作同一个共享资源时,会出现线程不安全而造成的数据不一致等问题,在下面的打印结果中,当增大操作的次数过后(左图)可以明显看到val的值出现了问题,没有达到30000的预期结果,那么在这样的情况下为了保证线程安全一般需要加锁,即让所有线程互斥式的访问这份共享资源,这个操作在linux下的时候我们早就习以为常了,所以互斥锁不是重点,CAS原子操作才是重点。

在这里插入图片描述
2.
C++提供了线程安全的原子操作,支持++,- -,按位与,按位或等等操作的原子性,以保证线程安全,下面贴了一个atomic的链接,详细信息可以转过去看一下。
那CAS的原理是什么呢?CAS实现主要是依靠三个操作数,内存位置,预期原值,新值。每个线程会先将内存中的共享资源值拿到,并将这个值设置为预期原值,然后对其进行修改得到新值,然后对比当前内存中的共享资源值是否与预期原值相同,如果相同,则将新值写回内存,如果不相同,则写回操作失败,重新读取内存的值,重新修改,重新拿新的预期原值进行比对,看是否满足写入要求。所以当多个线程在写回内存的时候,操作系统将时间粒度缩的足够小,那肯定是有先后顺序的,当某一个线程写入工作完成之后,其余线程在写入之前会进行内存值和预期原值的比对,现在内存中的值是新值,所以比对肯定是失败的,那么其他线程的写入操作都会失败,则需要重新while循环执行再一次的读取,修改,比对,写回的工作,而CAS就是compare and swap,但也有人叫做compare and set,我觉得compare and set更加形象一些,拿线程的预期原值和当前内存位置中的值进行compare,如果相同,则将修改后的新值set到内存里面,如果不相同,则此次CAS操作失败,重新while循环执行新的CAS操作。
这就是CAS操作的原理,当多个线程在修改共享资源的值的时候,由于CAS操作的约束,则可以保证只有一个线程能够修改成功,其余线程需要重新进行新一轮的CAS操作,这就是线程安全的原子操作。

C++中atomic类的介绍
在这里插入图片描述

3.
下面代码中也是演示了全局互斥锁和全局原子操作的使用方式,保证了共享资源的线程安全,但实际项目当中比较忌讳用全局变量,因为全局变量工程的所有文件都可以看到,链接时容易造成链接属性的问题,所以我们一般都用局部的锁和原子。

int val = 0;
mutex mtx;
atomic<int> atoval(0);//实际项目当中,不太推荐用全局变量,因为全局多个文件之间都可以看到,会有链接属性的问题

void Func1(int n)//每个线程都有自己的私有栈,每个线程都会在私有栈建立线程函数栈帧
{
    
    
	for (int i = 0; i < n; i++)
	{
    
    
		//mtx.lock();
		++atoval;
		//mtx.unlock();
	}
}
void Func2(int n)		
{
    
    
	mtx.lock();
	for (int i = 0; i < n; i++)
	{
    
    
		//加锁和解锁也是有消耗的,如果放里面,则会频繁的申请锁释放锁,这会导致效率降低,阻塞到运行还需要线程上下文的保存和恢复,这很废时间。
		//mtx.lock();
		++val;
		//mtx.unlock();
	}
	mtx.unlock();

	
	for (int i = 0; i < n; i++)
	{
    
    
		++atoval;//让++变成原子操作
	}
}
int main()
{
    
    
	int m = 100000;
	//200 100发生错误的概率不大,稍微多线程操作的次数多一点,那就会出错了
	thread t1(Func1, 2 * m);
	thread t2(Func2, m);


	t1.join();
	t2.join();

	cout << atoval << endl;

	return 0;
}

3.lock_guard和unique_lock

1.
lock_guard和unique_lock都是RAII的锁,但unique_lock较为特殊一些,他除了RAII外又主动实现了lock和unlock,这也正是条件变量wait的时候只需要互斥锁的原因,因为线程在条件变量中等待和被唤醒的时候,需要释放锁和加锁,而lock_guard只有RAII,无法实现这样主动加锁和释放锁的功能,所以条件变量wait的时候必须使用unique_lock。

unique_lock

2.
下面代码中我们不再使用全局的锁和原子,而是使用局部的方式,通过lambda捕捉原子和互斥锁的方式来实现线程安全,使用RAII的锁对象时一般配合代码块来进行使用,因为对象的生命周期随代码块儿,所以有RAII对下的代码块就是所谓的临界区,我们想让多线程串行打印cout语句。
除此之外引入了chrono类,该类有多个创建出时间段duration的静态方法,这可以让线程休眠一段指定的时间,休眠函数可以用this_thread命名空间中的sleep_for接口。

int main()
{
    
    
	int m = 100000;
	atomic<int> atoval = 0;
	mutex mtx;

	auto func = [&](int cnt) {
    
    
		for (int i = 0; i < cnt; i++)
		{
    
    
			{
    
    
				lock_guard<mutex> lock(mtx);//可以搞一个代码块来控制临界区的粒度
				cout << this_thread::get_id() << "->" << atoval << endl;
			}
			
			++atoval;//这是原子操作
			this_thread::sleep_for(chrono::milliseconds(1000));
		}
	};

	thread t1(func, 2 * m);
	thread t2(func, m);

	t1.join();
	t2.join();

	cout << atoval << endl;

	return 0;
}

3.
还有一些其他杂七杂八的锁,比较乱,然后平常中我们也用不到,因为我们并不清楚某一个线程被操作系统调度的具体情况,无法做出准确的加锁或解锁某一段时间,所以一般我们就用普通的互斥锁就够了,但是这些杂七杂八的还是说一下比较好,平常需要使用这些锁的时候,直接去查文档就OK了,看看原理,看看使用样例就懂了。
try_lock是一种非阻塞式申请锁的接口,如果锁状态未就绪,则该函数直接返回,可以让线程去做别的工作,你也可以使用try_lock来轮询检测锁的状态。
另一个锁是recursive_mutex,即递归互斥锁,通过线程id则可以判断是否该线程能够进入临界区,如果同一个线程多次进入临界区则递归锁是允许的,其余线程想要进入临界区,递归锁会互斥式的拒绝,除非等锁就绪。

在这里插入图片描述

4.两个线程交替打印,一个打印奇数,一个打印偶数(线程同步)

1.
条件变量是配合互斥锁来进行使用的,所以多线程访问条件变量的操作本身就是线程不安全的,所以使用条件变量之前需要加锁,并且条件变量的wait接口只允许使用unique_lock,有两点原因,一是unique_lock相比原生的mutex更为灵活且安全,因为他是RAII的,二是条件变量需要等待和唤醒操作,这两个操作是在临界区中执行的,那么就需要主动的申请和释放锁,这点lock_guard做不到,所以只能用unique_lock。

2.
通过条件变量来实现两个线程分别打印奇数和偶数是一种非常安全且经典的操作,当条件不满足时,让线程去条件变量内部维护的等待队列进行等待,当条件满足时,唤醒对应条件变量中等待的线程,C++11线程库提供了两个wait接口,第二个接口不怎么好用,因为有点绕,所以一般都是直接用第一个接口让线程进行wait等待,我们自己手动设置等待和唤醒的条件,唤醒的接口是notify_one和notify_all,分别对应POSIX中的pthread_cond_signal和pthread_cond_broadcast,即唤醒一个线程和唤醒多个线程。

在这里插入图片描述
3.
代码实现并不复杂,老铁们可以自己看一下。推荐使用第一个wait接口,下面是程序的打印结果,通过条件变量实现了线程的同步。

在这里插入图片描述

int main()
{
    
    
	int i = 0;
	mutex mtx;
	condition_variable cond;

	//如果想要做到你打印完通知我,我打印完通知你,那就需要各自用while循环,条件不满足就wait,满足就notify
	//如果是for循环一个遍历奇数,一个遍历偶数的话,无法利用条件变量进行wait,因为时时刻刻都是满足条件的。这样不行

	//打印奇数
	thread t1([&]() {
    
    
		while (i < 100)
		{
    
    
			unique_lock<mutex> ulock(mtx);//必须用unique_lock因为它可以主动加锁和解锁
			
			//solution 1
			while (i % 2 == 0)
			{
    
    
				cond.wait(ulock);
			}
			//solution 2
			cond.wait(ulock, [&]() {
    
     return !(i % 2 == 0); });//当i是偶数的时候,那就阻塞,返回false才会阻塞
			
			cout << "t1: " << this_thread::get_id() << " -> " << i << endl;
			i++;
			cond.notify_one();
		}
		});

	//打印偶数
	thread t2([&]() {
    
    
		while (i <= 100)
		{
    
    
			unique_lock<mutex> ulock(mtx);// 必须用unique_lock因为它可以主动加锁和解锁
			//因为unique_lock可以手动加锁和解锁,那就可以满足条件变量的需求,当wait的时候unlock,当被notify时申请锁lock
			//而lock_guard不能手动加锁和解锁,只能在创建和销毁的时候lock和unlock锁
			
			//solution 1
			while (i % 2 != 0)
			{
    
    
				cond.wait(ulock);//推荐使用这个wait接口,下面那个wait接口太绕了
			}
			//solution 2
			cond.wait(ulock, [&]() {
    
     return !(i % 2 != 0); });//当i是奇数的时候,发生阻塞,返回false才会阻塞
			
			cout << "t2: " << this_thread::get_id() << " -> " << i << endl;
			i++;
			cond.notify_one();
		}
		});


	t1.join();
	t2.join();
}

二、C++IO流

1.C++标准IO流(自定义类型到内置类型的隐式类型转换)

1.
C++标准库提供了四个全局流对象,分别为cin cout cerr clog,分别为将数据从键盘流向内存中的程序,数据从内存程序流向显示器文件,标准错误输出到显示器文件,输出日志信息,但cout、cerr、clog是ostream类的三个不同的对象,这三个对象现在基本没有区别,只是应用场景不同罢了。
cin是从缓冲区中拿数据,我们键盘输入的数据会先存放到缓冲区中,输入的数据以换行符为结束符,cin读取时以空格和换行符作为数据的间隔。
C++实现了一个庞大的输入输出流库,其中ios为基类,其他类都直接或间接的是ios类的派生类。

在这里插入图片描述

2.
cin和cout支持所有内置类型的输入和输出其实就是因为<<运算符的函数重载,cin和cout重载了所有的内置类型的流插入<<和流提取>>,而自定义类型想要支持cin>>和cout<<,也很简单,只要类里面重载了自定义类型对象的<<和>>运算符的重载函数即可。
在很多在线OJ题目中有很多IO类型的题,这些题往往都要求循环cin输入,我们知道cin返回的对象是一个istream类的对象,那为什么istream类对象能够做逻辑判断呢?其实是因为隐式类型转换,自定义类型对象可以隐式转换为内置类型,这里的隐式类型转换的实现也是通过运算符重载来实现的,不过严格意义上讲不能叫做运算符重载,因为void *和bool不能算是运算符。
ios基类中实现了operator void *和operator bool函数,这样的函数支持istream和ostream对象隐式类型转换为bool值之后,作为while循环逻辑条件判断的值。当其他内置类型比如int,int *,double等类型作为逻辑条件判断时,都是隐式类型转换为了bool值进行判断的。

在这里插入图片描述

3.
在下面代码中,我们实现了A类的operator int函数,则A类对象便可以隐式类型转换成内置类型int,同理只要我实现了operator bool函数,则A类对象也可以隐式类型转换为内置类型bool。
结束while循环的cin流提取可以通过ctrl+c发送信号杀死进程,或者是ctrl+z将istream流对象转换为的bool类型值设置成false,这样就可以结束while循环的cin流提取了。

class A
{
    
    
public:
	A(int a)
		:_a1(1)
		,_a2(2)
	{
    
    }

	operator int()
	{
    
    
		return _a1 + _a2;
	}
private:
	int _a1;
	int _a2;
};
int main()
{
    
    
	//cout << "1111111111" << endl;
	//cerr << "1111111111" << endl;
	//clog << "1111111111" << endl;


	
	string str;
	while (cin >> str)
		//表达式的返回值是流提取对象,调用cin.operator>>(str),cin为什么能做逻辑条件判断呢?
		//cin的父类ios重载了operator bool和operator void*,void*作条件逻辑判断时,还是会隐式的转为bool值
		//所以cin对象在作逻辑条件判断的时候,可以隐式的转换为bool进行判断
	{
    
    
		cout << str << endl;
	}

	A aa1 = 1;// 内置类型隐式类型转换成自定义类型

	int a = aa1;// 自定义类型隐式类型转换成内置类型

	cout << a << endl;

	return 0;
}

4.
下面是用经典的日期类来演示自定义类型转换为内置类型的场景,可以实现多种重载,下面代码中实现了operator void */int/bool等三种支持日期类对象转换为对应内置类型的函数。
支持这样的函数过后,C++便可以让内置类型和自定义类型的对象都支持流插入和流提取,并且还支持内置类型隐式类型转换到自定义类型(通过构造函数实现),自定义类型隐式类型转换到内置类型(通过operator 内置类型实现)。

class Date
{
    
    
	friend ostream& operator << (ostream& out, const Date& d);
	friend istream& operator >> (istream& in, Date& d);
public:
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{
    
    }
	//operator bool()//给日期类重载一个operator bool,这样日期类对象也可以隐式类型转换为bool
	//{
    
    
	//	// 这里是随意写的,假设输入_year为0,则结束
	//	if (_year == 0)
	//		return false;
	//	else
	//		return true;
	//}
	//operator void* ()
	//{
    
    
	//	if (_year == 0)
	//		return nullptr;
	//	else
	//		return (void*)1;
	//}
	operator int()
	{
    
    
		if (_year == 0)
			return 0;
		else
			return 1;
	}
private:
	int _year;
	int _month;
	int _day;
};
istream& operator >> (istream& in, Date& d)
{
    
    
	in >> d._year >> d._month >> d._day;
	return in;
}
ostream& operator << (ostream& out, const Date& d)
{
    
    
	out << d._year << " " << d._month << " " << d._day;
	return out;
}
// C++ IO流,使用面向对象+运算符重载的方式
// 能更好的兼容自定义类型,流插入和流提取
int main()
{
    
    
	// cout自动识别类型的本质--函数重载
	// 内置类型可以直接使用--因为库里面ostream类型已经实现了对应类型的<<运算符重载
	int i = 1;
	double j = 2.2;
	cout << i << endl;
	cout << j << endl;


	// 自定义类型则需要我们自己重载<< 和 >>
	Date d(2022, 4, 10);
	cout << d << endl;
	while (d)//直接让自定义类型作为while的判断条件,年为0返回false,不为0就一直输入
	{
    
    
		cin >> d;
		cout << d;
	}

	return 0;
}

2.C++文件IO流

2.1 二进制读写(string作为二进制读写要谨慎,否则把你坑的死死的!)

1.
C++提供了文件IO的类,分别是ifstream和ofstream,提供了一套面向对象的写入和读取文件的接口,C语言的面向过程就是需要先打开文件,然后对文件进行读写操作,而C++只要创建好对应的istream/ostream对象,则对应文件就会被打开,当对象析构的时候,则对应文件就会被关闭,这也是面向对象和面向过程的不同。

在这里插入图片描述

2.
二进制读写的接口使用我简单说一下,构造对象的接口需要文件名和open mode的两个参数,我们用的文件名_filename是string类型,而构造对象的接口是const char *类型,由于string类内部提供了c_str接口,所以string类型是可以隐式类型转换为const char *的。而打开文件的openmode早在ios_base类实现了,所以其余所有的派生类都可以直接用openmode,默认的ifstream和ofstream的openmode是in和out,并且是文本读写。
调用ifstream和ofstream对象的类成员函数read和write时,read是将二进制文件的内容读到char *的缓冲区当中,write是将const char *缓冲区中的二进制内容写到文件里面。读取之后可能对缓冲区内容做出修改,所以是缓冲区是非const修饰的,写入过程中,缓冲区的内容不应发生改动,所以缓冲区是const修饰的。

在这里插入图片描述
在这里插入图片描述
3.
下面是二进制将结构体ServerInfo内容写到文件中的结果,当结构体ServerInfo成员变量为char[32]数组时,二进制写入和读取都是没有问题的,而当结构体ServerInfo的char[32]数组改为string的时候,二进制写入并读取,而且读到的内容也是正确的,但程序却异常退出了,这是为什么呢?
要想知道原因,需要先知道什么是二进制写入,二进制写入你可以简单理解为将数据的二进制表示形式原模原样的写入到文件中,例如某个指针的二进制表示形式为0x0032447b3a(我自己编的),那在二进制写入时,就会将数据的二进制表示形式原封不动的写到文件中,所以二进制文件最终保存的是原始的二进制数据。而文本写入则是将所有类型先转换为字符类型,将转换后的字符写入到文本文件当中,所以文本文件最终保存的是字符数据。

在这里插入图片描述

当换了长一点的字符串后,二进制写入的工作确实完成了,但二进制读取的时候这回却什么都读不到(读取和写入的过程是这个进程分开执行的,用注释的方式将二进制写入和读取过程分开),并且程序依旧是异常退出了。
在这里插入图片描述
4.
出现上面的现象主要和vs下string的结构有关系,vs下的string在存储字符字节数小于等于15时,会将内容存储到内部的一个buf数组里面,这个buf数组的生命周期随string对象的生命周期结束而结束,当存储字符字节数大于15时,string内部有一个ptr指针,此时会在堆上动态开辟一块内存用于存放大于15字节的内容,而这个ptr指针存储的内容就是这块堆内存空间的地址。
而当string在作为二进制读写的时候,会将ptr这个指针的二进制表示写入到文件,而ptr指向的堆空间的内容并不会写入到文件中,也就是原封不动的将结构体写入到二进制文件中,当string存储字符串长度较短时,其实就是将string的buf数组整体写入到文件里面,那么读取的时候自然也会将文件中的内容读回到rinfo结构体中string的buf数组里面,所以这个写入和读取的过程是没有问题的,但还有一个容易忽略的因素就是ptr,字符串内容较短时,buf存储有效内容,而ptr则会分配一个随机的野指针,此时就出大问题了,winfo结构体和rinfo结构体中各自的string对象里面的ptr指针都是相同的野指针,而两个string对象在析构时,ptr指针相同并且都是野指针,所以就会出现析构野指针的情况,这就会导致程序异常退出。
而当存储内容字节数较大时,就会用ptr分配堆空间来存储,但如果分开两次,也就是注释读取让进程单执行写入,然后再注释写入让进程单执行读取,这样就是不同的进程来进行二进制读取和写入,此时也会出问题,因为原来的ptr指针确实指向有效的堆空间,并且能够通过ptr虚拟地址访问到这个堆空间,但是当换了进程之后,原来的虚拟地址对于当前进程的地址空间来说是无效的,通过原来进程的虚拟地址让当前进程继续访问虚拟地址指向的空间的话,那就是野指针访问,程序必然会出错,所以这样也会出问题。
那如果是一个进程执行写入和读取呢?并且string存储内容是内部ptr开辟堆空间来进行存储的,这是否会出现问题呢?这回可以读取内容成功,因为虚拟地址还是有效的,当前进程的地址空间没有发生改变,但是在对象析构时,还是会出问题,原因很简单,还是因为winfo和rinfo结构体内部string的ptr指针相同,此时这两个指针虽然不是随机分配的指针,而是指向有效堆空间的指针,但谁让他们指向的堆空间是相同的呢?一块空间被释放两次,必然会出现野指针访问的问题,这就是为什么进程会异常退出的原因。

在这里插入图片描述
析构两次string对象,堆空间释放两次,出现野指针访问的问题
在这里插入图片描述

在这里插入图片描述

5.
在上面分析了一大堆情况过后,就知道为什么用string来进行二进制读写很坑了吧,最主要还是因为指针的原因,一旦指针作为二进制写入和读取,就会出现写入缓冲区winfo和读取缓冲区rinfo的指针内容相同的情况,那么此时在两个对象析构的时候就一定会出现野指针访问的情况,所以用string来作为二进制读取和写入要谨慎,防止野指针问题的出现。但光防止还是不够,推荐的做法就是不要用string对象来进行二进制写入和读取,而是直接使用char数组来进行二进制读取和写入,这一定不会出现问题。
因为每个ServerInfo结构体在构造的时候,都会分配各自的char数组,所以各自的char数组占用的 内存空间都是不同的,在进行二进制读取和写入的时候,会将char中的所有内容的二进制表示形式写到内存里面,读取的时候也会这么做,但不同结构体的char数组内存位置不同,所以在析构的时候,大家都各自析构各自的,并不会出现野指针问题,这也是char数组作为二进制读写的优势所在。所以以后在进行二进制读写的时候,用char数组就对了,不要问为什么,因为前人已经踩过坑了。

struct ServerInfo
{
    
    
	char _address[64];//表示结构体信息的时候,没有用string,用string的时候不能用二进制读写。
	string _address;//二进制读写要谨慎的用string,否则会把你坑的死死的
	int _port;

	//Date _date;
};

struct ConfigManager
{
    
    
public:
	ConfigManager(const char* filename)
		:_filename(filename)
	{
    
    }

	void WriteBin(const ServerInfo& info)//二进制写入
	{
    
    
		//创建对象的时候会自动调用open函数,析构对象会自动调用close函数
		ofstream ofs(_filename, std::ofstream::out | std::ios_base::binary);//ios_base就已经定义了mode
		ofs.write((const char*)&info, sizeof(info));
		//ofs.close();
	}
	void ReadBin(ServerInfo& info)//二进制读取
	{
    
    
		//创建对象的时候会自动调用open函数,析构对象会自动调用close函数
		ifstream ifs(_filename, std::ifstream::in | std::ios_base::binary);//ios_base就已经定义了mode
		ifs.read((char*)&info, sizeof(info));
		//ifs.close();
	}

private:
	string _filename; // 配置文件
};
int main()
{
    
    
	ConfigManager cm("test.txt");
	ServerInfo winfo = {
    
     "192.0.0", 80};//测试数据1
	ServerInfo winfo = {
    
     "192.0.0.111111111111111111", 80};//测试数据2
	cm.WriteBin(winfo);

	ServerInfo rinfo;
	cm.ReadBin(rinfo);

	cout << rinfo._address << endl;
	cout << rinfo._port << endl;


	return 0;
}

2.2 文本读写(类设计层次的代码复用:i/ostream类的<<和>>重载)

1.
进行文本读写时用string或是用char数组都是无所谓的,因为不管你是什么类型,在进行文本读写时,都会先将类型转为字符类型,然后将字符写入到文件当中。
比较牛的一点是,i/ofstream的对象都可以使用<<和>>来进行数据向文件插入和数据从文件提取,只不过数据流动的对象换了,以前是针对于显示器和键盘,现在可以是所有文件,包括键盘和显示器文件。
所以上面的二进制读写除了使用read和write接口外,也可以使用<<流插入和>>流提取来进行二进制读写,只不过二进制模式下,<<和>>会直接将内容写到内存里面,不会对字符串做解析,比如说文本读写会以空格和换行符作为间隔,但二进制读写不会这么做的,你给什么,他就直接写什么,不会做任何额外的处理。至于选择调用运算符重载还是调用read和write接口,选择权在于你。

在这里插入图片描述

2.
为什么i/ofstream对象可以直接用流插入和流提取呢?因为类设计层次的代码复用,说白了就是继承带来的效果,基类重载的成员函数派生类都可以直接调用,所以在使用i/ofstream对象进行读写时,除了调用read和write接口外,也可以直接用流插入和流提取。

在这里插入图片描述
如果日期类对象也实现了流插入和流提取,那么i/ofstream对象也就可以直接将日期类对象写到文件和从文件中读取日期类对象,这其实是因为派生类对象赋值给基类对象,是天然的切割赋值过程,所以i/ofstream对象是可以直接调用日期类对象的i/ostream流插入和流提取的。
所以除了标准IO外,对于文件的IO,也是可以使用流插入和流提取的。包括内置类型和自定义类型,都是可以进行流插入和流提取,只要重载了对应的<<和>>函数即可。

在这里插入图片描述

struct ServerInfo
{
    
    
	//文本读写用string或者是用数组都是无所谓的
	char _address[64];
	//string _address;
	int _port;

	Date _date;
};

struct ConfigManager
{
    
    
public:
	ConfigManager(const char* filename)
		:_filename(filename)
	{
    
    }

	void WriteText(const ServerInfo& info)
	{
    
    
		ofstream ofs(_filename);//用ofstream自带的第二个缺省参数mode::out
		ofs << info._address << endl << info._port << endl;//以空格或换行符作为分隔依据
		//遇到整型就会将其转成字符串写到文件里,比如以前我们cout输出信息到显示器文件里面时,显示器文件放的都是字符串
		ofs << info._date << endl;//重载了日期类对象的流插入和流提取
	}
	void ReadText(ServerInfo& info)
	{
    
    
		ifstream ifs(_filename);
		ifs >> info._address >> info._port >> info._date;

	}

private:
	string _filename; // 配置文件
};

int main()
{
    
    
	ConfigManager cm("test.txt");

	ServerInfo winfo = {
    
     "192.0.0.11111111111111111111111111", 80, {
    
    2023, 5, 22} };
	cm.WriteText(winfo);

	ServerInfo rinfo;
	cm.ReadText(rinfo);
	
	cout << rinfo._address << endl;
	cout << rinfo._port << endl;
	cout << rinfo._date << endl;
	

	return 0;
}

3.C++字符串流

1.
C++标准库还实现了istringstream和ostringstream类,用于进行多种类型序列化为字符串类型,和将字符串类型反序列化为其他多种类型。
i/ostringstream对象内部维护了一个string对象,用于存储序列化之后的结果,和从中提取结果进行反序列化。可以调用i/ostringstream对象内部的str()接口来返回其内部维护的string对象。
stringstream内部使用string类对象代替字符数组,可以避免缓冲区溢出的危险,而且其会对参
数类型进行推演,不需要格式化控制,也不会出现格式化失败的风险,因此使用更方便,更
安全。

在这里插入图片描述

//stringstream既有istringstream的功能,也有ostringstream的功能

int main()
{
    
    
	//把Date转成一个字符串
	int i = 999;
	double dou1 = 13.14;
	Date d1 = {
    
     2023, 5, 22 };

	ostringstream oss;
	oss << i << " " << dou1 << " " << d1;
	string str = oss.str();
	cout << str << endl;

	int j;
	double dou2;
	Date d2;
	istringstream iss(str);
	iss >> j >> dou2 >> d2;
	cout << str;

	return 0;
}

2.
在进行多次转换时,需要调用clear()函数将状态标志位设置为允许进行新一轮的转换,但clear并不会清空stringstream内部维护的string对象内容,所以如果仅调用clear()接口重置标志位的话,则新一轮的序列化内容会重复累积到string尾部。
所以如果想要进行全新一轮的转换,则可以先调用str()接口将string底层内容设置为空(只有’\0’),然后再调用clear重置状态标志位,当然顺序也可以反过来。

int main()
{
    
    
    int a = 12345678;
    string sa;
    // 将一个整形变量转化为字符串,存储到string类对象中
    stringstream s;
    s << a;
    s >> sa;

    cout << sa << endl;
    // clear()
    // 注意多次转换时,必须使用clear将上次转换状态清空掉
    // stringstreams在转换结尾时(即最后一个转换后),会将其内部状态设置为badbit
    // 因此下一次转换是必须调用clear()将状态重置为goodbit才可以转换
    // 但是clear()不会将stringstreams底层字符串清空掉

    // s.str("");
 // 将stringstream底层管理string对象设置成"", 
 // 否则多次转换时,会将结果全部累积在底层string对象中

    s.str("");
    s.clear();   // 清空s, 不清空下一轮的转换是无效的,string存储的还是上一轮的结果

    double d = 12.34;
    s << d;
    s >> sa;
    string sValue;
    sValue = s.str();   // str()方法:返回stringsteam中管理的string类型
    cout << sValue << endl;
    return 0;
}

3.
下面这段代码就是直接使用stringstream来进行序列化和反序列化,使用的方式也非常简单,直接复用i/ostream类的operator <<和operator >>重载函数即可,所以你可以看到C++的这一套继承体系带来很大的便捷,无论是标准IO,还是文件IO,还是字符串IO,都可以使用统一的一套标准来实现,即通过operator <<和operator >>重载函数来完成IO的过程。
不过使用stringstream来进行序列化和反序列化格式控制过于单一,所以大部分公司都不喜欢用stringstream,而是用一些第三方库,例如json,xml等来进行序列化和反序列化。

struct ChatInfo
{
    
    
	string _name; // 名字
	int _id;      // id
	Date _date;   // 时间
	string _msg;  // 聊天信息
};
//下面是简单的序列化和反序列化
int main()
{
    
    
	//  stringstream作序列化和反序列化只能作简单的分割,例如用空格或\n来作为分隔符,难一点的分隔他做不到
	//尤其面对复杂数据的时候。
	ChatInfo winfo = {
    
     "张三", 123456, {
    
     2023, 5, 22 }, "晚上一起看电影吧"};

	//序列化
	stringstream oss;
	oss << winfo._name << ": " << winfo._id << " " << winfo._date << " " << winfo._msg << " ";
	cout << oss.str() << endl << endl;
	cout << "网络发送" << endl << endl;
	// 我们通过网络这个字符串发送给对象,实际开发中,信息相对更复杂,
	// 一般会选用Json、xml等方式进行更好的支持
	// 字符串解析成结构信息

	//反序列化
	ChatInfo rInfo;
	stringstream iss(oss.str());
	iss >> rInfo._name >> rInfo._id >> rInfo._date >> rInfo._msg;
	cout << "-------------------------------------------------------" << endl;
	cout << "姓名:" << rInfo._name << "(" << rInfo._id << ") ";
	cout << rInfo._date << endl;
	cout << rInfo._name << ":>" << rInfo._msg << endl;
	cout << "-------------------------------------------------------" << endl;


	return 0;
}

猜你喜欢

转载自blog.csdn.net/erridjsis/article/details/130740974
今日推荐