C++复习

发布于:2025-05-08 ⋅ 阅读:(16) ⋅ 点赞:(0)

线程库(类)

在C++11之前,涉及到多线程问题,都是和平台相关的,比如Windows和Linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行了支持,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。

thread的带参的构造函数的定义如下:

template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);

线程类的使用: 

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t3 = thread(func, 10);

	t3.join();
	return 0;
}
  • 如果创建线程对象时提供了线程函数,那么就会启动一个线程来执行这个线程函数,该线程与主线程一起运行。
  • thread类是防拷贝的,不允许拷贝构造和拷贝赋值,但是可以移动构造和移动赋值,可以将一个线程对象关联线程的状态转移给其他线程对象,并且转移期间不影响线程的执行。
  • 线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,就算线程函数的参数为引用类型,在线程函数中修改后也不会影响到外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参

如果要通过线程函数的形参改变外部的实参,可以参考以下三种方式:

方式一:借助std::ref函数

当线程函数的参数类型为引用类型时,如果要想线程函数形参引用的是外部传入的实参,而不是线程栈空间中的拷贝,那么在传入实参时需要借助ref函数保持对实参的引用。比如:

void add(int& num)
{
	num++;
}
int main()
{
	int num = 0;
	thread t(add, ref(num));
	t.join();

	cout << num << endl; //1
	return 0;
}

线程回收:join与,ref值引用

启动一个线程后,当这个线程退出时,需要对该线程所使用的资源进行回收,否则可能会导致内存泄露等问题。

主线程创建新线程后,可以调用join函数等待新线程终止,当新线程终止时join函数就会自动清理线程相关的资源。join函数清理线程的相关资源后,thread对象与已销毁的线程就没有关系了,因此一个线程对象一般只会使用一次join,否则程序会崩溃。比如:

void func(int n)
{
	for (int i = 0; i <= n; i++)
	{
		cout << i << endl;
	}
}
int main()
{
	thread t(func, 20);
    //  t.detach(); 主线程直接分离线程,不再阻塞
	t.join();
	t.join(); //程序崩溃
	return 0;
}

因此采用join方式结束线程时,join的调用位置非常关键,为了避免上述问题,可以采用RAII的方式对线程对象进行封装,也就是利用对象的生命周期来控制线程资源的释放。比如:

class myThread
{
public:
	myThread(thread& t)
		:_t(t)
	{}
	~myThread()
	{
		if (_t.joinable())
			_t.join();
	}
	//防拷贝
	myThread(myThread const&) = delete;
	myThread& operator=(const myThread&) = delete;
private:
	thread& _t;
};

