C++11的一些特性

目录

1.C++11

2.统一的列表初始化

2.1 {} 初始化

2.2std::initializer_list

2.2.1std::initializer_list的介绍

2.2.2std::initializer_list的使用场景

3.声明

3.1auto

3.2decltype

3.3nullptr

4.范围for循环

5.智能指针

5.1为什么需要智能指针?

5.2内存泄漏

5.2.1什么是内存泄漏,内存泄漏的危害

5.2.2内存泄漏分类

5.2.3如何避免内存泄漏

5.3智能指针的使用及原理

5.3.1 RAII

5.3.2智能指针的原理

5.3.3 std::auto_ptr

5.3.4 std::unique_ptr

5.3.5 std::shared_ptr

5.4 C++11和boost中智能指针的关系

5.5make_shared函数(

6.STL中的一些变化

7.右值引用和移动语义

7.1左值引用和右值引用

7.2左值引用和右值引用的比较

7.3右值引用使用场景和意义

7.4右值引用 引用左值及其一些更深入的使用场景分析

7.5完美转发forward函数

forward函数

8.新的类功能

默认成员函数

强制生成默认函数的关键字default

禁止生成默认函数的关键字delete

继承和多态中的final与override关键字

9.可变参数模版

10.lambda表达式

10.1C++98中的一个例子

10.2 lambda表达式语法

10.3 lambda表达式

10.4函数对象与lambda表达式

11.包装器

function包装器

bind函数

12 线程库

12.1thread类的简单介绍

12.2线程函数参数

12.3原子操作库

12.4mutex的种类

12.5atomic类型(原子类型)

12.5 lock_guard与unique_lock

12.5.1 lock_guard

12.5.2 unique_lock

12.6支持两个线程交替打印,一个打印奇数,一个打印偶数

1.C++11

在2003年C++标准委员会曾经提交了一份技术勘误表(简称TC1),使得C++03这个名字已经取代了C++98称为C++11之前的最新C++标准名称。不过由于C++03(TC1)主要是对C++98标准中的漏洞进行修复,语言的核心部分则没有改动,因此人们习惯性的把两个标准合并称为C++98/03标准。从C++0x到C++11,C++标准10年磨一剑,第二个真正意义上的标准珊珊来迟。相比于

C++98/03,C++11则带来了数量可观的变化,其中包含了约140个新特性,以及对C++03标准中约600个缺陷的修正,这使得C++11更像是从C++98/03中孕育出的一种新语言。相比较而言,C++11能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个重点去学习。C++11增加的语法特性非常篇幅非常多,所以这里只介绍比较实用的语法。

C++11 - cppreference.com

2.统一的列表初始化

2.1 {} 初始化

在C++98中,标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定。比如:

struct Point
{
	int _x;
	int _y;
};
int main()
{
	int array1[] = { 1, 2, 3, 4, 5 };
	int array2[5] = { 0 };
	Point p = { 1, 2 };
	return 0;
}

C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自
定义的类型,使用初始化列表时,可添加等号(=),也可不添加

struct Point
{
	int _x;
	int _y;
};
int main()
{
	int x1 = 1;
	int x2{ 2 };
	int array1[]{ 1, 2, 3, 4, 5 };
	int array2[5]{ 0 };
	Point p{ 1, 2 };
	// C++11中列表初始化也可以适用于new表达式中
	int* pa = new int[4] { 0 };
	return 0;
}

创建对象时也可以使用列表初始化方式调用构造函数初始化

class Date
{
public:
	Date(int year, int month, int day)
		:_year(year)
		, _month(month)
		, _day(day)
	{
		cout << "Date(int year, int month, int day)" << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2022, 1, 1); // old style
	// C++11支持的列表初始化,这里会调用构造函数初始化

	//这两个本质上是调用构造+拷贝构造优化成只调用构造,可以在构造函数前加上explicit他们就会被区分开来
	Date d2 = { 2022, 1, 2 };
	Date d3{ 2022, 1, 3 };
	return 0;
}

2.2std::initializer_list

2.2.1std::initializer_list的介绍

https://cplusplus.com/reference/initializer_list/initializer_list/

std::initializer_list是一个标准库类型,用于表示一个初始化列表。它是在C++11中引入的,并被用于简化创建和传递列表的过程。

std::initializer_list类似于数组,可以存储一组同类型的元素。它作为函数参数或构造函数的参数时,可以方便地将多个值作为一个参数传递。例如,可以使用std::initializer_list来初始化一个容器,或者在函数调用时传递多个参数。

std::initializer_list提供了以下功能和特性:

  1. 支持迭代器,可以通过begin()和end()方法访问列表中的元素。
  2. 提供size()方法,用于返回列表中的元素数量。
  3. 列表中的元素是只读的,不支持修改。
  4. 列表中的元素按照声明的顺序进行存储,可以通过索引访问。

总而言之,std::initializer_list提供了一种简洁、方便的方式来处理初始化列表,并用于传递多个参数。它在C++11中引入,是现代C++中常用的类型之一。

2.2.2std::initializer_list的使用场景

std::initializer_list一般是作为构造函数的参数,C++11对STL中的不少容器就增加了std::initializer_list作为参数的构造函数,这样初始化容器对象就更方便了。也可以作为operator=的参数,这样就可以用大括号赋值。

std::initializer_list的作用是用于简化创建和传递列表的过程。它的主要使用场景有:

  1. 初始化容器:可以使用std::initializer_list来初始化标准库容器,如std::vector、std::list、std::set等。这样可以一次性地给容器添加多个元素。

示例:

std::vector<int> numbers1 = {1, 2, 3, 4, 5};
std::vector<int> numbers2 = {{1, 2, 3},{4, 5, 6}};
std::set<std::string> names = {"Alice", "Bob", "Charlie"};
std::map<std::string, std::string> mymap = {{"sort", "排序"}{"right", "右边"}{"string", "字符串"}};

图中是vector使用initializer_list来初始化的构造函数,所以numbers的本质就是在调用这个构造。

  1. 函数参数:可以将std::initializer_list作为函数参数,用于接收多个参数值。这样可以方便地传递一个列表给函数,而不需要手动创建和传递数组。

示例:

void printNumbers(std::initializer_list<int> nums) {
    for (int num : nums) {
        std::cout << num << " ";
    }
}

printNumbers({1, 2, 3, 4, 5}); // 输出:1 2 3 4 5
  1. 构造函数参数:可以在类的构造函数中使用std::initializer_list作为参数,用于初始化成员变量。这样可以在创建对象时传递一个初始化列表,方便地初始化成员变量。

示例:

class MyClass {
public:
    MyClass(std::initializer_list<int> nums) {
        for (int num : nums) {
            numbers.push_back(num);
        }
    }

    void printNumbers() {
        for (int num : numbers) {
            std::cout << num << " ";
        }
    }

private:
    std::vector<int> numbers;
};

MyClass obj = {1, 2, 3, 4, 5};
obj.printNumbers(); // 输出:1 2 3 4 5

总之,std::initializer_list提供了一种简洁、方便的方式来处理初始化列表,并用于容器初始化、函数参数传递和构造函数初始化等场景。它的使用可以使代码更加简洁、可读性更好。

3.声明

c++11提供了多种简化声明的方式,尤其是在使用模板时。

3.1auto

在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。C++11中废弃auto原来的用法,将其用于实现自动类型推到。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。

int main()
{
	int i = 10;
	auto p = &i;
	auto pf = strcpy;
	cout << typeid(p).name() << endl;
	cout << typeid(pf).name() << endl;
	map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
	//map<string, string>::iterator it = dict.begin();
	auto it = dict.begin();

	return 0;
}

3.2decltype

关键字decltype将变量的类型声明为表达式指定的类型,decltype是一个C++11引入的关键字,用于获取表达式或变量的类型。它可以在编译时获取类型信息,用这个类型可以实例化模版参数或者定义对象,而不需要实际执行表达式或初始化变量。

// decltype的一些使用使用场景
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
	decltype(t1 * t2) ret;
	cout << typeid(ret).name() << endl;
}
int main()
{
	const int x = 1;
	double y = 2.2;

	decltype(x * y) ret; // ret的类型是double
	decltype(&x) p; // p的类型是int*
	cout << typeid(ret).name() << endl;
	cout << typeid(p).name() << endl;
	F(1, 'a');

	//vector存储的类型根 x*y 表达式返回值类型一致
	vector<decltype(x* y)> v;

	return 0;
}

3.3nullptr

由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能表示指针常量,又能表示整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。

#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif

4.范围for循环

范围for循环(range-based for loop)是C++11引入的一种循环结构,用于遍历容器、数组或其他支持迭代器的对象的元素。它提供了一种简洁、易读的方式来遍历容器中的元素,而无需使用传统的索引迭代方式,在之前的文章里介绍过,它的底层就是迭代器,比如自己实现的容器类型,只要实现了迭代器,就会自动支持范围for循环。

5.智能指针

5.1为什么需要智能指针?

下面我们先分析一下,下面这段程序有没有什么内存方面的问题?提示一下:注意分析函数中的问题。

int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("除0错误");
    return a / b;
}
void Func()
{
    // 1、如果p1这里new 抛异常会如何?
    // 2、如果p2这里new 抛异常会如何?
    // 3、如果div调用这里又会抛异常会如何?
    int* p1 = new int;
    int* p2 = new int;
    cout << div() << endl;
    delete p1;
    delete p2;
}
int main()
{
    try
    {
        Func();
    }
	catch (exception& e)
    {
        cout << e.what() << endl;
    }
    return 0;
}

在给定的代码中,有一些潜在的内存管理问题,主要涉及到异常处理和动态内存分配。让我们逐个分析:

  1. Func()函数中的new int抛出异常: 如果在Func()函数中的第一个new int抛出异常,不会导致内存泄漏,如果抛出异常则说明内存没有开辟成功,分配内存也就失败,抛出的异常会直接被捕获,所以没有任何问题。
  2. Func()函数中的第二个new int抛出异常:如果第二个new int抛出异常,p1指针分配的内存资源将不会被释放,所以导致内存泄漏。
  3. div()函数中抛出异常: 如果在div()函数中抛出异常,程序将会终止div()函数的执行,但是在Func()函数中分配的内存不会被释放,这同样会导致内存泄漏。在异常抛出前,p1和p2指针都已经分配了内存,但是由于没有释放,这两块内存将永远无法被释放。

5.2内存泄漏

5.2.1什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏是指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

void MemoryLeaks()
{
	// 1.内存申请了忘记释放
	int* p1 = (int*)malloc(sizeof(int));
	int* p2 = new int;
	// 2.异常安全问题
	int* p3 = new int[10];
	Func(); // 如果这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
	delete[] p3;
}
5.2.2内存泄漏分类

内存泄漏可以分为以下几类:

  1. 堆内存泄漏(Heap Memory Leak):这是指在动态分配内存时,没有释放已分配的堆内存。这可能是因为忘记使用delete或free来释放内存,或者是在异常或逻辑错误的情况下未能正确地释放内存。
  2. 栈内存泄漏(Stack Memory Leak):这是指在函数调用过程中,没有释放函数内部创建的局部变量或未正确管理栈帧的内存。这种泄漏在函数返回后会自动释放内存,但如果在一个长时间运行的循环或递归中发生,会导致内存消耗过多。
  3. 指针内存泄漏(Pointer Memory Leak):这是指在使用指针时,没有正确释放指针指向的内存空间。这可能是因为指针被重新分配给其他对象,导致之前的内存无法访问和释放,或者是指针在程序中丢失,无法再被访问和释放。
  4. 文件内存泄漏(File Memory Leak):这是指在程序打开文件或流时,没有正确关闭文件或流,导致文件或流句柄一直被占用,从而造成资源泄漏。
  5. 资源内存泄漏(Resource Memory Leak):这是指在程序使用其他系统资源(如网络连接、数据库连接、线程等)时,没有正确释放或关闭这些资源,导致资源一直被占用,从而造成资源泄漏。

需要特别注意的是,内存泄漏会导致程序的内存消耗逐渐增加,最终可能导致系统崩溃或运行缓慢。因此,开发人员应该在编写代码时,养成良好的内存管理习惯,确保动态分配的内存能够正确释放。

系统资源泄漏是指在程序中使用系统资源(如文件、网络连接、数据库连接、线程等)时,没有正确释放或关闭这些资源,导致资源一直被占用,从而造成系统资源的泄漏。系统资源泄漏可能会导致以下问题:

  1. 文件资源泄漏:在程序打开文件或流后,没有正确关闭文件或流,导致文件或流句柄一直被占用。这可能会导致系统打开文件数量的增加,最终导致系统无法打开更多的文件。
  2. 网络资源泄漏:在网络通信过程中,没有正确关闭网络连接或释放所分配的网络资源,导致系统中的网络资源被占用。这可能会导致系统无法建立更多的网络连接,或者导致网络通信的性能下降。
  3. 数据库资源泄漏:在程序连接数据库后,没有正确关闭数据库连接或释放相关的数据库资源,导致系统中的数据库资源被占用。这可能会导致系统无法建立新的数据库连接,或者导致数据库性能下降。
  4. 线程资源泄漏:在程序中创建线程后,没有正确释放或终止线程,导致系统中的线程资源一直被占用。这可能会导致系统无法创建更多的线程,或者导致系统的性能下降。

系统资源泄漏会导致系统资源的浪费和不足,可能导致系统的性能下降、稳定性降低,甚至可能导致系统崩溃。因此,开发人员在编写程序时应该注意正确释放和关闭系统资源,以避免系统资源泄漏的问题。

5.2.3如何避免内存泄漏

1.工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。

2.采用RAll思想或者智能指针来管理资源。

3.有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。

4.出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

总结一下:

内存泄漏非常常见,解决方案分为两种: 1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。

5.3智能指针的使用及原理

5.3.1 RAII

RAII(Resource Acquisition Is Initialization)是C++中的一个惯用法,用于通过对象的生命周期管理资源。RAII的核心思想是利用对象的构造函数来获取资源,在析构函数中释放资源。这样,当对象超出作用域时,它的析构函数会被自动调用,从而保证了资源的正确释放。

RAII的常见实现包括使用类来自动管理动态分配的内存、文件句柄、线程句柄和其他临界资源。在C++中,智能指针(如std::unique_ptr和std::shared_ptr)就是RAII的典型例子,它们在对象生命周期结束时自动释放所指向的内存。通过使用RAII,可以大大减少资源泄露的风险,并简化资源管理逻辑。

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

不需要显式地释放资源。

采用这种方式,对象所需的资源在其生命期内始终保持有效。


// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr {
public:
	SmartPtr(T* ptr = nullptr)//创建对象时自动调用构造函数获取资源
		: _ptr(ptr)
	{}
	~SmartPtr()//对象出作用域自动调用析构函数释放资源
	{
		if (_ptr)
			delete _ptr;
	}
private:
	T* _ptr;
};
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(new int);
	cout << div() << endl;
}

int main()
{
	try {
		Func();
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

在给sp1进行new int的操作时,会调用SmartPtr类的构造函数,并将new int返回的指针作为参数传递给构造函数的形参。构造函数会将这个指针赋值给成员变量_ptr。

所以,对于代码SmartPtr<int> sp1(new int);,构造函数会接收new int返回的指针作为参数,将其赋值给私有成员变量_ptr,完成对智能指针sp1的初始化。

5.3.2智能指针的原理

上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指对象空间中的内容,因此: AutoPtr模板类中还得需要将*、->重载,才可让其像指针一样去使用。


// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr {
public:
	SmartPtr(T* ptr = nullptr)//创建对象时自动调用构造函数获取资源
		: _ptr(ptr)
	{}
	~SmartPtr()//对象出作用域自动调用析构函数释放资源
	{
		if (_ptr)
			delete _ptr;
	}

	T& operator*()
	{
		return *_ptr;
	}

	T* operator->()
	{
		return _ptr;
	}
private:
	T* _ptr;
};

int main()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2 = sp1;


	return 0;
}

总结一下智能指针的原理:

1.RAlI特性

2.重载operator*和opertaor->,具有像指针—样的行为。

3.支持拷贝

但是还是存在这些问题,不支持拷贝,智能指针必须要支持拷贝(浅拷贝):

我们可以自己看一下库里面是如何解决这个问题的。

5.3.3 std::auto_ptr

https://cplusplus.com/reference/memory/auto_ptr/

C++98版本的库中就提供了auto_ptr的智能指针。下面演示的auto_ptr的使用及问题。

auto_ptr的实现原理:管理权转移的思想,下面简化模拟实现了一份bi:auto_ptr来了解它的原理

// C++98 管理权转移 auto_ptr
namespace bit
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T* ptr)
			:_ptr(ptr)
		{}
		auto_ptr(auto_ptr<T>& sp)
			:_ptr(sp._ptr)
		{
			// 管理权转移
			sp._ptr = nullptr;
		}
		auto_ptr<T>& operator=(auto_ptr<T>& ap)
		{
			// 检测是否为自己给自己赋值
			if (this != &ap)
			{
				// 释放当前对象中资源
				if (_ptr)
					delete _ptr;
				// 转移ap中资源到当前对象中
				_ptr = ap._ptr;
				ap._ptr = NULL;
			}
			return *this;
		}
		~auto_ptr()
		{
			if (_ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
			}
		}
		// 像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}
		T* operator->()
		{
			return _ptr;
		}
	private:
		T* _ptr;
	};
}
// 结论:auto_ptr是一个失败设计,很多公司明确要求不能使用auto_ptr
int main()
{
 std::auto_ptr<int> sp1(new int);
 std::auto_ptr<int> sp2(sp1); // 管理权转移

 // sp1悬空
 *sp2 = 10;
 cout << *sp2 << endl;
 cout << *sp1 << endl;
 return 0;
}

C++智能指针起源于1997年的C++标准库。在之前的版本中,C++开发者需要手动管理内存,包括手动分配和释放内存,这往往容易出现内存泄漏和悬空指针等问题。为了解决这些问题,智能指针被引入到C++中。

最早的智能指针是auto_ptr,它通过在析构函数中自动释放指针所指向的内存,避免了手动释放内存的问题。然而,auto_ptr存在一些缺点,比如它实现的所有权转移语义可能导致一些意外的行为,比如会导致原指针悬空的问题,如上代码所示。

为了解决auto_ptr的问题,C++11引入了更加高级的智能指针:unique_ptr和shared_ptr。unique_ptr通过移动语义来实现所有权的转移,并且确保只有一个指针可以管理特定的对象。这样可以避免多个指针同时访问同一个对象的问题。

而shared_ptr则可以被多个指针共享,它使用引用计数的方式来跟踪和管理对象的所有权。当最后一个指向对象的shared_ptr被销毁时,会自动释放对象的内存。

5.3.4 std::unique_ptr

在C++中,std::unique_ptr 是一种智能指针,用于管理动态分配的单个对象。与 std::shared_ptr 不同,std::unique_ptr 拥有独占所有权,这意味着在任何给定时间只有一个 std::unique_ptr 实例可以拥有对其指向的对象的所有权。当 std::unique_ptr 超出其范围时(例如,当离开其作用域时),它将自动释放其所管理的对象。

https://cplusplus.com/reference/memory/unique_ptr/

unique_ptr的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份UniquePtr来了解它的原理

// C++11库才更新智能指针实现
// C++11出来之前,boost搞除了更好用的scoped_ptr/shared_ptr/weak_ptr
// C++11将boost库中智能指针精华部分吸收了过来
// C++11->unique_ptr/shared_ptr/weak_ptr
// unique_ptr/scoped_ptr
// 原理:简单粗暴 -- 防拷贝
namespace bit
{
	template<class T>
	class unique_ptr
	{
	public:
		unique_ptr(T* ptr)
			:_ptr(ptr)
		{}
		~unique_ptr()
		{
			if (_ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
			}
		}
		// 像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}
		T* operator->()
		{
			return _ptr;
		}
		//C++11的做法就是使用delete,C++98的做法就是把这两个函数声明为私有且不实现
		//C++中的 = delete用于删除一个函数,即禁止对该函数的调用。通过在函数声明或定义
		//的末尾加上 = delete,可以显式地告诉编译器不要生成该函数的默认实现。
		//使用 = delete可以在一些情况下禁用默认的行为,例如禁止拷贝构造函数、禁止拷贝赋值
		//运算符或禁止移动操作等。当试图调用被 = delete的函数时,编译器将会产生错误,提示该函数已被删除。
		unique_ptr(const unique_ptr<T>& sp) = delete;
		unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
	private:
		T* _ptr;
	};
}
int main()
{
 /*bit::unique_ptr<int> sp1(new int);
 bit::unique_ptr<int> sp2(sp1);*/

 std::unique_ptr<int> sp1(new int);
 //std::unique_ptr<int> sp2(sp1);

 return 0;
}

以下是 std::unique_ptr 的一些重要特点:

  1. 独占所有权:只能有一个 std::unique_ptr 实例拥有对其指向的对象的所有权。
  2. 轻量级:std::unique_ptr 是一种轻量级智能指针,因为它只需要一个指针和一个小的控制块来管理所指向的对象。
  3. 移动语义:std::unique_ptr 支持移动语义,因此它可以通过移动操作转移所有权,而不需要执行显式的内存分配或复制操作。
  4. 删除器:可以为 std::unique_ptr 提供自定义的删除器,以指定在释放对象时要执行的特殊操作。
5.3.5 std::shared_ptr

http://www.cplusplus.com/reference/memory/shared_ptr/

shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。例如:老师晚上在下班之前都会通知,让最后走的学生记得把门锁下。

1.shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。

2.在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。

3.如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;

4.如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。

那么我们该如何设计这个引用计数?直接在成员里定义一个 int _count; 可以吗?很显然不可以,因为在示例化对象时每一个对象都有自己独立的int _count。那么设计成 static int _count; 呢?也是不可以的,因为这是属于所有这个类对象的。可以看一下代码中是如何设计的:

引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源

#include <mutex>
#include <thread>

namespace bit
{
	//智能指针三大要素
	// 1、RAII
	// 2、像指针一样使用
	// 3、拷贝
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr)
			:_ptr(ptr)
			, _pcount(new int(1))
			, _pmtx(new mutex)
		{}

		~shared_ptr()
		{
			Release();
		}

		void Release()
		{
			_pmtx->lock();

			bool deleteFlag = false;

			if (--(*_pcount) == 0)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
				delete _pcount;

				deleteFlag = true;
			}

			_pmtx->unlock();

			if (deleteFlag)
			{
				delete _pmtx;
			}
		}

		void AddCount()
		{
			_pmtx->lock();

			++(*_pcount);

			_pmtx->unlock();
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
			, _pmtx(sp._pmtx)
		{
			AddCount();
		}

		// sp1 = sp4
		// sp1 = sp1;
		// sp1 = sp2;
		shared_ptr<T>& operator=(const shared_ptr<T>& sp)
		{
			if (_ptr != sp._ptr)
			{
				Release();

				_ptr = sp._ptr;
				_pcount = sp._pcount;
				_pmtx = sp._pmtx;

				AddCount();
			}

			return *this;
		}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

		T* get()
		{
			return _ptr;
		}

		int use_count()
		{
			return *_pcount;
		}

	private:
		T* _ptr;
		int* _pcount;//引用计数,用来解决无法浅拷贝的问题
		mutex* _pmtx;
	};

	void test_shared()
	{
		shared_ptr<int> sp1(new int(1));
		shared_ptr<int> sp2(sp1);
		shared_ptr<int> sp3(sp2);

		shared_ptr<int> sp4(new int(10));

		//sp1 = sp4;
		sp4 = sp1;

		sp1 = sp1;
		sp1 = sp2;
	}

	struct Date
	{
		int _year = 0;
		int _month = 0;
		int _day = 0;
	};

	// shared_ptr本身是线程安全的,因为计数是加锁保护
	// shared_ptr管理的对象是否是线程安全?
	void SharePtrFunc(std::shared_ptr<Date>& sp, size_t n, mutex& mtx)
	{
		//cout << &sp << endl;

		//cout << sp.get() << endl;

		for (size_t i = 0; i < n; ++i)
		{
			// 这里智能指针拷贝会++计数,智能指针析构会--计数,这里是线程安全的。
			std::shared_ptr<Date> copy(sp);

			mtx.lock();

			sp->_year++;
			sp->_day++;
			sp->_month++;

			mtx.unlock();
		}
	}

	void test_shared_safe()
	{
		//bit::shared_ptr<Date> p(new Date);
		std::shared_ptr<Date> p(new Date);

		cout << p.get() << endl;
		//cout << &p << endl;

		const size_t n = 50000;
		mutex mtx;
		thread t1(SharePtrFunc, ref(p), n, ref(mtx));
		thread t2(SharePtrFunc, ref(p), n, ref(mtx));

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

		cout << p.use_count() << endl;

		cout << p->_year << endl;
		cout << p->_month << endl;
		cout << p->_day << endl;
	}
}

这段代码实现了一个简单的智能指针类shared_ptr,具有自动引用计数的功能。

在命名空间bit中,定义了一个模板类shared_ptr,模板参数T表示被智能指针管理的对象的类型。

在shared_ptr类中,有以下成员变量:

  • _ptr:指向被管理对象的指针
  • _pcount:引用计数的指针,用来记录当前指针被多少个shared_ptr对象共享
  • _pmtx:互斥锁,用来保护引用计数的操作

shared_ptr类的构造函数接受一个指针作为参数,并将其赋值给_ptr成员变量。同时创建一个引用计数的对象,初始化为1,并将其赋值给_pcount成员变量。还创建一个互斥锁,并将其赋值给_pmtx成员变量。

shared_ptr类的析构函数中,调用Release()函数来释放资源。在Release()函数中,使用互斥锁进行加锁操作,然后将引用计数减1。如果引用计数变为0,表示没有其他shared_ptr对象共享该资源,此时释放资源,并删除引用计数指针。最后释放互斥锁。

shared_ptr类还实现了AddCount()函数,用于增加引用计数。函数中使用互斥锁进行加锁操作,然后将引用计数加1,最后释放互斥锁。

shared_ptr类还重载了拷贝构造函数和赋值运算符,用于实现智能指针的拷贝。在拷贝构造函数和赋值运算符中,先调用AddCount()函数将引用计数加1,然后将成员变量赋值为被拷贝对象的成员变量。

shared_ptr类还重载了解引用运算符*和箭头运算符->,使得智能指针可以像指针一样使用。

shared_ptr类还定义了get()函数,用于返回被管理对象的指针。

shared_ptr类还定义了use_count()函数,用于返回当前引用计数的值。

在命名空间bit中还定义了一个test_shared()函数,用于测试shared_ptr类的基本功能。在函数中创建多个shared_ptr对象,并进行赋值操作,然后输出引用计数的值。

在命名空间bit中还定义了一个结构体Date,用于测试shared_ptr类的线程安全性。结构体中有三个成员变量。

在命名空间bit中还定义了一个SharePtrFunc()函数,用于测试shared_ptr类在多线程环境下的线程安全性。函数中接受一个智能指针参数sp,一个整数参数n,一个互斥锁参数mtx。函数中使用循环对sp进行拷贝,并在加锁的情况下修改被管理对象的成员变量。该函数在多个线程中被调用,参数sp通过引用传递,保证了多个线程共享同一个智能指针对象。

在命名空间bit中还定义了一个test_shared_safe()函数,用于测试shared_ptr类在多线程环境下的线程安全性。函数中创建一个shared_ptr对象p,并输出其指针地址。然后创建两个线程调用SharePtrFunc()函数,传入参数为p、n、mtx。最后输出引用计数的值和被管理对象的成员变量的值。

总结: 这段代码实现了一个简单的智能指针类shared_ptr,具有自动引用计数的功能,并在多线程环境下保证了线程安全性。shared_ptr类的主要原理是通过引用计数来追踪共享对象的使用情况,并在引用计数变为0时释放资源。在多线程环境下,通过互斥锁来保护引用计数的操作,从而实现线程安全性。

std:shared_ptr的线程安全问题

通过下面的程序我们来测试shared_ptr的线程安全问题。需要注意的是shared_ptr的线程安全分为两方面:

1.智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时++或--,这个操作不是原子的,引用计数原来是1,++了两次,可能还是2.这样引用计数就错乱了。会导致资源未释放或者程序崩溃的问题。所以只能指针中引用计数++、--是需要加锁的,也就是说引用计数的操作是线程安全的。

⒉.智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题。

std::shared_ptr的循环引用

struct ListNode
{
	int _data;
	shared_ptr<ListNode> _prev;
	shared_ptr<ListNode> _next;
	~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
	shared_ptr<ListNode> node1(new ListNode);
	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;
}

循环引用分析:
1. node1和node2两个智能指针对象指向两个节点,引用计数变成1,我们不需要手动delete。
2. node1的_next指向node2,node2的_prev指向node1,引用计数变成2。
3. node1和node2析构,引用计数减到1,但是_next还指向下一个节点。但是_prev还指向上一个节点。
4. 也就是说_next析构了,node2就释放了。
5. 也就是说_prev析构了,node1就释放了。
6. 但是_next属于node的成员,node1释放了,_next才会析构,而node1由_prev管理,_prev属于node2成员,所以这就叫循环引用,谁也不会释放。

// 解决方案:在引用计数的场景下,把节点中的_prev和_next改成weak_ptr就可以了
// 原理就是,node1->_next = node2;和node2->_prev = node1;时weak_ptr的_next和
//_prev不会增加node1和node2的引用计数。
struct ListNode
{
	int _data;
	weak_ptr<ListNode> _prev;
	weak_ptr<ListNode> _next;
	~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
	shared_ptr<ListNode> node1(new ListNode);
	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;
}