互斥量库(mutex

mutex锁是C++11提供的最基本的互斥量,mutex对象之间不能进行拷贝,也不能进行移动。

void func(int n, mutex& mtx)
{
	mtx.lock(); //for循环体外加锁
	for (int i = 1; i <= n; i++)
	{
		//mtx.lock(); //for循环体内加锁
		cout << i << endl;
		//mtx.unlock();
	}
	mtx.unlock();
}
int main()
{
	mutex mtx;
	thread t1(func, 100, ref(mtx));
	thread t2(func, 100, ref(mtx));

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

锁资源和线程资源都需要我们进行管理。都可以使用RAII风格管理。lock_guard和unique_lock

mutex mtx;
void func()
{
	//...
	//匿名局部域
	{
		lock_guard<mutex> lg(mtx); //调用构造函数加锁
		FILE* fout = fopen("data.txt", "r");
		if (fout == nullptr)
		{
			//...
			return; //调用析构函数解锁
		}
	} //调用析构函数解锁
	//...
}
int main()
{
	func();
	return 0;
}

如下场景就适合使用unique_lock:

  • 要用互斥锁保护函数1的大部分代码,但是中间有一小块代码调用了函数2,而调用函数2时不需要用函数1中的互斥锁进行保护,函数2内部的代码由其他互斥锁进行保护。
  • 因此在调用函数2之前需要对当前互斥锁进行解锁,当函数2调用返回后再进行加锁,这样当调用函数2时其他线程调用函数1就能够获取到这个锁。

原子类库

原子类解决线程安全问题

C++11中引入了原子操作类型,使得线程间数据的同步变得非常高效。

  • 为了防止意外,标准库已经将atomic模板类中的拷贝构造、移动构造、operator=默认删除掉了。
  • 原子类型不仅仅支持原子的++操作,还支持原子的--、加一个值、减一个值、与、或、异或操作。

如下:两个线程对同一个共享变量进行++操作

void func(atomic_int& n, int times)
{
	for (int i = 0; i < times; i++)
	{
		n++;
	}
}
int main()
{
	atomic_int n = { 0 };
	int times = 100000; //每个线程对n++的次数
	thread t1(func, ref(n), times);
	thread t2(func, ref(n), times);

	t1.join();
	t2.join(); //先join等两个子线程执行完毕
	cout << n << endl; //打印n的值
	return 0;
}

实现两个线程交替打印1-100

尝试用两个线程交替打印1-100的数字,要求一个线程打印奇数,另一个线程打印偶数,并且打印数字从小到大依次递增。

int main()
{
	int n = 100;
	mutex mtx;
	condition_variable cv;
	bool flag = true;
	//奇数
	thread t1([&]{
		int i = 1;
		while (i <= 100)
		{
			unique_lock<mutex> ul(mtx);
			cv.wait(ul, [&flag]()->bool{return flag; }); //等待条件变量满足
			cout << this_thread::get_id() << ":" << i << endl;
			i += 2;
			flag = false;
			cv.notify_one(); //唤醒条件变量下等待的一个线程
		}
	});
	//偶数
	thread t2([&]{
		int j = 2;
		while (j <= 100)
		{
			unique_lock<mutex> ul(mtx);
			cv.wait(ul, [&flag]()->bool{return !flag; }); //等待条件变量满足
			cout << this_thread::get_id() << ":" << j << endl;
			j += 2;
			flag = true;
			cv.notify_one(); //唤醒条件变量下等待的一个线程
		}
	});

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

C++异常

异常是面向对象语言常用的一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数直接或间接的调用者处理这个错误。

  • throw:当程序出现问题时,可以通过throw关键字抛出一个异常。
  • try:try块中放置的是可能抛出异常的代码,该代码块在执行时将进行异常错误检测,try块后面通常跟着一个或多个catch块。
  • catch:如果try块中发生错误,则可以在catch块中定义对应要执行的代码块。

异常的抛出和捕获的匹配原则:

  • 异常是通过throw 对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码,如果抛出的异常对象没有捕获,或是没有匹配类型的捕获,那么程序会终止报错。
  • 被选中的处理代码(catch块)是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
  • 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(类似于函数的传值返回)
  • catch(...)可以捕获任意类型的异常,但捕获后无法知道异常错误是什么。
  • 实际异常的抛出和捕获的匹配原则有个例外,捕获和抛出的异常类型并不一定要完全匹配,可以抛出派生类对象,使用基类进行捕获,这个在实际中非常有用。

最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。比如:

class Exception
{
public:
	Exception(int errid, const char* errmsg)
		:_errid(errid)
		, _errmsg(errmsg)
	{}
	int GetErrid() const
	{
		return _errid;
	}
	virtual string what() const
	{
		return _errmsg;
	}
protected:
	int _errid;     //错误编号
	string _errmsg; //错误描述
	//...
};

其他模块如果要对这个异常类进行扩展,必须继承这个基础的异常类,可以在继承后的异常类中按需添加某些成员变量,或是对继承下来的虚函数what进行重写,使其能告知程序员更多的异常信息。异常类的成员变量不能设置为私有,因为私有成员在子类中是不可见的,基类Exception中的what成员函数最好定义为虚函数,方便子类对其进行重写,从而达到多态的效果。

智能指针

智能指针的原理

实现智能指针时需要考虑以下三个方面的问题:

  1. 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
  2. *->运算符进行重载,使得该对象具有像指针一样的行为。
  3. 智能指针对象的拷贝问题。浅拷贝析构两次

auto_ptr管理权转移

auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。

 unique_ptr防拷贝

unique_ptr是C++11中引入的智能指针,unique_ptr通过防拷贝的方式解决智能指针的拷贝问题,也就是简单粗暴的防止对智能指针对象进行拷贝,这样也能保证资源不会被多次释放

shared_ptr 引用计数

类模板,shared_ptr<int> p(new int(0));

namespace xwy
{
	template<class T>
	class shared_ptr
	{
	private:
		//++引用计数
		void AddRef()
		{
			_pmutex->lock();
			(*_pcount)++;
			_pmutex->unlock();
		}
		//--引用计数
		void ReleaseRef()
		{
			_pmutex->lock();
			bool flag = false;
			if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
			{
				if (_ptr != nullptr)
				{
					cout << "delete: " << _ptr << endl;
					delete _ptr;
					_ptr = nullptr;
				}
				delete _pcount;
				_pcount = nullptr;
				flag = true;
			}
			_pmutex->unlock();
			if (flag == true)
			{
				delete _pmutex;
			}
		}
	public:
		//RAII
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _pmutex(new mutex)
		{}
		~shared_ptr()
		{
			ReleaseRef();
		}
		shared_ptr(shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
			, _pmutex(sp._pmutex)
		{
			AddRef();
		}
		shared_ptr& operator=(shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr) //管理同一块空间的对象之间无需进行赋值操作
			{
				ReleaseRef();         //将管理的资源对应的引用计数--
				_ptr = sp._ptr;       //与sp对象一同管理它的资源
				_pcount = sp._pcount; //获取sp对象管理的资源对应的引用计数
				_pmutex = sp._pmutex; //获取sp对象管理的资源对应的互斥锁
				AddRef();             //新增一个对象来管理该资源,引用计数++
			}
			return *this;
		}
		//获取引用计数
		int use_count()
		{
			return *_pcount;
		}
		//可以像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}
		T* operator->()
		{
			return _ptr;
		}
	private:
		T* _ptr;        //管理的资源
		int* _pcount;   //管理的资源对应的引用计数
		mutex* _pmutex; //管理的资源对应的互斥锁
	};
}
std::shared_ptr的定制删除器

定制删除器的用法

第二个参数传入一个 仿函数,再智能指针析构时调用

template<class T>
struct DelArr
{
	void operator()(const T* ptr)
	{
		cout << "delete[]: " << ptr << endl;
		delete[] ptr;
	}
};
int main()
{
	std::shared_ptr<ListNode> sp1(new ListNode[10], DelArr<ListNode>());
	std::shared_ptr<FILE> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});

	return 0;
}

weak_ptr解决循环引用

weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数。

将ListNode中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,此时当node1和node2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源。比如:

struct ListNode
{
	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};
int main()
{
	std::shared_ptr<ListNode> node1(new ListNode);
	std::shared_ptr<ListNode> node2(new ListNode);

	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	node1->_next = node2;
	node2->_prev = node1;
	//...
	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	return 0;
}

C语言中的类型转换

C语言中有两种形式的类型转换,分别是隐式类型转换和显式类型转换:

  • 隐式类型转换:编译器在编译阶段自动进行,能转就转,不能转就编译失败。
  • 显式类型转换:需要用户自己处理,以(指定类型)变量的方式进行类型转换。

需要注意的是,只有相近类型之间才能发生隐式类型转换,比如int和double表示的都是数值,只不过它们表示的范围和精度不同。而指针类型表示的是地址编号,因此整型和指针类型之间不会进行隐式类型转换,如果需要转换则只能进行显式类型转换。比如:

int main()
{
	//隐式类型转换
	int i = 1;
	double d = i;
	cout << i << endl;
	cout << d << endl;

	//显式类型转换
	int* p = &i;
	int address = (int)p;
	cout << p << endl;
	cout << address << endl;
	return 0;
}

int a = xxx_cast<int> (b);

static_cast

static_cast用于相近类型之间的转换,编译器隐式执行的任何类型转换都可用static_cast,但它不能用于两个不相关类型之间转换。比如:

int main()
{
	double d = 12.34;
	int a = static_cast<int>(d);
	cout << a << endl;

	int* p = &a;
	// int address = static_cast<int>(p); //error
	return 0;
}

reinterpret_cast

用于两个不相关类型之间的转换。比如指针转整型

int main()
{
	int a = 10;
	int* p = &a;
	int address = reinterpret_cast<int>(p);
	cout << address << endl;
	return 0;
}

const_cast必须转指针

const_cast用于删除变量的const属性,转换后就可以对const变量的值进行修改。提供一种修改const常量的方法,C语言中可以间接修改const常量,C++类型检查更严格。比如:

int main()
{
	const int a = 2; //加valatile 下面变成
	int* p = const_cast<int*>(&a);
	*p = 3;
	cout << a << endl;  //2
	cout << *p << endl; //3
	return 0;
}

dynamic_cast转为子类指针

向上转型: 子类的指针(或引用)→ 父类的指针(或引用)。
向下转型: 父类的指针(或引用)→ 子类的指针(或引用)。

其中,向上转型就是所说的切割/切片,是语法天然支持的,不需要进行转换,而向下转型是语法不支持的,需要进行强制类型转换。

使用dynamic_cast进行向下转型则是安全的,如果父类的指针(或引用)指向的是子类对象那么dynamic_cast会转换成功,但如果父类的指针(或引用)指向的是父类对象那么dynamic_cast会转换失败并返回一个空指针。

class A
{
public:
	virtual void f()
	{}
};
class B : public A
{};
void func(A* pa)
{
	B* pb1 = (B*)pa;               //不安全
	B* pb2 = dynamic_cast<B*>(pa); //安全

	cout << "pb1: " << pb1 << endl;
	cout << "pb2: " << pb2 << endl;
}
int main()
{
	A a;
	B b;
	func(&a);
	func(&b);
	return 0;
}

说明一下: dynamic_cast只能用于含有虚函数的类,因为运行时类型检查需要运行时的类型信息,而这个信息是存储在虚函数表中的,只有定义了虚函数的类才有虚函数表。

explicit

explicit用来修饰单参数构造函数,从而禁止单参数构造函数的隐式转换。

RTTI(Run-Time Type Identification)就是运行时类型识别。

C++通过以下几种方式来支持RTTI:

  1. typeid:在运行时识别出一个对象的类型。
  2. dynamic_cast:在运行时识别出一个父类的指针(或引用)指向的是父类对象还是子类对象。
  3. decltype:在运行时推演出一个表达式或函数返回值的类型。

网站公告

今日签到

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