std::weak_ptr的介绍:

weak_ptr - C++ Reference

weak_ptr是C++11标准中引入的智能指针类之一,用于解决shared_ptr的循环引用问题。weak_ptr可以跟踪shared_ptr指向的对象,但不会增加引用计数,也不会拥有该对象的所有权。

weak_ptr与shared_ptr通常是配对使用的,可以通过shared_ptr创建weak_ptr对象。weak_ptr对象可以通过lock()函数获取一个有效的shared_ptr对象,如果该shared_ptr对象还存在,则可以使用该对象访问被管理的对象,否则返回一个空的shared_ptr对象。

weak_ptr的主要作用是解决循环引用问题,循环引用指的是两个或多个对象相互引用,导致无法被销毁。如果使用shared_ptr相互引用,会导致引用计数无法降为0,从而无法释放资源。但是使用weak_ptr作为其中一个引用,可以打破循环引用,当没有其他shared_ptr引用时,对象会被正确释放。

使用weak_ptr的示例代码如下:

#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<int> sp1 = std::make_shared<int>(42);
    std::weak_ptr<int> wp = sp1;
    
    if (wp.expired())
    {
        std::cout << "shared_ptr is expired" << std::endl;
    }
    else
    {
        std::shared_ptr<int> sp2 = wp.lock();
        
        if (sp2 != nullptr)
        {
            std::cout << "shared_ptr value: " << *sp2 << std::endl;
        }
        else
        {
            std::cout << "shared_ptr is expired" << std::endl;
        }
    }
    
    return 0;
}

在上面的示例代码中,首先创建一个shared_ptr对象sp1,它指向一个int类型的对象。然后使用sp1创建一个weak_ptr对象wp。通过wp.expired()函数判断sp1是否过期,如果过期则输出相应信息。否则,通过wp.lock()函数尝试获取一个有效的shared_ptr对象sp2,如果sp2不为空,则输出其值。最后,输出结果为"shared_ptr value: 42"。

总结: weak_ptr是C++11标准中引入的智能指针类,用于解决shared_ptr的循环引用问题。它可以跟踪shared_ptr指向的对象,但不会增加引用计数,也不会拥有该对象的所有权。可以通过lock()函数获取一个有效的shared_ptr对象,用于访问被管理的对象。使用weak_ptr可以打破循环引用,确保对象的正确释放。

如果不是new出来的对象如何通过智能指针管理呢?(指的是外部传入的对象)其实shared_ptr设计了一个删除器来解决这个问题?

// 定制删除器 -- 可调用对象
template<class T>
struct DeleteArray
{
	void operator()(T* ptr)
	{
		cout << "void operator()(T* ptr)" << endl;
		delete[] ptr;
	}
};

struct Date
{
	int _year = 0;
	int _month = 0;
	int _day = 0;
};

void test_shared_deletor()
{
	std::shared_ptr<Date> spa1(new Date[10], DeleteArray<Date>());
	std::shared_ptr<Date> spa2(new Date[10], [](Date* ptr){
		cout << "lambda delete[]"<<ptr << endl;
		delete[] ptr; 
	});

	std::shared_ptr<FILE> spF3(fopen("Test.cpp", "r"), [](FILE* ptr){
		cout << "lambda fclose" << ptr << endl;
		fclose(ptr);
	});
}

智能指针需要定制删除器的原因有以下几点:

  1. 灵活释放资源:智能指针通常用于管理资源,如动态分配的内存、文件句柄等。不同类型的资源可能需要不同的释放方式,因此通过定制删除器可以灵活地选择适合特定资源类型的释放策略。
  2. 非默认删除器:有些对象可能使用自定义的析构函数或者特殊的释放方式。此时如果使用默认的删除器,可能会导致资源泄漏或者未定义行为。通过定制删除器,可以确保在销毁智能指针时正确调用对象的析构函数或者释放资源的方式。
  3. 扩展智能指针行为:通过定制删除器,可以扩展智能指针的行为,如在销毁时执行特定的逻辑或者触发回调函数。比如,可以在删除器中添加日志记录、异常处理等操作,以增加智能指针的功能。
  4. 自定义引用计数:有些情况下,需要对对象进行引用计数,以便在没有引用时自动删除对象。定制删除器可以自定义引用计数的逻辑,并在引用计数达到零时触发对象的销毁。

总的来说,定制删除器可以使智能指针更加灵活和强大,能够适应各种资源管理的需求,并提供更好的资源释放和扩展性。

5.4 C++11和boost中智能指针的关系

C11标准受到了Boost库的强烈影响,特别是其中的智能指针部分。在C11之前,Boost库提供了一系列智能指针,例如boost::shared_ptr、boost::weak_ptr、boost::scoped_ptr和boost::scoped_array。这些智能指针通过自动管理内存来防止资源泄漏,并且提供了其他高级功能,如引用计数和循环引用保护。

当C++11被引入时,它包含了对智能指针的标准化,其中很多是基于Boost库中相应实现的。例如:

  • std::shared_ptr 是基于 boost::shared_ptr。
  • std::weak_ptr 是基于 boost::weak_ptr。
  • std::unique_ptr 是一种新引入的智能指针,它与Boost中的 boost::scoped_ptr 类似,但提供了所有权语义的增强。
  • 没有将 boost::scoped_array 包含在内,而是引入了 std::unique_ptr 来安全地管理动态分配的数组。

1.C++98中产生了第一个智能指针auto_ptr

2.C++ boost给出了更实用的scoped_ptr和shared_ptr和weak_ptr

3.C++TR1,引入了shared_ptr等。不过注意的是TR1并不是标准版。

4.C++11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。

5.5make_shared函数(

std::make_shared 是 C++11 引入的一个函数模板,用于在动态内存中创建对象,并返回指向该对象的 std::shared_ptr 智能指针。它可以减少动态内存分配的次数,并提高内存分配的效率。

std::make_shared 接受任意数量的参数,并根据这些参数在动态内存中构造一个对象,然后返回一个指向该对象的 std::shared_ptr。与直接调用 std::shared_ptr 构造函数相比,使用 std::make_shared 的好处是它可以减少一次内存分配和一次构造函数的调用。

原型:

template< class T, class... Args >
shared_ptr<T> make_shared( Args&&... args );
  • T:要创建的对象的类型。
  • Args&&... args:构造对象 T 所需的参数。

简单来说就是make_shared函数会把传给它的参数去构造成会调用传给它需要构造的类型参数的构造函数自动构造一个该类型的智能指针:
std::make_shared 函数是 C++ 中的一个工厂函数,用于创建 std::shared_ptr 智能指针。make_shared 函数可以接受一个类型参数和构造函数的参数,并自动为该类型创建一个对象,并返回一个指向该对象的std::shared_ptr 的智能指针类型。

你可以将类型 T 和构造函数的参数直接传递给 make_shared,它会在内部调用 T 类型的构造函数,然后返回一个指向新创建对象的 std::shared_ptr<T>。

使用方法:

使用 std::make_shared 非常简单,只需提供要创建的对象类型以及传递给该对象构造函数的参数即可。

例如,假设有一个 MyClass 类:

#include <iostream>
#include <memory>

class MyClass {
    public:
    MyClass(int value) : m_value(value) {
        std::cout << "Constructor called with value: " << m_value << std::endl;
    }
    ~MyClass() {
        std::cout << "Destructor called for value: " << m_value << std::endl;
    }

    int getValue() const {
        return m_value;
    }

    private:
    int m_value;
};

现在可以使用 std::make_shared 来创建 MyClass 类的对象,并获得一个指向该对象的 std::shared_ptr 智能指针:

int main() {
    // 使用 std::make_shared 创建 shared_ptr
    std::shared_ptr<MyClass> ptr = std::make_shared<MyClass>(42);

    // 使用 ptr 所指向的对象
    std::cout << "Value of ptr: " << ptr->getValue() << std::endl;

    return 0;
}

在这个示例中,std::make_shared<MyClass>(42) 创建了一个 MyClass 类的对象,并将值 42 传递给 MyClass 的构造函数。然后,返回的 std::shared_ptr 智能指针 ptr 指向该对象。

使用 std::make_shared 的好处是它可以减少内存分配的次数,因为它将分配内存和构造对象的操作结合在一起,从而提高了性能。

6.STL中的一些变化

用橘色圈起来是C++11中的一些几个新容器,但是实际最有用的是unordered_map和unordered_set。这两个前面的文章里已经进行了介绍,其他的只需要稍微 的了解一下即可。

array容器:

array容器是一种用于存储和管理固定大小的连续元素序列的容器。它在C++语言中定义在头文件中,并引入了std命名空间。

array容器的特点包括:

  1. 固定大小:array容器在创建时需要指定固定的大小,并且在运行时不可更改,即容器的大小是固定的。
  2. 连续存储:array容器的元素在内存中是连续存储的,这使得在访问元素时具有较高的性能。
  3. 快速访问:由于元素的连续存储,可以通过下标索引快速访问容器中的元素,类似于普通的C数组。
  4. 高效的拷贝:array容器的拷贝操作是高效的,因为它的内部元素是在连续存储中的。

array容器的使用类似于普通的C数组,可以使用下标索引来访问和修改元素的值。同时,它也提供了一些成员函数,如size()、at()、front()、back()等,用于获取容器的大小和访问元素。

需要注意的是,array容器的大小是固定的,不能通过插入或删除元素来改变容器的大小。如果需要动态改变容器大小的话,可以选择其他容器,如vector或list。

forward_list容器:

forward_list容器是C++标准库中的一种单向链表容器,用于存储和管理元素的序列。它在C++语言中定义在头文件中,并引入了std命名空间。

forward_list容器的特点包括:

  1. 单向链表:forward_list容器以单向链表的方式组织元素,每个元素包含自身的值和一个指向下一个元素的指针。
  2. 动态大小:forward_list容器的大小可以动态增加或减少,可以在运行时进行插入、删除操作。
  3. 低内存占用:由于是单向链表,forward_list容器在存储元素时只需要额外的一个指针,相比其他容器(如vector或list),它具有较低的内存占用。
  4. 快速插入和删除操作:由于元素的链式结构,插入和删除操作在forward_list容器中是高效的,不需要移动其他元素。
  5. 不支持随机访问:由于是单向链表,forward_list容器不支持随机访问,即不能通过下标索引来访问元素,只能通过迭代器进行遍历和访问。

forward_list容器提供了一些成员函数,如push_front()、pop_front()、insert_after()等,用于在链表头部插入、删除元素,以及在指定位置插入元素。此外,它还可以通过迭代器实现遍历和访问操作。

需要注意的是,由于forward_list容器的单向性质,无法直接访问前一个元素,也不支持尾删,所以在进行某些操作时可能会产生额外的复杂性。如果需要双向链表功能或更多的随机访问支持,可以选择其他容器,如list或deque。

容器中的一些新方法
如果我们再细细去看会发现基本每个容器中都增加了一些C++11的方法,但是其实很多都是用得比较少的。
比如提供了cbegin和cend方法返回const迭代器等等,但是实际意义不大,因为begin和end也是
可以返回const迭代器的,这些都是属于锦上添花的操作。
实际上C++11更新后,容器中增加的新方法最后用的插入接口函数的右值引用版本:

但是这些接口到底意义在哪?网上都说他们能提高效率,他们是如何提高效率的?请看下面的右值引用和移动语义的讲解。另外emplace还涉及模板的可变参数。

7.右值引用和移动语义

7.1左值引用和右值引用

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

什么是左值?什么是左值引用?

左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。

int main()
{
	// 以下的p、b、c、*p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;
	// 以下几个是对上面左值的左值引用
	int*& rp = p;
	int& rb = b;
	const int& rc = c;
	int& pvalue = *p;

	return 0;
}

在C++中,左值(lvalue)是一个表达式,指向具体的内存位置(对象或函数)。左值可以出现在赋值操作符的左边或右边,但并不是所有左值都可以出现在赋值操作符的右边。

左值可以分为以下两种情况:

  1. 纯左值(pure lvalue):纯左值是指具有标识符的表达式,可以代表一个具体的对象或函数。例如,变量、函数、对象成员等都属于纯左值。
  2. 左值引用(lvalue reference):左值引用是指通过引用绑定到左值的表达式。左值引用本身也是一个左值。使用&符号来定义左值引用。

左值可以出现在赋值操作符的左边或右边,例如:

int a = 5;   // a是纯左值,在赋值操作符的左边
int b = a;   // a是纯左值,在赋值操作符的右边

int& c = a;  // c是左值引用,在赋值操作符的左边
int d = c;   // c是左值引用,在赋值操作符的右边

但是,并不是所有左值都可以出现在赋值操作符的右边。在C++中,有一些特殊的左值,称为“即将销毁的左值”(expiring lvalue),这些左值无法出现在赋值操作符的右边,。

int foo() {
    int a = 42;
    return a;   // a是即将销毁的左值,不能出现在赋值操作符的右边
}

int main() {
    int b = foo();   // 错误,不能将即将销毁的左值赋值给变量
    return 0;
}

总之,左值是一个表达式,可以代表具体的内存位置(对象或函数),并可以出现在赋值操作符的左边或右边。但并非所有左值都能出现在赋值操作符的右边,其中包括“即将销毁的左值”,比如上面代码意思就是不能返回局部变量,因为局部变量出了作用就会销毁。

什么是右值?什么是右值引用?

右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。

在C++中,右值(rvalue)是一个表达式,表示临时的、不具名的对象或值。右值只能出现在赋值操作符的右边,不能作为赋值操作符的左操作数。

右值可以分为以下两种情况:

  1. 字面量(Literal):例如整型、浮点型、字符串常量等都属于字面量,它们是临时的、不可修改的值。
  2. 右值引用(rvalue reference):右值引用是指通过引用绑定到右值的表达式。使用&&符号来定义右值引用。

右值可以出现在赋值操作符的右边,例如:

int a = 5;   // 5是右值,在赋值操作符的右边
int b = 10 + 20;   // (10 + 20)是右值,在赋值操作符的右边

int&& c = 42;   // c是右值引用,在赋值操作符的右边
int d = std::move(c);   // c是右值引用,在赋值操作符的右边

右值在C++11中引入了移动语义(Move Semantics)的概念,可以通过std::move函数将左值转换为右值引用,从而实现高效的资源转移。通过使用右值引用,可以避免不必要的对象拷贝和内存分配,提高性能。

int main()
{
	
	double x = 1.1, y = 2.2;
	// 以下几个都是常见的右值
	10;
	x + y;
	fmin(x, y);

	// 以下几个都是对右值的右值引用,右值引用给右值取别名
	int&& rr1 = 10;
	double&& rr2 = x + y;
	double&& rr3 = fmin(x, y);

	//const左值引用可以给右值取别名,还可以给左值取别名
	const double& ref1 = (x + y);//左值引用对右值取别名
	const double& ref2 = x;//左值引用给左值取别名

	// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
	//10 = 1;
	//x + y = 1;
	//fmin(x, y) = 1;

	return 0;
}

右值在现代C++中被广泛应用于移动语义、完美转发等特性,提高了代码的性能和可读性。

需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1去引用,是不是感觉很神奇,这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。

int main()
	{
		double x = 1.1, y = 2.2;
		int&& rr1 = 10;
		const double&& rr2 = x + y;
		rr1 = 20;
		rr2 = 5.5; // 报错
		return 0;
	}

7.2左值引用和右值引用的比较

左值引用总结:

1.左值引用只能引用左值,不能引用右值。

2.但是const左值引用既可引用左值,也可引用右值。

int main()
{
	// 左值引用只能引用左值,不能引用右值。
	int a = 10;
	int& ra1 = a; // ra为a的别名
	//int& ra2 = 10; // 编译失败,因为10是右值
	// const左值引用既可引用左值,也可引用右值。
	const int& ra3 = 10;
	const int& ra4 = a;
	return 0;
}

右值引用总结:

1.右值引用只能引用右值,不能引用左值。

2.但是右值引用可以引用move以后的左值。

int main()
{
	// 右值引用只能右值,不能引用左值。
	int&& r1 = 10;
	// error C2440: “初始化”: 无法从“int”转换为“int &&”
	// message : 无法将左值绑定到右值引用
	int a = 10;
	//int&& r2 = a;//报错
	
	// 右值引用可以引用move以后的左值
	int&& r3 = std::move(a);
	return 0;
}

7.3右值引用使用场景和意义

前面我们可以看到左值引用既可以引用左值和又可以引用右值,那为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!

移动构造函数和移动赋值运算符是C++11引入的特殊成员函数,用于实现对象的移动语义,以提高性能和资源利用。

移动构造函数(Move Constructor)接受一个右值引用作为参数,并将传入的对象的资源所有权转移到新创建的对象上。通常,在移动构造函数中,会将传入对象的成员变量(如指针)的值直接移动到新对象中,而不进行深拷贝操作。这样可以避免不必要的内存分配和复制,提高程序的效率。

移动赋值运算符(Move Assignment Operator)也接受一个右值引用作为参数,用于将右值对象的资源所有权转移给当前对象。它允许我们使用已存在的对象来重新赋值,避免不必要的资源复制。移动赋值运算符通常会先释放当前对象的资源,然后将右值对象的资源转移给当前对象。

需要注意的是,移动构造函数和移动赋值运算符通常与拷贝构造函数和拷贝赋值运算符一起使用,并作为对象的特殊成员函数。通过使用移动语义,我们可以减少不必要的资源拷贝和内存分配,从而提高程序的性能。在代码中,编译器会根据类型和语言规则选择适当的构造函数或赋值运算符来实现资源的转移。

移动语义是指程序设计中的一种语义,用于描述变量或对象的所有权、生命周期和值的移动。在传统的程序设计中,当将一个值赋给另一个变量时,通常会进行值的复制操作,这样原来的变量和新的变量都会持有该值。而在移动语义中,当将一个值赋给另一个变量时,可以选择将原来的变量“移动”给新的变量,而不是进行复制操作。这样做可以避免不必要的内存复制,提高程序的性能。

移动语义适用于具有所有权概念的编程语言,如C++中的移动语义和Rust中的所有权系统。通过移动语义,可以将资源(如内存、文件句柄等)从一个对象传递给另一个对象,而不需要进行复制操作。同时,移动语义还可以避免资源的多重释放或泄漏问题,因为在移动所有权时,原来的对象将不再拥有该资源,从而避免了重复释放或未释放的问题。

总之,移动语义是一种程序设计中的语义,它允许将变量或对象的所有权和值传递给其他变量或对象,以提高程序的性能和资源管理的效率。

7.4右值引用 引用左值及其一些更深入的使用场景分析

按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义(移动资源)。

7.5完美转发forward函数

forward函数

在 C++ 中,std::forward 是一个用于完美转发(perfect forward ing)的工具函数,引入自 <utility> 头文件。它主要用于在泛型编程中保持参数的值类别(value category)和转发它们给其他函数,以实现更灵活的模板代码。

在理解 std::forward 之前,首先需要了解两个概念:左值(lvalue)和右值(rvalue)。

  • 左值(lvalue)是可以取地址的表达式,通常是具名变量,也可以是引用等。
  • 右值(rvalue)是临时对象或表达式,其值在语句结束后就会被销毁。

std::forward 通常在模板函数或类模板中的转发过程中使用。考虑一个简单的情况,你有一个模板函数,它需要将参数转发给其他函数,保持参数的值类别:

#include <utility>

template <typename T>
void wrapper(T&& arg) {
    // 这里需要将 arg 转发给其他函数
    // std::forward 用于保持 arg 的值类别
    other_function(std::forward<T>(arg));
}

在这个例子中,wrapper 函数接受一个模板参数 T,这里使用了右值引用(T&&),这样可以接受左值和右值。当需要将 arg 转发给另一个函数 other_function 时,我们使用了 std::forward<T>(arg),这样就可以保持 arg 的原始值类别。

使用 std::forward 的好处在于,它可以正确地转发左值或右值,确保传递的参数维持原始的值类别,避免不必要的拷贝和移动操作。在模板编程中,这种技术经常用于实现泛型函数和类模板,以提高代码的通用性和性能。

模板中的&& 万能引用

std::forward 完美转发在传参的过程中保留对象原生类型属性

完美转发实际中的使用场景:

template<class T>
struct ListNode
{
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
T _data;
};
template<class T>
class List
{
typedef ListNode<T> Node;
public:
List()
{
    _head = new Node;
    _head->_next = _head;
    _head->_prev = _head;
}
void PushBack(T&& x)
{
    //Insert(_head, x);
    Insert(_head, std::forward<T>(x));
}
void PushFront(T&& x)
{
    //Insert(_head->_next, x);
    Insert(_head->_next, std::forward<T>(x));
}

void Insert(Node* pos, T&& x)
{
    Node* prev = pos->_prev;
    Node* newnode = new Node;
    newnode->_data = std::forward<T>(x); // 关键位置
    // prev newnode pos
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = pos;
    pos->_prev = newnode;
}
void Insert(Node* pos, const T& x)
{
    Node* prev = pos->_prev;
    Node* newnode = new Node;
    newnode->_data = x;
    // prev newnode pos
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = pos;
    pos->_prev = newnode;
}
private:
Node* _head;
};
int main()
{
    List<bit::string> lt;
    lt.PushBack("1111");
    lt.PushFront("2222");
    return 0;
}

8.新的类功能

默认成员函数

原来C++类中,有6个默认成员函数:

1.构造函数

2.析构函数

3.拷贝构造函数

4.拷贝赋值重载

5.取地址重载

6.const 取地址重载

最后重要的是前4个,后两个用处不大。默认成员函数就是我们不写编译器会生成一个默认的。

C++11新增了两个:移动构造函数和移动赋值运算符重载。

针对移动构造函数和移动赋值运算符重载有一些需要注意的点如下:

如果你没有自己实现移动构造函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。

如果你没有自己实现移动赋值重载函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似)

如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。

强制生成默认函数的关键字default

C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以使用default关键字显示指定移动构造生成。

class Person
{
    public:
    Person(const char* name = "", int age = 0)
        :_name(name)
            , _age(age)
        {}
    Person(const Person& p)
        :_name(p._name)
            , _age(p._age)
        {}
    Person(Person&& p) = default;//强制生成移动构造
    private:
    bit::string _name;
    int _age;
};
int main()
{
    Person s1;
    Person s2 = s1;
    Person s3 = std::move(s1);
    return 0;
}

禁止生成默认函数的关键字delete

如果能想要限制某些默认函数的生成,在C++98中,是将该函数设置成private私有成员,并且只声明不实现函数体,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。

class Person
{
public:
	Person(const char* name = "", int age = 0)
		:_name(name)
		, _age(age)
	{}
	Person(const Person& p) = delete;//禁止生成拷贝构造函数
private:
	bit::string _name;
	int _age;
};
int main()
{
	Person s1;
	Person s2 = s1;//报错
	Person s3 = std::move(s1);//报错
	return 0;
}

继承和多态中的final与override关键字

在C++中,继承和多态是面向对象编程的重要概念,而final和override关键字在继承和多态中有特定的作用和含义。

  1. final关键字的作用:
    • 继承中的final:在C++11及以上的版本中,可以使用final关键字来修饰一个类,表示该类不能被其他类继承。被final修饰的类是最终类,不能再有派生类。
    • 方法中的final:在C++11及以上的版本中,可以使用final关键字来修饰一个虚函数,表示该函数不能被派生类重写。被final修饰的虚函数是最终函数,不能再被派生类修改其实现方式。
  1. override关键字的作用:
    • 在多态中,override关键字用于标识派生类中对基类虚函数的重写。使用override关键字可以确保派生类正确地重写了基类的虚函数。
    • override关键字提醒编译器进行函数重写的检查,以确保派生类中的重写函数与基类的虚函数具有相同的签名,如果不一致则会产生编译错误。

综上所述,final关键字用于限制继承和方法重写的行为,防止类和虚函数被派生类修改或扩展;而override关键字用于标识派生类对基类虚函数的重写,保证函数的正确性。这两个关键字在C++中在继承和多态中起到了不同的作用。

9.可变参数模版

可变参数模板是C++11中引入的一种特性,允许函数或类模板接受可变数量的参数。使用可变参数模板可以编写更通用、灵活的代码。

可变参数模板定义的语法如下:

template <typename... Args>
retType functionName(Args&&... args);

其中 Args 是一个模板参数包,可以接受0个或多个参数。Args&&... 是一个参数包展开的方式,表示参数包中的每个参数都是右值引用。retType 是函数的返回类型,可以根据实际需要进行定义。

在函数模板或类模板的实现中,可以使用 sizeof...() 操作符来获取参数包中的参数数量。例如:

template <typename... Args>
void printArgs(Args&&... args) {
    std::cout << "Number of arguments: " << sizeof...(args) << std::endl;//注意...的位置
}

在调用函数模板时,可以传递任意数量的参数:

printArgs(1, 2, 3); // 输出:Number of arguments: 3
printArgs("hello", 3.14, 'c'); // 输出:Number of arguments: 3
printArgs(); // 输出:Number of arguments: 0

这样,通过可变参数模板,可以在编写函数模板时更灵活地处理不同数量和类型的参数。例如,可以用可变参数模板来实现一个通用的打印函数,可以打印任意数量和类型的参数。

除了函数模板,可变参数模板也可以用于类模板的定义。同样地,可以在类模板中定义一组具有不同类型的成员变量或成员函数。

需要注意的是,在使用可变参数模板时,需要注意参数的顺序和类型匹配的问题,避免出现歧义。此外,可变参数模板也可以与其他C++特性组合使用,如模板特化、默认参数等,以进一步增强其灵活性和通用性。

我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用args[i]这样方式获取可变参数,所以我们的用一些奇招来一一获取参数包的值。

递归函数方式展开参数包:

在C++中,可以使用递归函数方式来展开可变参数模板的参数包。这种方式不依赖于折叠表达式,并可在不支持C++17的编译器上使用。

// 递归终止函数
void ShowList()
{
	cout << endl;
}

// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)//value表示推断参数
{
	cout << value << " ";
	ShowList(args...);//每次从参数包取出一个参数传给T,自己在传给Args参数包
}

int main()
{
	ShowList(1);
	ShowList(1, 'A');
	ShowList(1, 'A', std::string("sort"));
	ShowList(1, 'A', std::string("sort"), 3.14);

	return 0;
}

ShowList()(这里指的是递归终止函数)函数用作递归终止函数,当没有剩余参数时就会调用这个函数,表示递归展开结束,在每次递归调用中,首先输出当前参数的值,然后再递归调用ShowList()(这里指的是展开函数)函数来展开剩余的参数。每次递归调用都会输出一个参数的值,当没有参数时就会调用递归终止函数,通过递归终止函数来结束递归展开。

递归函数方式展开参数包另一种写法:

// 递归函数展开参数包的辅助函数
template <typename T>
void printArg(const T& arg) {
    std::cout << arg << " ";
}

template<typename T, typename... Args>
void printArgs(const T& arg, const Args&... args) {
    printArg(arg); // 打印参数
    printArgs(args...); // 递归展开剩余的参数
}

// 函数模板打印参数包
template<typename... Args>
void printAllArgs(const Args&... args) {
    printArgs(args...);
    std::cout << std::endl;
}

在上述代码中,printArg()函数用于打印单个参数,printArgs()函数用于展开剩余的参数包,并将每个参数传递给printArg()函数。最后,printAllArgs()函数是一个对外接口,用于调用参数展开函数。通过递归调用printArgs()函数,可以逐个打印参数包中的所有参数。

使用示例:

printAllArgs(1, 2, 3); // 输出:1 2 3
printAllArgs("hello", 3.14, 'c'); // 输出:hello 3.14 c

以上示例展示了如何使用递归函数方式展开可变参数模板的参数包。这种方式不依赖于折叠表达式,并可在不支持C++17的编译器上使用。请注意,在使用递归函数方式展开参数包时,递归的结束条件是没有剩余参数需要展开。

逗号表达式展开参数包:

这两个示例代码中的逗号表达式虽然看起来很相似,但实际上还是有一些区别的。

首先,让我们来看一下第一个示例代码:

template <class T>
void PrintArg(T t) {
    cout << t << " ";
}

// 展开函数
template <class ...Args>
void ShowList(Args... args) {
    int arr[] = { (PrintArg(args), 0)... };
    cout << endl;
}

int main() {
    ShowList(1);
    ShowList(1, 'A');
    ShowList(1, 'A', std::string("sort"));
    return 0;
}

在这个示例中,逗号表达式 (PrintArg(args), 0) 在每次展开时,先调用PrintArg函数打印参数args的值,然后用0初始化一个整型元素。由于逗号表达式的值是最后一个表达式的值,这里的逗号表达式返回的是0。所以,整型数组arr中的每个元素都是0。

接下来,让我们看一下第二个示例代码:

template <class T>
int PrintArg(T t) {
    cout << t << " ";
    return 0;
}

// 展开函数
template <class ...Args>
void ShowList(Args... args) {
    int arr[] = { (PrintArg(args))... };
    cout << endl;
}

int main() {
    ShowList(1);
    ShowList(1, 'A');
    ShowList(1, 'A', std::string("sort"));
    return 0;
}

在这个示例中,逗号表达式 (PrintArg(args)) 在每次展开时,先调用PrintArg函数打印参数args的值,然后返回值为0。所以,整型数组arr中的每个元素都是PrintArg函数的返回值,即0。

因此,这两个示例中的逗号表达式在返回值上有区别:第一个示例返回的是0,而第二个示例返回的是PrintArg函数的返回值。

请注意,这里使用逗号表达式展开参数包是为了在展开过程中执行一个副作用(调用PrintArg函数或者初始化数组元素)。

折叠表达式展开参数包:

template<typename... Args>
void printArgs(const Args&... args) {
    ((std::cout << args << " "), ...);
    std::cout << std::endl;
}

上述代码中,Args 是一个模板参数包,const Args&... args 是一个参数包展开的方式,表示任意数量的参数。((std::cout << args << " "), ...) 使用了折叠表达式 (fold expression) 的方式,将所有参数依次打印出来。

使用示例:

printArgs(1, 2, 3); // 输出:1 2 3 
printArgs("hello", 3.14, 'c'); // 输出:hello 3.14 c

注:以上写法只有支持C++17的编译器才可以执行,否则会报错

STL容器中的emplace相关接口函数:

https://cplusplus.com/reference/list/list/emplace_back/,list的emplace函数

https://cplusplus.com/reference/vector/vector/emplace_back/,vector的emplace函数

.......

STL容器中大部分都有这个接口函数。

//emplace的声明
template <class... Args>
void emplace_back(Args&&... args);

std::insert和std::emplace是C++标准库中用于插入元素的函数模板系列。首先我们看到的emplace系列的接口,支持模板的可变参数,并且万能引用,它们相似的地方是都能用来向容器中插入元素,但它们在如何插入元素以及对性能的影响上有一些区别。以下是它们的优势:

  1. std::insert:
    • std::insert函数接受一个已存在的值作为参数,并将该值拷贝到容器中。
    • 当需要将一个已经存在的对象插入到容器中时,可以使用std::insert。
    • 它适用于容器的所有位置,包括开头、结尾和中间位置。
    • 这个接口适用于所有的C++标准容器。
  1. std::emplace:
    • std::emplace函数使用传递给它的参数直接在容器中构造对象。
    • 当需要构造一个新的对象并将其插入到容器中时,可以使用std::emplace。
    • 它通过将参数原地构造在容器的内存中,避免了额外的拷贝或移动操作,提高了性能。
    • 使用std::emplace可以避免不必要的对象拷贝或移动的开销,特别是对于较大的对象或者不可拷贝的对象。
    • 这个接口适用于可变参数的C++标准容器,如std::map、std::set、std::unordered_map和std::unordered_set等。

总的来说,std::insert用于将一个已存在的值拷贝到容器中,而std::emplace用于在容器中直接构造一个新的对象。使用std::emplace可以避免额外的拷贝或移动开销,提高了性能。但是需要注意的是,在使用std::emplace时,需要传递构造对象所需的参数。

使用insert()插入元素:

std::vector<int> vec = {1, 2, 3};
std::vector<int>::iterator it = vec.begin();

// 在迭代器位置插入元素
it = vec.insert(it, 4);

// 在迭代器位置之前插入多个元素
vec.insert(it, 2, 5);

// 在迭代器位置之前插入另一个容器的元素
std::vector<int> anotherVec = {6, 7, 8};
vec.insert(it, anotherVec.begin(), anotherVec.end());

使用emplace()构造元素:

std::vector<std::pair<int, std::string>> vec;

// 在迭代器位置构造元素
auto it = vec.emplace(vec.begin(), 1, "one");

// 在迭代器位置之前构造多个元素
vec.emplace(it, 2, "two");

// 在迭代器位置之前构造另一个容器的元素
std::vector<std::pair<int, std::string>> anotherVec = {{3, "three"}, {4, "four"}};
vec.emplace(it, anotherVec.begin(), anotherVec.end());

需要注意的是,emplace()需要通过参数直接构造元素,而不是提供元素的值。insert是先构造临时对象在插入元素,emplace是直接构造插入元素,然后基本用法是一样的,emplace基本是支持insert的所有写法的,insert可以怎么写,emplace就可以怎么写。

10.lambda表达式

10.1C++98中的一个例子

在C++98中,如果想要对一个数据集合中的元素进行排序,可以使用stdsort方法。

int main()
{
	int array[] = { 4,1,8,5,3,7,0,9,2,6 };
	// 默认按照小于比较,排出来结果是升序
	std::sort(array, array + sizeof(array) / sizeof(array[0]));
	// 如果需要降序,需要改变元素的比较规则
	std::sort(array, array + sizeof(array) / sizeof(array[0]), greater<int>());
	return 0;
}

如果待排序元素为自定义类型,需要用户定义排序时的比较规则:

struct Goods
{
	string _name; // 名字
	double _price; // 价格
	int _evaluate; // 评价
	Goods(const char* str, double price, int evaluate)
		:_name(str)
		, _price(price)
		, _evaluate(evaluate)
	{}
};
struct ComparePriceLess
{
	bool operator()(const Goods& gl, const Goods& gr)
	{
		return gl._price < gr._price;
	}
};
struct ComparePriceGreater
{
	bool operator()(const Goods& gl, const Goods& gr)
	{
		return gl._price > gr._price;
	}
};
int main()
{
	vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2,
	3 }, { "菠萝", 1.5, 4 } };
	sort(v.begin(), v.end(), ComparePriceLess());
	sort(v.begin(), v.end(), ComparePriceGreater());
}

随着C++语法的发展,人们开始觉得上面的写法太复杂了,每次为了实现一个algorithm算法,都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,这些都给编程者带来了极大的不便。因此,在C++11语法中出现了Lambda表达式。

10.2 lambda表达式语法

lambda表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }
lambda表达式各部分说明:
1.[capture-list] : 捕捉列表,用于捕获外部变量。可以是空的,也可以包含一个或多个变量,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数捕捉列表能够捕捉上下文中的变量供lambda函数使用捕获方式可以是按值捕获([=])或按引用捕获([&]),也可以指定具体的某个变量进行捕获。
2.(parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略
3.mutable:Lambda表达式默认情况下是const的,即不能修改按值捕获的变量,而按引用捕获的变量默认是可以被修改的,不需要使用mutable关键字。如果需要在Lambda函数体内修改按值捕获的变量,可以使用mutable修饰符。使用该修饰符时,参数列表不可省略(即使参数为空)

4.->return-type:返回值类型。用于追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导
5.{statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意:
在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情。

int main()
{
	// 最简单的lambda表达式, 该lambda表达式没有任何意义
	[] {};

	// 省略参数列表和返回值类型,返回值类型由编译器推导为int
	int a = 3, b = 4;
	[=] {return a + 3; };

	// 省略了返回值类型,无返回值类型
	auto fun1 = [&](int c) {b = a + c; };
	fun1(10);
	cout << a << " " << b << endl;

	// 各部分都很完善的lambda函数
	auto fun2 = [=, &b](int c)->int {return b += a + c; };
	cout << fun2(10) << endl;

	// 复制捕捉x
	int x = 10;
	auto add_x = [x](int a) mutable { x *= 2; return a + x; };
	cout << add_x(10) << endl;

	return 0;
}

通过上述例子可以看出,lambda表达式实际上可以理解为无名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量。

捕获列表说明:
捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用
[var]:表示值传递方式捕捉变量var
[=]:表示值传递方式捕获所有父作用域中的变量(包括this)
[&var]:表示引用传递捕捉变量var
[&]:表示引用传递捕捉所有父作用域中的变量(包括this)
[this]:表示值传递方式捕捉当前的this指针
注意:
a. 父作用域指包含lambda函数的语句块
b. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割,但是[=, &]这两个不能一起使用
比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量
[&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量
c. 捕捉列表不允许变量重复传递,否则就会导致编译错误
比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复
d. 在块作用域以外的lambda函数捕捉列表必须为空
e. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,如果嵌套在多层作用域底下,被称为"嵌套捕捉"。

当定义一个嵌套的lambda函数时,它可以从外部作用域向上访问和捕捉变量,直到找到所需的变量。这意味着,嵌套的lambda函数可以访问其父作用域、祖父作用域、曾祖父作用域等的局部变量。捕捉任何非此些作用域或者非局部变量都会导致编译报错。
f. lambda表达式之间不能相互赋值,即使看起来类型相同

void (*PF)();
int main()
{
	auto f1 = [] {cout << "hello world" << endl; };
	auto f2 = [] {cout << "hello world" << endl; };
	// 此处先不解释原因,等lambda表达式底层实现原理看完后,大家就清楚了
	//f1 = f2; // 编译失败--->提示找不到operator=()
	// 允许使用一个lambda表达式拷贝构造一个新的副本
	auto f3(f2);
	f3();
	// 可以将lambda表达式赋值给相同类型的函数指针
	PF = f2;
	PF();
	return 0;
}

10.3 lambda表达式

#include <algorithm>
struct Goods
{
	string _name; // 名字
	double _price; // 价格
	int _evaluate; // 评价
	Goods(const char* str, double price, int evaluate)
		:_name(str)
		, _price(price)
		, _evaluate(evaluate)
	{}
};
struct ComparePriceLess
{
	bool operator()(const Goods& gl, const Goods& gr)
	{
		return gl._price < gr._price;
	}
};
struct ComparePriceGreater
{
	bool operator()(const Goods& gl, const Goods& gr)
	{
		return gl._evaluate > gr._evaluate;
	}
};

int main()
{
	vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };

	auto priceLess = [](const Goods& g1, const Goods& g2)->bool {return g1._price < g2._price; };
	sort(v.begin(), v.end(), priceLess);

	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)->bool {
		return g1._price > g2._price; });//按价格进行排序

	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)->bool {
		return g1._evaluate < g2._evaluate; });//按评价进行排序

	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)->bool {
		return g1._name < g2._name; });//按名字进行排序
}

上述代码就是使用C++11中的lambda表达式来解决,可以看出lambda表达式实际是一个匿名函数。

10.4函数对象与lambda表达式

函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了operator()运算符的类对象。

class Rate
{
public:
	Rate(double rate) : _rate(rate)
	{}
	double operator()(double money, int year)
	{
		return money * _rate * year;
	}
private:
	double _rate;
};
int main()
{
	// 函数对象
	double rate = 0.49;
	Rate r1(rate);
	r1(10000, 2);
	// lamber
	auto r2 = [=](double monty, int year)->double {return monty * rate * year;
	};
	r2(10000, 2);
	return 0;
}

从使用方式上来看,函数对象与lambda表达式完全一样,函数对象将rate作为其成员变量,在定义对象时给出初始值即可,lambda表达式通过捕获列表可以直接将该变量捕获到。

实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator(),因为lambda会处理成为一个类,一个类没有给成员变量就是一个空类。对于编译器生成的lambda类,其大小并不能保证一定是1个字节。类的大小取决于编译器的具体实现和lambda表达式所捕获的变量。如果lambda表达式没有捕获任何变量,那么该类的大小可能是1个字节,但如果有捕获的变量,那么类的大小将会相应增大。

这是lambda形成类的UUID,UUID(Universally Unique Identifier)是一种用于唯一标识对象或实体的标识符。UUID的生成是基于时间戳、计算机信息和随机数等因素的,因此具有一定的随机性,所以这是两个lambda不能进行互相赋值的原因,就相当于两个不同的类不能互相赋值。

11.包装器

function包装器

在C++中,有一个名为std::function的函数包装器类模板,它可以用来封装任何可调用对象(函数指针、函数对象、成员函数指针等),并提供一种统一的调用接口。std::function允许我们将函数对象作为参数传递、存储和调用。

std::function的作用是提供一种通用的方式来封装函数,并允许我们在运行时动态选择要调用的函数。它可以帮助我们实现一些特定的设计模式,比如策略模式、命令模式等。通过使用std::function,我们可以将函数作为参数传递给其他函数,也可以存储函数到容器中,或者将函数作为成员变量等。

std::function的用法如下:

#include <iostream>
#include <functional>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::function<int(int, int)> func = add;
    std::cout << func(3, 4) << std::endl;  // 输出: 7
    return 0;
}

在上面的例子中,我们定义了一个add函数来执行两个整数的相加操作。然后,我们使用std::function来声明一个名为func的对象,该对象可以调用两个整数作为输入,并返回一个整数。我们将add函数赋值给func,然后可以像调用普通函数一样调用func对象,传递参数并获得结果。

std::function的模板参数表示函数的签名类型,即参数和返回值的类型。在上面的示例中,std::function<int(int, int)>表示接受两个整数参数并返回一个整数的函数类型。我们可以根据实际情况来定义不同的函数签名类型。

除了将函数指针赋值给std::function对象外,还可以将仿函数对象、成员函数指针、lambda表达式等赋值给它。这使得std::function非常灵活,可以用于封装各种类型的可调用对象。

std::function的模板参数中,尖括号里面的三个类型表示函数的签名类型,即指定了函数的参数类型和返回值类型。

具体来说,尖括号中的三个类型指定了std::function对象可以接受的函数的参数类型和返回值类型,它们的顺序和数量需要与函数的签名保持一致。

以std::function<int(int, int)>为例:

  1. int 表示函数的返回值类型,即函数的返回值是一个整数。
  2. (int, int) 表示函数的参数类型,即函数接受两个整数作为参数。

所以,std::function<int(int, int)>表示一个接受两个整数参数并返回一个整数的函数类型。

在使用std::function时,需要根据实际函数的签名来确定模板参数的类型。如果函数有多个参数,可以使用逗号分隔它们。如果函数没有参数,则不需要在尖括号中指定参数类型。

例如,对于一个没有参数且没有返回值的函数,可以使用std::function<void()>,其中void表示函数没有返回值,()表示函数没有参数。

需要注意的是,模板参数不仅仅可以是基本类型,还可以是自定义的结构体、类等类型。只要符合函数签名的要求,都可以作为模板参数传递给std::function。

它声明了一个名为function的类模板,有两个模板参数:RetArgs

Ret是用来表示函数的返回类型的类型参数。 Args是一个可变模板参数包,用来表示函数的参数列表的类型参数

注:函数的签名是指函数的参数类型和返回值类型的组合。函数的签名用于唯一标识一个函数,不同函数的签名必须不同。

函数的签名类型是指用于表示函数签名的类型。在C++中,可以使用函数指针、函数对象、成员函数指针等不同类型来表示函数的签名。这些类型包含了函数的参数类型和返回值类型信息。

例如,对于函数 int add(int a, int b),其签名类型可以用 int(*)(int, int) 表示,其中 int(*) 表示函数指针类型,(int, int) 表示函数参数类型。

在使用一些需要函数作为参数的函数或类模板时,我们需要指定函数的签名类型作为模板参数,以告诉编译器函数的参数类型和返回值类型。这样编译器才能正确地推断函数的类型和进行函数的调用。

函数签名类型的重要性在于它提供了一种通用的方式来描述函数的参数和返回值,使得我们可以使用一种统一的方式来处理不同类型的函数,实现更灵活和可扩展的代码。(简单来说函数的签名类型指的就是函数的类型)

对函数指针、仿函数、lambda表达式进行包装:

#include<map>
#include<functional>

int f(int a, int b)
{
	cout << "int f(int a, int b)" << endl;
	return a + b;
}

struct Functor
{
public:
	int operator() (int a, int b)
	{
		cout << "int operator() (int a, int b)" << endl;

		return a + b;
	}
};

class Plus
{
public:
	Plus(int rate = 2)
		:_rate(rate)
	{}

	static int plusi(int a, int b)
	{
		return a + b;
	}

	double plusd(double a, double b)
	{
		return (a + b) * _rate;
	}

private:
	int _rate = 2;
};

int main()
{
    function<int(int, int)> f1 = f;//包装函数指针
    function<int(int, int)> f2 = Functor();//包装仿函数
    function<int(int, int)> f3 = [](int a, int b) {
        cout << "[](int a, int b) {return a + b;}" << endl;
        return a + b;
    };//包装lambda表达式

    cout << f1(1, 2) << endl;
    cout << f2(10, 20) << endl;
    cout << f3(100, 200) << endl;

    map<string, function<int(int, int)>> opFuncMap;
    opFuncMap["函数指针"] = f;
    opFuncMap["仿函数"] = Functor();
    opFuncMap["lambda"] = [](int a, int b) {
        cout << "[](int a, int b) {return a + b;}" << endl;
        return a + b;
    };
    cout << opFuncMap["lambda"](1, 2) << endl;
    cout << opFuncMap["仿函数"](5, 5) << endl;

    return 0;
}

//对类里的函数进行包装
int main()
{
	//function<int(int, int)> f1 = &Plus::plusi;//静态成员可以使用&,也可以不用
	function<int(int, int)> f1 = Plus::plusi;//静态的调用方法

	//function<double(Plus*, double, double)> f2 = &Plus::plusd;//不推荐,这里的plus*表示一个对象指针,无法直接调用下面的输出
	function<double(Plus, double, double)> f2 = &Plus::plusd;//非静态成员调用方法,这里pius表示的是&Plus::plusd调用的是plus类里面的函数

	cout << f1(1, 2) << endl;
	//cout << f2(&Plus(), 20, 20) << endl;//只能使用这个
	cout << f2(Plus(), 20, 20) << endl;//如果使用这个调用就会报错&Plus::plusd


	//Plus pl(3);
	//cout << f2(&pl, 20, 20) << endl;

	//function<double(Plus, double, double)> f2 = &Plus::plusd;

	cout << f2(Plus(), 20, 20) << endl;//包装非静态成员在调用时,需要传入一个类对象

	Plus pl(3);
	cout << f2(pl, 20, 20) << endl;

	return 0;
}

为什么需要设计function包装器类型?

设计一个函数包装器类型(如function)有以下几个优点:

  1. 泛型封装:函数包装器可以封装任意类型的可调用对象(函数指针、函数对象、lambda表达式等),使得我们可以在不考虑具体类型的情况下进行函数操作和传递。这种泛型封装可以提高代码的灵活性和可重用性。
  2. 可替代性:函数包装器可以作为函数指针的替代品,使得我们可以在运行时动态绑定不同的函数或可调用对象。这种动态绑定的能力使得代码更具有可扩展性和可配置性。
  3. 统一接口:函数包装器可以提供一个统一的接口,使得我们可以以相同的方式调用不同类型的可调用对象。这种统一接口能够简化代码,并提高代码的可读性和可维护性。
  4. 回调功能:函数包装器可以作为回调函数的载体,用于在特定事件发生时执行相应的操作。这种回调功能可以用于实现事件驱动的编程模型,例如GUI编程、网络编程等。

总之,函数包装器类型的设计可以提供更灵活、可扩展和可配置的函数操作和传递方式,使得代码更具有通用性和可重用性。

题目:. - 力扣(LeetCode)

// 使用包装器以后的玩法
class Solution {
public:
  int evalRPN(vector<string>& tokens) {
    stack<int> st;
    map<string, function<int(int, int)>> opFuncMap =
    {
    { "+", [](int i, int j) {return i + j; } },
    { "-", [](int i, int j) {return i - j; } },
    { "*", [](int i, int j) {return i * j; } },
    { "/", [](int i, int j) {return i / j; } }
    };
    for (auto& str : tokens)
    {
      if (opFuncMap.find(str) != opFuncMap.end())
      {
        int right = st.top();
        st.pop();
        int left = st.top();
        st.pop();
        st.push(opFuncMap[str](left, right));
      }
      else
      {
        // 1、atoi itoa
        // 2、sprintf scanf
        // 3、stoi to_string C++11
        st.push(stoi(str));
      }
    }
    return st.top();
  }
};

bind函数

std::bind函数定义在头文件中,是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来"适应"原对象的参数列表。一般而言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M可以大于N,但这么做没什么意义)参数的新函数。同时,使用std:.bind函数还可以实现参数顺序调整等操作。

// 原型如下:
template <class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);
// with return type (2)
template <class Ret, class Fn, class... Args>
/* unspecified */ bind(Fn&& fn, Args&&... args);

可以将bind函数看作是一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来"适应"原对象的参数列表。

调用bind的一般形式: auto newCallable = bind(callable,arg_list);(callable,它接受一个可调用对象列如函数,arg_list,它是一个参数列表,是callable函数需要使用的参数)

其中,newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表(意思就是有很多参数,每一个参数使用逗号分隔,所以叫做参数列表),对应给定的callable函数的参数。当我们调用newCallable时,newCallable会调用callable,并把参数传给它arg_list中的参数。

arg_list中的参数可能包含形如_n的名字,其中n表示一个整数,这些参数是"占位符",表示

newCallable的参数,它们占据了传递给newCallable的参数的“位置"。数值n表示生成的可调用对象中参数的位置: _1为newCallable的第一个参数,_2为第二个参数,以此类推。

std::bind 是 C++ 标准库中的一个函数模板,用于创建函数对象(即可调用对象)。它可以将函数和其参数绑定在一起,形成一个新的可调用对象。

以下是 std::bind 的各种用法、注意事项和功能示例:

  1. 绑定普通函数:
#include <functional>
#include <iostream>

void foo(int a, int b, int c) {
    std::cout << a << " + " << b << " + " << c << " = " << a + b + c << std::endl;
}

int main() {
    auto sum = std::bind(foo, 10, std::placeholders::_1, std::placeholders::_2);
    sum(20, 30); // 输出: 10 + 20 + 30 = 60
    return 0;
}

在这个例子中,std::bind 绑定了函数 foo,并将参数 10 固定在第一个位置上,然后使用占位符(std::placeholders::_1 和 std::placeholders::_2)表示在调用时使用的位置。

  1. 绑定成员函数:
#include <functional>
#include <iostream>

class Bar {
public:
    void barFunc(int a, int b) {
        std::cout << "Bar::barFunc(" << a << ", " << b << ")" << std::endl;
    }
};

int main() {
    Bar bar;
    auto func = std::bind(&Bar::barFunc, &bar, std::placeholders::_1, std::placeholders::_2);
    func(10, 20); // 输出: Bar::barFunc(10, 20)
    return 0;
}

这个例子演示了如何使用 std::bind 绑定成员函数 Bar::barFunc。需要注意的是,需要在 std::bind 中传递对象的地址 &bar 来绑定成员函数,因为第一个参数是this指针,所以要提供&bar,并使用占位符表示参数的位置。

  1. 绑定到类的成员变量:
#include <functional>
#include <iostream>

class Baz {
public:
    int x = 10;
    void printX() {
        std::cout << "Baz::x = " << x << std::endl;
    }
};

int main() {
    Baz baz;
    auto printFunc = std::bind(&Baz::printX, &baz);
    auto getXFunc = std::bind(&Baz::x, &baz);

    printFunc(); // 输出: Baz::x = 10
    std::cout << "getXFunc() = " << getXFunc() << std::endl; // 输出: getXFunc() = 10
    return 0;
}

在这个例子中,std::bind 绑定了成员函数 Baz::printX 和成员变量 Baz::x。绑定成员变量时,可以直接使用成员变量的名称进行绑定,并且在调用时不需要传递参数。

  1. 绑定到函数对象:
#include <functional>
#include <iostream>

class Adder {
public:
    int operator()(int a, int b) {
        return a + b;
    }
};

int main() {
    Adder adder;
    auto addFunc = std::bind(adder, std::placeholders::_1, std::placeholders::_2);
    int result = addFunc(10, 20); // result 等于 30
    std::cout << "result = " << result << std::endl;
    return 0;
}

这个例子中,std::bind 绑定了函数对象 Adder 的 operator(),使用占位符表示参数的位置,然后在调用时传递具体的参数。

注意事项和功能:

  • 使用 std::bind 时,需要包含 <functional> 头文件。
  • 使用占位符(std::placeholders::_1、std::placeholders::_2 等)来表示占位的参数位置。
  • 在绑定成员函数时,需要使用 & 取对象的地址进行绑定。
  • 在绑定成员变量时,直接使用成员变量的名称进行绑定。
  • std::bind 可以将多个参数绑定到函数对象,并在调用时传递剩余的参数。
  • 可以使用 std::bind 创建函数对象,它可以在需要可调用对象的地方使用,如函数指针、函数参数、STL 算法等。
  • std::bind 还支持部分应用(partial application)、函数对象适配器(function object adaptors)等高级功能。

需要注意的是,C++11 引入了更现代的函数对象绑定方式,如使用 lambda 表达式和闭包代替 std::bind。这些方式更加直观和灵活,可以更好地处理函数对象的绑定和参数传递。

注:在绑定成员函数时,确实可以使用值传递和匿名对象的方式进行绑定,而不一定需要取地址。使用值传递的方式会创建该对象的一个副本,并绑定到函数对象上。使用匿名对象的方式可以直接创建临时对象,并绑定到函数对象上。这两种方式在某些情况下可以更简洁和方便,但需要注意的是,函数对象在执行时会使用绑定时的对象,如果对象已经销毁,则会导致未定义行为。所以,在绑定成员函数时,通常建议使用对象的地址进行绑定,以确保对象的生命周期和函数对象的使用一致。

bind调整参数顺序位置:

#include <functional>
void Print(int a, int b)
{
	cout << a << " ";
	cout << b << endl;
}

int main()
{
	Print(10, 20);
	//调整参数顺序位置
	auto RPrint = bind(Print, placeholders::_2, placeholders::_1);
	RPrint(10, 20);
	//也可以使用function接受返回值
	function<void(int, int)> f = bind(Print, placeholders::_2, placeholders::_1);
	f(10, 20);

	return 0;
}

调整占位符的位置即可。

bind调整参数个数:

在 std::bind 中,您可以在绑定时传递任意数量的参数。这些参数可以是实际的值、引用、指针,或者是占位符(std::placeholders::_1, std::placeholders::_2, 等等)。通过绑定时传递参数的方式,您可以固定部分参数并支持剩余参数的灵活传递。

下面是一个示例:

#include <functional>
#include <iostream>

void printValues(int a, double b, float c) {
    std::cout << "a = " << a << ", b = " << b << ", c = " << c << std::endl;
}

int main() {
    auto boundFunc = std::bind(printValues, std::placeholders::_2, 3.14, std::placeholders::_1);
    boundFunc(100, 2.5);

    return 0;
}

在上述示例中,使用 std::bind 将 printValues 函数绑定到 boundFunc 上。在绑定时,将第二个参数绑定到占位符 _2 上,将固定值 3.14 绑定到第二个参数位置,将第一个参数绑定到占位符 _1 上。在调用 boundFunc 时,传递实际参数 100 和 2.5,将会按照绑定时的顺序依次传递给 printValues 函数,输出为 "a = 2.5, b = 3.14, c = 100"。

注:简单来说就是在绑定时显示传入参数,如果使用的是占位符则需要手动传入参数,已经显示传入的参数不需要再次进行传参,比如3.14;

12 线程库

12.1thread类的简单介绍

在C++11之前,涉及到多线程问题,都是和平台相关的,比如windows和linux下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含<thread >头文件。https://cplusplus.com/reference/thread/thread/?kw=thread

std::thread是C++11引入的标准库类,用于创建和管理线程。它位于<thread>头文件中,用于实现多线程编程。

使用std::thread可以创建一个新的线程并执行指定的函数或可调用对象。以下是std::thread类的一些重要特点和用法说明:

  1. 创建线程:
    • 可以通过传递一个可调用对象(如函数、lambda表达式、函数对象等)作为std::thread的构造函数参数来创建线程。线程将在创建时立即开始执行。
    • 可以使用detach()函数将线程与std::thread对象分离,使其在后台运行,不再与对象绑定。分离线程将独立执行,不受对象的生命周期影响。
    • 当std::thread对象被销毁时,如果线程仍然在运行且没有被分离,会导致程序终止。因此,在使用std::thread时,应该确保线程的正确管理,避免资源泄露和未定义行为。
  1. 线程同步:
    • 可以使用互斥锁(std::mutex)和条件变量(std::condition_variable)等线程同步的工具来避免竞争条件和线程间的数据竞争问题。
    • C++标准库还提供了一些其他的线程同步机制,如原子类型(std::atomic)、原子操作(std::atomic_*)、信号量(std::semaphore)等。
  1. 线程属性和管理:
    • 可以设置线程的属性,如栈大小、优先级等,来控制线程的行为。
    • 可以使用std::this_thread::yield()函数放弃当前线程的时间片,以便其他线程有机会执行。
    • 可以使用std::this_thread::sleep_for()和std::this_thread::sleep_until()等函数实现线程的延迟(休眠)。

注:时间片是操作系统调度算法中的一个概念,用于划分和分配给每个进程或线程的执行时间。在多任务环境下,操作系统将系统资源分配给多个进程或线程,每个进程或线程被分配到一个时间片,即一段固定的时间段。当一个进程或线程的时间片用完后,操作系统会将其挂起,然后切换到下一个就绪状态的进程或线程,继续分配一个时间片给它执行。这样循环往复,实现了多个进程或线程之间的并发执行。

时间片的长度取决于操作系统的调度策略和配置,不同的操作系统或调度算法可能有不同的时间片长度。通常,时间片的长度可以在操作系统的配置中进行调整,以适应不同的应用场景和需求。较长的时间片可以减少任务切换的开销,但可能导致响应时间较长;较短的时间片可以提高响应速度,但可能导致任务切换的开销增加。

时间片的存在使得多任务操作系统可以同时执行多个任务,使得用户感觉到多个任务在同时执行,提高了系统的并发性和响应能力。

函数名

功能

therad()

构造一个线程对象,没有关联任何线程函数,即没有启动任何线程

thread(fn, args1, args2, ...)

构造一个线程对象,并关联线程函数fn,args1, args2,...为线程函数的参数

get_id()

获取线程id

jionable()

线程是否还在执行, joinable代表的是一个正在执行中的线程。

jion()

该函数调用后会阻塞住当前线程,直到被调用的线程执行完毕,主线程继续执行

detach()

在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关

注:在C++中,线程有两个函数用于获取线程的ID,std::thread::get_id()std::this_thread::get_id()

  1. std::thread::get_id(): 这是一个成员函数,用于获取与特定线程对象关联的线程的ID。这个函数需要在一个已创建的std::thread对象上调用。
  2. std::this_thread::get_id(): 这是一个静态成员函数,用于获取当前线程的ID。这个函数可以在任何地方直接调用,而不需要一个特定的线程对象。

两个函数的作用都是获取线程的ID,但调用的方式略有不同。std::thread::get_id()需要在一个特定的线程对象上调用,而std::this_thread::get_id()可以直接调用,不需要特定的线程对象。

使用这两个函数的场景也不同。std::thread::get_id()通常用于在多线程编程中,判断两个线程是否相等,或者获取特定线程的ID进行比较。std::this_thread::get_id()通常用于获取当前线程的ID,用于一些线程相关的操作或记录。

需要注意的是,线程的ID是一个唯一的标识符,但它的实际值或表示方式在不同的平台和编译器可能有所不同。因此,对于特定的应用程序或平台,可能需要根据实际情况来处理和比较线程的ID。

注意:

1.线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态。

2.当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。

#include <thread>
int main()
{
	std::thread t1;
	cout << t1.get_id() << endl;
	return 0;
}

get_id()的返回值类型为id类型,id类型实际为std::thread命名空间下封装的一个类,该类中包含了一个结构体:

// vs下查看
typedef struct
{ /* thread identifier for Win32 */
	void* _Hnd; /* Win32 HANDLE */
	unsigned int _Id;
} _Thrd_imp_t;

3.当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照以下三种方式提供:

  • 函数指针
  • lambda表达式
  • 函数对象
#include <iostream>
using namespace std;
#include <thread>
void ThreadFunc(int a)
{
    cout << "Thread1" << a << endl;
}
class TF
{
public:
    void operator()()// 仿函数 -- 重载()
    {
        cout << "Thread3" << endl;
    }
};
int main()
{
    // 线程关联函数为函数指针
    thread t1(ThreadFunc, 10);
    // 线程关联函数为lambda表达式
    thread t2([] {cout << "Thread2" << endl; });
    // 线程关联函数为函数对象
    TF tf;
    thread t3(tf);
    t1.join();
    t2.join();
    t3.join();
    cout << "Main thread!" << endl;
    return 0;
}

4.thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,在使用移动构造函数或移动赋值操作符将线程对象的状态从一个对象转移到另一个对象时,并不会保持线程的执行状态。

include <iostream>
#include <thread>

void print_numbers() {
	for (int i = 1; i <= 5; i++) {
		std::cout << i << std::endl;
	}
}

int main() {
	std::thread t1(print_numbers);
	std::thread t2 = std::move(t1); // 移动构造

	t1 = std::thread();
	t1 = std::move(t2); // 移动赋值

	t1.join();

	return 0;
}

我们创建了一个线程t1,通过移动构造将其状态转移到线程t2上。然后,我们使用std::move()将t2的状态转移到t1上,实现了移动赋值。最后,我们调用t1.join()等待t1执行完毕。

需要注意的是,移动构造和移动赋值后,原来的线程对象将处于无效状态。在移动赋值之后,可以将新的线程对象赋值给原来的线程对象,以便对其进行新的操作或等待。但是需要注意线程对象转移后的状态,避免使用无效的线程对象调用成员函数导致未定义行为。

5.可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效

  • 采用无参构造函数构造的线程对象
  • 线程对象的状态已经转移给其他线程对象
  • 线程已经调用jion或者detach结束

测试题:并发与并行的区别?

在C++中,线程的并发(Concurrency)和并行(Parallelism)是两个重要的概念,它们描述了多线程执行的方式和特性。

并发是指多个线程在同一时间段内执行,它们可以以任意顺序交替运行。并发旨在提高系统的效率和资源利用率,通过将任务划分为多个部分并让多个线程同时执行,从而实现并行处理的效果。

与并发不同,并行是指多个线程在同一时间点上同时执行,每个线程在不同的处理器核心上独立运行。并行的目标是通过并发执行来加快任务的完成速度,实现更高的吞吐量和更低的响应时间。

总结起来,主要区别如下:

  • 并发是多个线程在同一时间段内执行,可以以任意顺序交替运行。
  • 并行是多个线程在同一时间点上同时执行,每个线程在独立的处理器核心上运行。
  • 并发旨在提高系统效率和资源利用率。
  • 并行旨在加快任务的完成速度,提高吞吐量和降低响应时间。

需要注意的是,并行需要硬件上的支持,即多核处理器或多个处理器。在单核处理器上,虽然可以通过并发来模拟并行,但实际上是串行执行的。而在具备多核处理能力的系统上,可以真正实现并行执行。

创建单个线程:

#include <iostream>
#include <thread>

// 线程函数
void threadFunction() {
    // 在新线程中执行的代码
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    // 创建新线程并启动
    std::thread myThread(threadFunction);

    // 等待线程执行完毕
    myThread.join();

    std::cout << "Main thread exiting" << std::endl;

    return 0;
}

创建多个线程:

#include <iostream>
#include <thread>

void print_numbers() {
	for (int i = 1; i <= 5; i++) {
		std::cout << i << std::endl;
	}
}

void print_letters() {
	for (char letter = 'A'; letter <= 'E'; letter++) {
		std::cout << letter << std::endl;
	}
}

int main() {
	std::thread t1(print_numbers);
	std::thread t2(print_letters);

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

	return 0;
}

创建n个线程:

#include <iostream>
#include <thread>
#include <vector>

void print_numbers(int n) {
	for (int i = 1; i <= n; i++) {
		std::cout << i << std::endl;
	}
}

int main() {
	int num_threads = 5;
	std::vector<std::thread> threads;

	for (int i = 0; i < num_threads; i++) {
		threads.push_back(std::thread(print_numbers, 5));
	}

	for (auto& thread : threads) {
		thread.join();
	}

	return 0;
}

我们定义了一个函数print_numbers,用于打印从1到n的数字。然后,我们使用一个循环来创建n个线程,并将它们存储在一个std::vector容器中。每个线程都会调用print_numbers函数,并传入相同的参数n。最后,我们使用另一个循环来等待每个线程执行完毕。

需要注意的是,在使用std::vector保存线程对象时,我们需要使用引用来避免复制线程对象。另外,线程的创建顺序不代表它们的执行顺序,因此不保证线程的输出顺序。

12.2线程函数参数

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参

注:线程函数指的是传给线程的函数,简称线程函数。

#include <thread>
void ThreadFunc1(int& x)
{
	x += 10;
}
void ThreadFunc2(int* x)
{
	*x += 10;
}
int main()
{
	int a = 10;
	// 在线程函数中对a修改,不会影响外部实参,因为:线程函数参数虽然是引用方式,但其实际
	//引用的是线程栈中的拷贝
	thread t1(ThreadFunc1, a);
	t1.join();
	cout << a << endl;
	// 如果想要通过形参改变外部实参时,必须借助std::ref()函数
	thread t2(ThreadFunc1, std::ref(a);
	t2.join();
	cout << a << endl;
	// 地址的拷贝
	thread t3(ThreadFunc2, &a);
	t3.join();
	cout << a << endl;
	return 0;
}

注:如果是类成员函数作为线程参数时,必须将this作为线程函数参数。

假设有一个名为MyClass的类,其中有一个成员函数doSomething(),我们希望将该函数作为线程函数,并在启动线程时将类的实例对象作为参数传递给线程函数。

#include <iostream>
#include <thread>

class MyClass {
public:
    void doSomething() {
        // 在此处编写需要执行的代码
        std::cout << "Executing doSomething() in a thread" << std::endl;
    }
};

int main() {
    MyClass obj;

    // 创建一个线程,并将当前对象的指针传递给线程函数
    std::thread t(&MyClass::doSomething, &obj);

    // 等待线程结束
    t.join();

    return 0;
}

在上述示例中,我们首先创建了一个MyClass对象obj,然后通过std::thread类的构造函数创建了一个线程t,将&MyClass::doSomething作为线程函数参数,表示我们要将MyClass的成员函数doSomething()作为线程函数。然后,我们使用&obj将类的实例对象作为参数传递给线程函数,以便在线程函数中可以访问和操作该对象。最后,我们使用t.join()等待线程结束。在线程函数中,我们可以通过this指针访问和操作对象的成员,实现了在多个线程中同时操作同一个对象的目的。

12.3原子操作库

多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。比如:

#include <iostream>
using namespace std;
#include <thread>

unsigned long sum = 0L;

void fun(size_t num)
{
	for (size_t i = 0; i < num; ++i)
		sum++;
}

int main()
{
	cout << "Before joining,sum = " << sum << std::endl;
	thread t1(fun, 10000000);
	thread t2(fun, 10000000);
	t1.join();
	t2.join();
	cout << "After joining,sum = " << sum << std::endl;
	return 0;
}

在只读操作的情况下,多个线程可以同时访问共享数据,因为只读操作不会改变数据的状态,因此不会产生数据一致性的问题。

但是,在涉及到修改共享数据的操作时,问题就变得复杂了。如果多个线程同时对共享数据进行写操作,可能会导致数据的不一致性。比如,一个线程正在修改数据,而另一个线程同时读取该数据,那么读取到的数据可能是未修改或部分修改的数据,从而导致程序错误。

为了保证线程安全,可以采取一些措施,例如:

  1. 互斥锁(Mutex):使用互斥锁来保护共享数据,确保同一时间只有一个线程可以访问或修改共享数据,其他线程需要等待锁释放后才能继续执行。这样可以避免多个线程同时修改共享数据的问题。
  2. 原子操作(Atomic operations):使用原子操作来进行数据的读取和修改。原子操作是一种不会被其他线程中断的操作,可以确保数据的一致性。
  3. 条件变量(Condition variables):使用条件变量来实现线程的同步。通过条件变量,线程可以在某个条件满足时进行等待,直到其他线程发出信号后才继续执行。
  4. 同步机制(Synchronization primitives):如信号量(Semaphore)、读写锁(Read-Write Lock)等,用于控制线程的并发访问。

总之,确保共享数据的线程安全是多线程编程中需要重点考虑的问题,需要通过合适的同步机制和线程间的通信来保护共享数据的一致性。

C++98中传统的解决方式:可以对共享修改的数据可以加锁保护。

#include <iostream>
using namespace std;
#include <thread>
#include <mutex>

std::mutex m;//定义一个互斥锁,而且,这个锁要定义在全局,因为每一个线程都有独立的栈,如果定义
//在函数内,就相当于每一个栈都有独立的锁,所以要定义在全局
unsigned long sum = 0L;

void fun(size_t num)
{
    m.lock();//加锁,串行
	for (size_t i = 0; i < num; ++i)
	{
		//m.lock();//并行
		sum++;//被加锁的代码
		//m.unlock();//并行

        //假设执行其他代码.....
	}
    m.unlock();//解锁,串行
    //如果执行的代码比较少的话建议把加锁和解锁放在外面可以提高效率,因为放在里面会频繁
    //的加锁和解锁,导致效率降低,如果代码比较多的话就放在里面,比如加锁一部分代码,其中
    //一个线程在执行加锁中的代码,那么另一个线程就可以去执行未被加锁的代码,可以提高效率
    //所以放在外面还是里面根据情况而定,放在外面叫做串行,里面叫做并行。
}

int main()
{
	cout << "Before joining,sum = " << sum << std::endl;

	thread t1(fun, 10000000);
	thread t2(fun, 10000000);
	t1.join();
	t2.join();

	cout << "After joining,sum = " << sum << std::endl;
	return 0;
}

在第一个代码中,两个线程同时对sum进行自增操作,由于没有对共享数据进行同步,可能会出现以下情况:

  1. 线程1读取sum的值为0,然后执行自增操作sum++。
  2. 线程2读取sum的值为0,然后执行自增操作sum++。
  3. 线程1和线程2的自增操作都完成,将结果写回到sum,由于两个操作同时完成,可能导致其中一个操作被覆盖,从而导致sum的值只增加了1。

因此,由于竞争条件的存在,输出结果可能小于预期的20000000。

在第二个代码中,引入了互斥锁m对共享数据进行保护。两个线程在修改sum之前都需要先获取锁,以保证同一时间只有一个线程访问和修改共享数据。具体来说:

  1. 线程1获取锁并执行自增操作。
  2. 线程2尝试获取锁,由于锁已经被线程1获取,线程2会等待。
  3. 线程1完成自增操作并释放锁。
  4. 线程2获取到锁并执行自增操作。
  5. 线程2完成自增操作并释放锁。

通过互斥锁的使用,保证了线程1和线程2对共享数据的安全访问,输出结果会等于预期的20000000。

总之,没有对共享数据进行适当的同步和保护,在多线程并发访问的场景下,可能会引发竞争条件和数据不一致的问题。使用互斥锁等同步机制可以有效解决这类问题。

虽然加锁可以解决,但是加锁有一个缺陷就是:只要一个线程在对sum++时,其他线程就会被阻塞,会影响程序运行的效率,而且锁如果控制不好,还容易造成死锁。

12.4mutex的种类

在C++11中,mutex总共有四个互斥量的种类:

  • std::mutex:

C++11提供的最基本的互斥量,该类的对象之间不能拷贝,也不能进行移动。mutex最常用的三个函数:

函数名

函数功能

lock()

上锁:锁住互斥量

unlock()

解锁:释放对互斥量的所有权

try_lock()

尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞

注意,线程函数调用lock()时,可能会发生以下三种情况:

如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一直拥有该锁

如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住

如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)

线程函数调用try_lock()时,可能会发生以下三种情况:

如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用unlock释放互斥量

如果当前互斥量被其他线程锁住,则当前调用线程返回false,而并不会被阻塞掉

    • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)
  • std::recursive_mutex:

其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的unlock(),除此之外,

stdrecursive_mutex的特性和std:mutex大致相同。

  • std::timed_mutex:

比std;mutex多了两个成员函数,try_lock_for(),try_lock_until()。

try_lock_for()

接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与std::mutex的try_lock()不同,try_lock如果被调用时没有获得锁则直接返回

false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回false。

try_lock_until()

接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回false。

  • std::recursive_timed_mutex

因此C++11中引入了原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入的原子操作类型,使得线程间数据的同步变得非常高效。

注意:需要使用以上原子操作变量时,必须添加头文件

#include <iostream>
using namespace std;
#include <thread>
#include <atomic>

atomic_long sum{ 0 };//定义原子类型
//atomic<int> sum{ 0 };//其中一种写法
void fun(size_t num)
{
    for (size_t i = 0; i < num; ++i)
        sum++; // 原子操作
}

int main()
{
    cout << "Before joining, sum = " << sum << std::endl;
    thread t1(fun, 1000000);
    thread t2(fun, 1000000);
    t1.join();
    t2.join();
    cout << "After joining, sum = " << sum << std::endl;
    return 0;
}

12.5atomic类型(原子类型)

在C++11中,程序员不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问。

更为普遍的,程序员可以使用atomic类模板,定义出需要的任意原子类型。

atmoic<T> t; // 声明一个类型为T的原子类型变量t

注意:原子类型通常属于"资源型"数据,多个线程只能访问单个原子类型的拷贝,因此在C++11中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及operator=等,为了防止意外,标准库已经将atmoic模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。

注:"资源型"数据通常指的是在多线程环境下会被多个线程同时访问和修改的数据。这些数据通常具有共享性,并且需要保证在并发情况下的线程安全性。原子类型就是一种常见的用于处理资源型数据的数据类型。

在 C++ 中,std::atomic 类型是用来进行原子操作的一种机制,它能够确保在多线程环境中对共享变量的操作是原子的,即不会被线程调度器中断,从而避免了竞态条件和数据竞争的问题。

std::atomic 提供了一系列的操作,如加载、存储、交换等,这些操作保证了多线程环境中的数据同步和一致性。

以下是一些常见的 std::atomic 操作及其用法:

  1. 加载操作(load):从原子变量中加载值,确保获取到的值是最新的。
std::atomic<int> atomicInt(0);
int value = atomicInt.load();
  1. 存储操作(store):将值存储到原子变量中,确保存储的值是原子的。
std::atomic<int> atomicInt;
atomicInt.store(10);
  1. 交换操作(exchange):交换原子变量的值,并返回交换前的值。
std::atomic<int> atomicInt(0);
int oldValue = atomicInt.exchange(5);
  1. 比较交换操作(compare_exchange_weak 或 compare_exchange_strong):比较原子变量的值和期望值,如果相等则用新值替换,并返回替换前的值。这个操作有两种形式,分别是弱(weak)和强(strong)版本,区别在于对于失败情况下的重试策略。
std::atomic<int> atomicInt(0);
int expected = 0;
int newValue = 10;
atomicInt.compare_exchange_weak(expected, newValue);
  1. 原子操作(fetch_add、fetch_sub、fetch_and、fetch_or、fetch_xor 等):对原子变量进行特定操作,并返回操作前的值。
std::atomic<int> atomicInt(0);
int oldValue = atomicInt.fetch_add(5); // oldValue = 0, atomicInt = 5

这些操作能够确保在多线程环境中对共享变量的操作是原子的,不会发生竞态条件和数据竞争。但需要注意,使用 std::atomic 并不能解决所有的并发问题,仍然需要谨慎设计并发逻辑,以确保线程安全性。

12.5 lock_guard与unique_lock

std::lock_guard和std::unique_lock都是C++中用于保护临界区的互斥锁(mutex)的RAII(资源获取即初始化)类模板。它们的作用是在构造时获取锁,在析构时释放锁,以确保临界区的互斥访问。

std::lock_guard是一个轻量级的互斥锁包装器,它提供了一种简单的方式来管理互斥锁,但功能相对较少。它的构造函数接受一个互斥锁对象,并在构造时调用互斥锁的lock()方法获取锁,在析构时自动调用互斥锁的unlock()方法释放锁。由于std::lock_guard对象不能手动解锁,因此它不能在临界区内部进行条件变量的等待和通知操作。

下面是一个使用std::lock_guard的示例:

#include <iostream>
#include <mutex>
#include <thread>

int x = 0;
std::mutex mtx;

void Func(int n) {
    for (int i = 0; i < n; i++) {
        std::lock_guard<std::mutex> lock(mtx);
        ++x;  // 在临界区内对共享变量进行操作
    }
}

int main() {
    std::thread t1(Func, 100000);
    std::thread t2(Func, 100000);

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

    std::cout << x << std::endl;

    return 0;
}

注:std::lock_guard<std::mutex>的作用是在其所在的作用域内对std::mutex进行自动的加锁和解锁操作。当std::lock_guard的对象被创建时,会调用std::mutex的lock方法进行加锁,当对象被销毁时,会自动调用std::mutex的unlock方法进行解锁,因此,std::lock_guard<std::mutex>创建的锁的作用域是从创建锁的那一行开始,到std::lock_guard对象的作用域结束之间的所有代码。在这个作用域内的所有代码,只有在获取到锁之后才能执行,保证了对临界区的互斥访问。一旦超过了std::lock_guard对象的作用域,锁会在其析构函数中自动释放,允许其他线程再次获取锁。

std::unique_lock相比于std::lock_guard更加灵活,它提供了更多的功能和控制选项。与std::lock_guard不同的是,std::unique_lock对象可以手动地锁定和解锁互斥锁,并且可以在临界区中进行条件变量的等待和通知。

下面是一个使用std::unique_lock和条件变量的示例:

#include <iostream>
#include <mutex>
#include <thread>
#include <condition_variable>

int x = 0;
std::mutex mtx;
std::condition_variable cv;

void Func(int n) {
    for (int i = 0; i < n; i++) {
        std::unique_lock<std::mutex> lock(mtx);
        ++x;  // 在临界区内对共享变量进行操作

        if (x % 10 == 0) {
            cv.notify_all();  // 每当x的值是10的倍数时,唤醒等待中的线程
        }
    }
}

void WaitAndPrint() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []() { return x % 10 == 0; });  // 等待x的值是10的倍数
    std::cout << "x = " << x << std::endl;
}

int main() {
    std::thread t1(Func, 100);
    std::thread t2(WaitAndPrint);

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

    return 0;
}

在上面的示例中,Func函数使用std::unique_lock对互斥锁进行加锁,在临界区内对共享变量x进行操作。每当x的值是10的倍数时,通过条件变量cv通知等待中的线程被唤醒。

WaitAndPrint函数使用std::unique_lock进行加锁,并调用cv.wait来等待x的值是10的倍数。当满足条件时,打印出当前x的值。

通过使用std::unique_lock和条件变量,我们可以更加灵活地控制临界区的访问和线程间的同步。

unique_lock比lock_guard更灵活并且也支持自动加锁和自动解锁,可以支持更多的功能

与lock_guard相比,unique_lock支持以下功能:

  1. 所有权转移:unique_lock可以在构造函数中传入std::adopt_lock参数来接管已经上锁的mutex。
  2. 可以手动上锁和解锁:unique_lock提供了lock()和unlock()方法,可以手动地上锁和解锁mutex。
  3. 条件变量的支持:unique_lock可以与条件变量一起使用,比如在等待条件时自动释放锁,并在条件满足时重新上锁。
  4. 可以延迟上锁:unique_lock指定延迟上锁,可以在需要时上锁,而不是在构造函数中就上锁。
  5. 可以灵活地指定锁的类型:unique_lock可以使用不同的锁类型,比如std::mutex、std::shared_mutex等。

总之,unique_lock提供了比lock_guard更多的功能,更加灵活,可以根据需要来选择使用。

注:临界区是多线程编程中的一个概念,指的是一段代码或一段程序片段,在执行过程中访问共享资源(如共享变量、共享数据结构等)的部分。

在并发编程中,多个线程可以同时访问共享资源,如果多个线程在没有适当的同步机制的情况下同时对共享资源进行读写操作,可能会导致数据不一致或竞争条件等问题。临界区的作用就是为了保护共享资源,确保在任意时刻只有一个线程可以进入临界区进行访问,从而避免了并发访问带来的问题。

通过使用互斥锁(mutex)、信号量(semaphore)、条件变量(condition variable)等同步机制,我们可以在进入临界区之前进行加锁操作,以确保只有一个线程可以进入临界区。一旦进入临界区,线程可以对共享资源进行操作,执行特定的任务。在临界区操作完成后,线程释放锁,允许其他线程进入临界区。

临界区的正确管理和同步对于多线程程序的正确性和性能至关重要。合理地划分和保护临界区,以确保每个线程在访问共享资源时是互斥的,可以避免并发问题和数据竞争,提高程序的可靠性和并发性能。

condition_variable:

condition_variable是C++标准库中的一个类,用于线程间的同步和通信。其主要作用是在多个线程之间实现对共享数据的等待和通知机制,用于解决线程之间的协作问题。

condition_variable的作用可以总结为以下几点:

  1. 等待条件:线程可以通过调用condition_variable的wait()函数来等待某个条件满足。如果条件不满足,线程将被阻塞,直到其他线程调用notify_one()或notify_all()函数通知该条件已经满足。(false就wait阻塞,true就wait不阻塞)
  2. 通知条件:线程可以通过调用condition_variable的notify_one()或notify_all()函数来通知其他等待中的线程条件已经满足,从而唤醒等待线程继续执行。
  3. 避免竞争条件:condition_variable通常和unique_lock配合使用,可以通过上锁和解锁的方式来避免多个线程同时访问共享数据而出现竞争条件。使用condition_variable可以实现线程间的互斥访问共享数据,以确保数据的正确性。

注:阻塞和唤醒是针对线程的状态而言,而加锁和解锁是针对互斥量的操作。

当线程执行unique_lock<mutex> lock(mtx);这样的语句时,它会尝试获取互斥量的所有权并加锁,这个过程将会阻塞线程,直到成功获取互斥量的所有权,也就是加锁成功。

而当线程被条件变量的wait()函数阻塞时,它会释放之前持有的互斥量的所有权,进入等待状态。当其他线程调用条件变量的notify_one()或notify_all()函数时,被阻塞的线程会被唤醒并尝试重新获取互斥量的所有权。在这个过程中,互斥量的加锁和解锁是由线程自动完成的。

所以阻塞并不等同于加锁,唤醒也不等同于解锁。阻塞是线程等待某个条件满足的过程,而加锁和解锁是对互斥量进行操作来确保线程安全性的机制。

12.5.1 lock_guard

std::lock_gurad是C++11中定义的模板类。定义如下:

template<class _Mutex>
class lock_guard
{
public:
// 在构造lock_gard时,_Mtx还没有被上锁
explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{
    _MyMutex.lock();
}
// 在构造lock_gard时,_Mtx已经被上锁,此处不需要再上锁
lock_guard(_Mutex& _Mtx, adopt_lock_t tag)
: _MyMutex(_Mtx)
{}
~lock_guard() _NOEXCEPT
{
    _MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};

这是 std::lock_guard 类的实现代码,它是 C++ 标准库提供的一个 RAII(Resource Acquisition Is Initialization)封装,用于对互斥量进行加锁和解锁的操作。

std::lock_guard 在构造时会自动获取传入的互斥量的所有权并对其进行加锁,而在析构时会自动解锁互斥量。这样可以确保在任何情况下,当 std::lock_guard 对象离开作用域时,互斥量都会被正确解锁,从而避免了忘记解锁的问题。

std::lock_guard 实现了两个构造函数:

  1. explicit lock_guard(_Mutex& _Mtx):这个构造函数在构造 std::lock_guard 对象时,会获取 _Mtx 互斥量的所有权并进行加锁。通常用于未上锁的互斥量。
  2. lock_guard(_Mutex& _Mtx, adopt_lock_t):这个构造函数在构造 std::lock_guard 对象时,假设 _Mtx 互斥量已经被上锁。此时不需要再进行加锁操作,只需要保存 _Mtx 的引用即可。通常在某些场景下,比如互斥量的所有权已经在其他地方被获取并上锁时,可以使用该构造函数。

std::lock_guard 的析构函数会自动对互斥量进行解锁操作,确保在 std::lock_guard 对象离开作用域时,互斥量被正确解锁。

注意,std::lock_guard 的拷贝构造函数和赋值运算符被删除,意味着不允许拷贝构造或赋值 std::lock_guard 对象,这是为了避免多个 std::lock_guard 对象对同一个互斥量进行重复解锁的问题。

12.5.2 unique_lock

与lock_gard类似,unique_lock类模板也是采用RAlI的方式对锁进行了封装,并且也是以独占所有权的方式管理mutex对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock对象需要传递一个 Mutex对象作为它的参数,新创建的

unique_lock对象负责传入的Mutex对象的上锁和解锁操作。使用以上类型互斥量实例化

unique_lock的对象时,自动调用构造函数上锁,unique_lock对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。

与lock_guard不同的是,unique_lock更加的灵活,提供了更多的成员函数:

上锁/解锁操作: lock、try_lock、try_lock_for、try_lock_until和unlock

修改操作︰移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)

获取属性: owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。

12.6支持两个线程交替打印,一个打印奇数,一个打印偶数

#include <iostream>
#include <mutex>
#include <thread>
#include <condition_variable>

 //支持两个线程交替打印,t1打印奇数,t2一个打印偶数
int main()
{
	mutex mtx;
	condition_variable cv;

	int n = 100;
	int x = 2;

	// 问题1:如何保证t1先运行,t2阻塞?
    //分析:
	//1: t1先抢到锁, t2后抢到锁t1先运行,t2阻塞在锁上面
	//2: t2先抢到锁,t1后抢到锁t2先运行,t1阻塞在锁上面。但是t2会被下一步wait阻塞。
	//并且wait时会解锁,保证了t1先运行

	// 问题2:如何防止一个线程不断运行?
	//为什么要防止一个线程连续打印?
	//假设t1,先获取到锁,t2后获取到锁,t2阻塞在锁上面
	//t1打印奇数,++x,x变成偶数
	//t1 notify,但是没有线程wait
	//t1 lock出了作用域解锁,t1时间片到了,切出去了
	//t2获取到锁,打印,notity,但是没有线程等待lock出作用域,解锁。
	//假设t2的时间片充足再次获取到锁,如果没有条件控制,就会导致,t2连续打印

    
	thread t1([&, n]() {
		while (1)
		{
			unique_lock<mutex> lock(mtx);
			if (x >= 100)
				break;

			//if (x % 2 == 0) // 偶数就阻塞
			//{
			//	cv.wait(lock);
			//}
			cv.wait(lock, [&x]() {return x % 2 != 0; });// 偶数就阻塞

			cout << this_thread::get_id() << ":" << x << endl;// 打印奇数
			++x;

			cv.notify_one();//唤醒其他被阻塞的线程
		}
		});

	thread t2([&, n]() {
		while (1)
		{
			unique_lock<mutex> lock(mtx);
			if (x > 100)
				break;

			//if (x % 2 != 0) // 奇数就阻塞
			//{
			//	cv.wait(lock);
			//}
			cv.wait(lock, [&x](){return x % 2 == 0; });


			cout << this_thread::get_id() << ":" << x << endl;
			++x;

			cv.notify_one();
		}
		});

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

	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值