前言:其实学习C++重头就是STL,我们上篇博客过后就基本讲完了STL里的容器。现在的公司很多都开始使用C++11了,而C++的版本是基本向前兼容的,我们今天要学习一些C++11里面的重要的新语法,与STL关联不大,大家可以放心学。

目录

一、C++11的发展历程

二、列表初始化

1. C++98中的{ }

2. C++11中的{ }

3. initializer_list

三、右值引用与移动语义

1. 左值与右值

2. 左值引用与右值引用

3. 右值引用延长生命周期

4. 左值和右值的参数匹配

5. 类型分类

6. 移动语义(重点)

(1)左值引用的回顾

(2)移动构造和移动赋值

(3)右值引用和移动语义解决传值返回问题

1> 右值对象构造,只有拷⻉构造,没有移动构造的场景  

2> 右值对象构造,有拷⻉构造,也有移动构造的场景  

3> 右值对象赋值,只有拷⻉构造和拷⻉赋值,没有移动构造和移动赋值的场景

4> 右值对象赋值,既有拷⻉构造和拷⻉赋值,也有移动构造和移动赋值的场景 

(4)STL的变化

7. 引用折叠

8. 完美转发

四、可变参数模板

1. 基本语法

2. 原理

3. 参数包扩展

4. 实际应用

(1)完美转发

(2)emplace类型接口

五、新的类功能

1. 默认的移动构造和移动赋值

2. 成员变量声明时给缺省值

3. default和delete

4. final与override

六、lambda表达式

1. lambda表达式语法

2. 捕捉列表

3. lambda的应⽤

4. lambda的原理

七、包装器

1. function

(1)整体概述

(2)基本使用规则

(3)成员函数的包装

2. bind



一、C++11的发展历程

       C++11 最初被称为 “C++0x”,原计划在 2010 年前发布,最终于 2011 年 8 月 12 日被 ISO 采纳。它是 C++ 历史上最重要的更新之一,标志着 C++ 进入现代化时代。此后,C++ 进入每三年发布一个新标准的节奏(C++14、C++17、C++20、C++23)。

二、列表初始化

1. C++98中的{ }

C++98 中,数组和结构体可以使用 { } 初始化:

int array1[] = { 1, 2, 3, 4, 5 };
Point p = { 1, 2 };

2. C++11中的{ }

  • C++11以后想统一初始化方式,试图实现一切对象皆可用{}初始化,{}初始化也叫做列表初始化。
  • 内置类型支持,自定义类型也支持,自定义类型本质是类型转换,中间会产生临时对象,最后优化了以后变成直接构造。
  • {}初始化的过程中,可以省略掉=
  • C++11列表初始化的本意是想实现一个大统一的初始化方式,其次他在有些场景下带来的不少便利,如容器push/insert多参数构造的对象时,{}初始化会很方便
void test1()
{
	int x1 = { 2 };
	int x2{ 2 };
	Date d1 = { 2025, 1, 1 };
	// 这里d2引用的是{ 2024, 7, 25 }构造的临时对象 
	const Date& d2 = { 2024, 7, 25 };
	//C++98支持单参数时类型转换,也可以不用{}
	Date d3 = 2025;
	Date d4{ 2024, 7, 25 };
	const Date& d5{ 2024, 7, 25 };
	Point p{ 1, 2 };
	// 不支持,只有{}初始化,才能省略= 
	// Date d8 2025;

	vector<Date> v;
	v.push_back(d1);
	v.push_back(Date(2025, 1, 1));
	// 比起有名对象和匿名对象传参,这里{}更有性价比
	v.push_back({ 2025, 1, 1 });
}

3. initializer_list

这个已经在“链表奇遇记:从零揭秘C++ list的魔法世界”讲过了,忘了可以复习一下

//vector用initializer_list有三种初始化方式
vector<Date> v1 = { {2025,1,1},{2025,1,2},{2025,3,4} };
vector<Date> v2({ {2025,1,1},{2025,1,2},{2025,3,4} });
vector<Date> v3{ {2025,1,1},{2025,1,2},{2025,3,4} };

大家明白每个括号的含义吗?

三、右值引用与移动语义

(这个学起来真的很恶心!!!!!)

1. 左值与右值

  • 左值是一个表示数据的表达式(如变量名或解引用的指针),一般是有持久状态,存储在内存中,我们可以获取它的地址,左值可以出现在赋值符号的左边,也可以出现在赋值符号右边。定义时const修饰符后的左值,不能给它赋值,但是可以取它的地址。
  • 右值也是一个表示数据的表达式,要么是字面值常量、要么是表达式求值过程中创建的临时对象等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址。
  • 值得一提的是,左值的英文简写为lvalue,右值的英文简写为rvalue。传统认为它们分别是left value、right value的缩写。现代C++中,lvalue被解释为locator value的缩写,可意为存储在内存中、有明确存储地址可以取地址的对象,而rvalue被解释为read value,指的是那些可以提供数据值,但是不可以寻址,例如:临时变量,字面量常量,存储于寄存器中的变量等,也就是说左值和右值的核心区别就是能否取地址。
// 左值:可以取地址 
// 以下的p、b、c、*p、s、s[0]就是常见的左值 
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';
cout << &c << endl;
cout << (void*)&s[0] << endl;
// 右值:不能取地址 
double x = 1.1, y = 2.2;
10;
x + y;
fmin(x, y);
string("11111");
//cout << &10 << endl;  报错

2. 左值引用与右值引用

       左值引用就是给左值取别名,右值引用就是给右值取别名。需要注意的是变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量表达式的属性是左值,而左值是可以被修改的。

//左值引用
int* p = new int(0);
int b = 1;
string s("111111");

int& r1 = b;
int*& r2 = p;
string& r3 = s;

//右值引用
double x = 1.1, y = 2.2;
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
string&& rr4 = string("11111");

       左值引用不能直接引用右值,但是const左值引用可以引用右值,右值引用不能直接引用左值,但是右值引用可以引用move(左值)。

// 左值引⽤不能直接引用右值,但是const左值引用可以引用右值 
 const int& rx1 = 10;
 const double& rx2 = x + y;
 const double& rx3 = fmin(x, y);
 const string& rx4 = string("11111");
 // 右值引用不能直接引用左值,但是右值引用可以引用move(左值) 
 int&& rrx1 = move(b);
 int*&& rrx2 = move(p);
 int&& rrx3 = move(*p);
 string&& rrx4 = move(s);
 string&& rrx5 = (string&&)s;

template <class T> typename remove_reference<T>::type&& move (T&& arg);move是库里面的一个函数模板,本质内部是进行强制类型转换,当然他还涉及一些引用折叠的知识,这个我们后面会细讲。)

3. 右值引用延长生命周期

string s1 = "test";

我们依次看三句代码:

string s2 = s1 + s1;

变量类型:string 对象(左值)
构造过程:

  • s1 + s1 表达式产生临时string对象(在栈上)
  • 临时对象在堆上分配内存存储 "testtest"
  • 从临时对象构造 s2(可能调用移动构造或拷贝构造)
  • 临时对象被销毁
string& s3 = s2;

变量类型:左值引用(s2 的别名)
存储位置:引用本身在栈上,但不包含数据
关键特性:

  • s3 只是 s2 的另一个名字
  • 对 s3 的任何操作都直接影响 s2
  • 不创建新对象,不分配新内存
string&& s4 = s1 + s1;
s4 += "Test"; 

变量类型:右值引用
关键特性:

  • s1 + s1 产生临时string对象(在栈上)
  • 临时对象在堆上分配内存存储 "testtest"
  • s4 绑定到这个临时对象(只是别名)
  • 生命周期延长:临时对象不会立即销毁,生命周期延长到 s4 的作用域(本来它的生命周期只有一行)
  • s4 有了左值属性,可以被修改,或者取地址
const string& s5 = s1 + s1; 

也延长了临时变量的生命周期,但是 s5 不能被修改。

( 语法层面看,左值引用和右值引用都是取别名,不开空间。从汇编底层的角度看,都是用指针实现的,没什么区别。底层汇编等实现和上层语法表达的意义有时是背离的,所以不要混到一起理解,互相佐证,这样反而是陷入迷途。所以不要深究为什么s4能取地址。)

4. 左值和右值的参数匹配

  • C++98中,我们实现一个const左值引用作为参数的函数,那么实参传递左值和右值都可以匹配。(这也是我们写函数参数时喜欢写const引用的原因之一)
  • C++11以后,分别重载左值引用、const左值引用、右值引用作为形参的f函数,那么实参是左值会匹配f(左值引用),实参是const左值会匹配f(const左值引用),实参是右值会匹配f(右值引用)。
  • 右值引用变量在用于表达式时属性是左值,这个设计这里会感觉很奇怪,下一小节我们讲右值引用的使用场景时,就能体会这样设计的价值了。
void f(int& x)
{
	std::cout << "左值引用重载 f(" << x << ")\n";
}

void f(const int& x)
{
	std::cout << "到 const 的左值引用重载 f(" << x << ")\n";
}

void f(int&& x)
{
	std::cout << "右值引用重载 f(" << x << ")\n";
}

int main()
{
	int i = 1;
	const int ci = 2;
	f(i); // 调用 f(int&) 
	f(ci); // 调用 f(const int&) 
	f(3); // 调用 f(int&&),如果没有 f(int&&) 重载则会调用 f(const int&) 
	f(std::move(i)); // 调用 f(int&&) 
	//右值引用变量在用于表达式时是左值 
	int&& x = 1;
	f(x); // 调用 f(int& x) 
	f(std::move(x)); // 调用 f(int&& x) 
	return 0;
}

5. 类型分类

  • C++11以后,进⼀步对类型进⾏了划分,右值被划分纯右值(pure-value,简称prvalue)和将亡值(expiring-value,简称xvalue)
  • 纯右值是指那些字⾯值常量或求值结果相当于字⾯值或是⼀个不具名的临时对象。如: 42、true、nullptr 或者类似 str.substr(1, 2)、str1 + str2 传值返回函数调⽤,或者整形 a、b、a++、a+b 等。纯右值和将亡值C++11中提出的,C++11中的纯右值概念划分等价于 C++98中的右值
  • 将亡值是指返回右值引⽤的函数的调⽤表达式和转换为右值引⽤的转换函数的调⽤表达,如 move(x)、static_cast(x) 等,还要着重记一种,隐式产生的将亡值,就是有些函数 return 之后的:
    vector<int> create_data() 
    {
        vector<int> data{1, 2, 3};
        return data;  // data 在返回时变成将亡值,资源可以被移动
    }
  • 泛左值(generalized-value,简称glvalue),泛左值包含将亡值和左值 

6. 移动语义(重点)

铺垫了一大堆,终于讲到这篇博客的重中之重了!

(1)左值引用的回顾

       左值引用主要使用场景是在函数中左值引用传参和左值引用传返回值时减少拷贝,同时还可以修改实参和修改返回对象的价值。左值引用已经解决大多数场景的拷贝效率问题,但是有些场景不能使用传左值引用返回,如addStrings和generate函数,C++98中的解决方案只能是被迫使用输出型参数解决。那么C++11以后这里可以使用右值引用做返回值解决吗?显然是不可能的,因为这里的本质是返回对象是一个局部对象,函数结束这个对象就析构销毁了,右值引用返回也无法改变对象已经析构销毁的事实。

// 传值返回需要拷贝(忘了的顺便复习复习高精度加减乘除)
string addStrings(string num1, string num2) 
{
	string str;
	int end1 = num1.size() - 1, end2 = num2.size() - 1; 
	int next = 0;
	while (end1 >= 0 || end2 >= 0)
	{
		int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
		int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
		int ret = val1 + val2 + next;
		next = ret / 10;
		ret = ret % 10;
		str += ('0' + ret);
	}
	if (next == 1)
		str += '1';
	reverse(str.begin(), str.end());
	return str;
}

(2)移动构造和移动赋值

  • 移动构造函数是一种构造函数,类似拷贝构造函数,移动构造函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用,如果还有其他参数,额外的参数必须有缺省值。

  • 移动赋值是一个赋值运算符的重载,他跟拷贝赋值构成函数重载,类似拷贝赋值函数,移动赋值函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用。

  • 对于像string/vector这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值才有意义,因为移动构造和移动赋值的第一个参数都是右值引用的类型,他的本质是要"窃取"引用的右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从而提高效率。下面的DaYuan::string样例实现了移动构造和移动赋值,我们需要结合场景理解。

namespace DaYuan
{
	class string
	{
	public:
		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			cout << "string(char* str)-构造" << endl;
			_str = new char[_capacity + 1];
			strcpy(_str, str);
		}

		string(const string& s)
			:_str(nullptr)
		{
			cout << "string(const string& s) -- 拷贝构造" << endl;
			reserve(s._capacity);
			for (auto ch : s)
			{
				push_back(ch);
			}
		}

		// 移动构造 
		string(string&& s)
		{
			cout << "string(string&& s) -- 移动构造" << endl;
			swap(s);
		}

		string& operator=(const string& s)
		{
			cout << "string& operator=(string s) -- 拷贝赋值" << endl;
			if (this != &s)
			{
				_str[0] = '\0';
				_size = 0;
				reserve(s._capacity);
				for (auto ch : s)
				{
					push_back(ch);
				}
			}
			return *this;
		}

		// 移动赋值 
		string& operator=(string&& s)
		{
			cout << "string& operator=(string&& s) -- 移动赋值" << endl;
			swap(s);
			return *this;
		}

		~string()
		{
			//cout << "~string() -- 析构" << endl;
			delete[] _str;
			_str = nullptr;
		}


        //其他成员函数......
	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;

	};
}

好,我们来具体说它的优势(移动赋值是一样的):

1> 拷贝对象是左值

这你没什么说的,必须深拷贝。

2> 拷贝对象是右值

DaYuan::string s3 = DaYuan::string("yyyyy");

(千万别问为什么这是拷贝不是赋值,要是不知道就重开吧兄弟......)

如果没有移动构造,它的过程是:

  • string("yyyyy") 创建临时对象
  • 调用拷贝构造函数初始化 s3
  • 分配新内存并拷贝所有数据
  • 临时对象被销毁

如果有移动构造,它的过程是:

  • string("yyyyy") 创建临时对象(调用普通构造函数)
  • 临时对象绑定到移动构造函数的 string&& 参数
  • 调用移动构造函数初始化 s3,交换指针
  • 临时对象被销毁(销毁前其实已为空)

在所有情况下,创建临时变量这一步是少不了的。移动语义的优势不在于避免创建临时变量,而在于避免从临时变量到目标对象的深拷贝

和现代写法相比有什么优势?

听到交换指针,肯定有人想起来拷贝构造的现代写法。

这里一定要注意,现代写法只是为了写的时候更方便,不是可以减少深拷贝!它的效率可是一点没减哈!只是它的深拷贝让构造函数做了。

(3)右值引用和移动语义解决传值返回问题

1> 右值对象构造,只有拷⻉构造,没有移动构造的场景  

2> 右值对象构造,有拷⻉构造,也有移动构造的场景  

还记的前面说的将亡值吧,return str时就是特殊的一种,它可以调用拷贝构造,也能调用移动构造,但优先调移动。三种情况如图所示:

3> 右值对象赋值,只有拷⻉构造和拷⻉赋值,没有移动构造和移动赋值的场景

(无优化和半优化一样,都是左边)

4> 右值对象赋值,既有拷⻉构造和拷⻉赋值,也有移动构造和移动赋值的场景 

(4)STL的变化

  • 增加了四个新容器,最有用的两个是unordered_map和unordered_set,讲解这个在“【STL关联式容器】:从“青铜”到“王者”的数据管理艺术”这一篇博客里。
  • STL中容器的新接口也不少,最重要的就是右值引用和移动语义相关的push/insert/emplace系列接口和移动构造和移动赋值,还有initializer_list版本的构造等,这些前面都讲过了,还有一些无关痛痒的如cbegin/cend等需要时查查文档即可。
  • 范围for,以前也有讲。

7. 引用折叠

  • C++中不能直接定义引用的引用如 int& && r = i;,这样写会直接报错,通过模板或typedef中的类型操作可以构成引用的引用。
  • 通过模板或typedef中的类型操作可以构成引用的引用时,这时C++11给出了一个引用折叠的规则:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用。
  • 下面的程序中很好的展示了模板和typedef时构成引用的引用时的引用折叠规则,大家需要一个一个仔细理解一下。
  • 像f2这样的函数模板中,T&& x参数看起来是右值引用参数,但是由于引用折叠的规则,他传递左值时就是左值引用,传递右值时就是右值引用,有些地方也把这种函数模板的参数叫做万能引用。
  • Function(T&& t)函数模板程序中,假设实参是int右值,模板参数T的推导int,实参是int左值,模板参数T的推导int&。结合引用折叠规则,便可以实现,实参是左值,实例化出左值引用版本形参的Function,实参是右值,实例化出右值引用的版本形参的Function。

// 由于引用折叠限定,f1实例化以后总是⼀个左值引用 
template<class T>
void f1(T& x)
{}
// 由于引用折叠限定,f2实例化后可以是左值引用,也可以是右值引用 
// 所以&&引用又被称为万能引用
template<class T>
void f2(T&& x)
{}

int main()
{
	typedef int& lref;
	typedef int&& rref;
	int n = 0;
	lref& r1 = n; // r1 的类型是 int& 
	lref&& r2 = n; // r2 的类型是 int& 
	rref& r3 = n; // r3 的类型是 int& 
	rref&& r4 = 1; // r4 的类型是 int&& 
	// 没有折叠->实例化为void f1(int& x) 
	f1<int>(n);
	f1<int>(0); // 报错 
	// 折叠->实例化为void f1(int& x) 
	f1<int&>(n);
	f1<int&>(0); // 报错 
	// 折叠->实例化为void f1(int& x) 
	f1<int&&>(n);
	f1<int&&>(0); // 报错 
	// 折叠->实例化为void f1(const int& x) 
	f1<const int&>(n);
	f1<const int&>(0);
	// 折叠->实例化为void f1(const int& x) 
	f1<const int&&>(n);
	f1<const int&&>(0);
	// 没有折叠->实例化为void f2(int&& x) 
	f2<int>(n); // 报错 
	f2<int>(0);
	// 折叠->实例化为void f2(int& x) 
	f2<int&>(n);
	f2<int&>(0); // 报错 
	// 折叠->实例化为void f2(int&& x) 
	f2<int&&>(n); // 报错 
	f2<int&&>(0);
	return 0;
}

8. 完美转发

传过来后,可以保证属性不变,再转出去

  Function(T&& t)函数模板程序中,传左值实例化以后是左值引用的Function函数,传右值实例化以后是右值引用的Function函数。但是结合我们在5.2章节的讲解,变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量表达式的属性是左值,也就是说Function函数中t的属性是左值,那么我们把t传递给下一层函数Fun,那么匹配的都是左值引用版本的Fun函数。这里我们想要保持t对象的属性,就需要使用完美转发实现。

       完美转发forward本质是一个函数模板,他主要还是通过引用折叠的方式实现,下面示例中传递给Function的实参是右值,T被推导为int,没有折叠,forward内部t被强转为右值引用返回;传递给Function的实参是左值,T被推导为int&,引用折叠为左值引用,forward内部t被强转为左值引用返回。

void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<class T>
void Function(T&& t)
{
	Fun(t);
	Fun(std::forward<T>(t));
	cout << "----------" << endl;
}
int main()
{
	Function(10);  
	int a;
	Function(a);  
	Function(std::move(a));  
	const int b = 8;
	const int& c = 8;
	Function(b); 
	Function(c);
	Function(std::move(b)); 
	return 0;
}

四、可变参数模板

1. 基本语法

我现在要写一个打印函数,要求我给什么就打印什么,给多少就打印多少,我们要怎么写?

template<class T1>
void Print(const T1& data1)
{
	cout << data1 << endl;
}

template<class T1,class T2>
void Print(const T1& data1, const T2& data2)
{
	cout << data1 << ' ' << data2 <<endl;
}

template<class T1, class T2, class T3>
void Print(const T1& data1, const T2& data2, const T3& data3)
{
	cout << data1 << ' ' << data2 << ' ' << data3 << endl;
}

int main()
{
	string s = "00";
	int e = 3445;
	double q = 66.66;

	Print(s);
	Print(s,e);
	Print(s,e,q);
}

       这样其实真的很挫,那我每传一个不一样的就得写一个模板函数,那得写到啥时候,所以C++11提出了新语法,可变模板参数。C++11支持可变参数模板,也就是说支持可变数量参数的函数模板和类模板,可变数目的参数被称为参数包,存在两种参数包:模板参数包,表示零或多个模板参数;函数参数包:表示零或多个函数参数。

template <class ...Args> void Func(Args... args) {}
template <class ...Args> void Func(Args&... args) {}
template <class ...Args> void Func(Args&&... args) {}
template<typename... Args>
class MyClass {};
//Args就是以前的T

      我们用省略号来指出一个模板参数或函数参数的表示一个包,在模板参数列表中 class... 或 typename... 指出接下来的参数表示零或多个类型列表;在函数参数列表中,类型名后面跟...指出接下来表示零或多个形参对象列表;函数参数包可以用左值引用或右值引用表示,跟前面普通模板一样,每个参数实例化时遵循引用折叠规则。

2. 原理

template <class ...Args>
void Print(Args&&... args)
{
	cout << sizeof...(args) << endl;
}

int main()
{
	string s = "00";
	int e = 3445;
	double q = 66.66;

	Print();  //0
	Print(s); //1
	Print(s,3445); //2
	Print(s,3445,q);  //3
}
  • sizeof...,是新的运算符,用于计算参数包中参数的个数。
  • Print 如果要实现打印功能需要涉及到后面讲的参数包展开方法,先这样实现。
  • 可变参数模板的原理跟模板类似,本质还是去实例化对应类型和个数的多个函数。

//编译器实例化出这四个
// 1. Print<>()
void Print() {
    cout << 0 << endl;
}

// 2. Print<string&>(string&)
void Print(string& arg0) {
    cout << 1 << endl;
}

// 3. Print<string&, int>(string&, int&&)  
void Print(string& arg0, int&& arg1) {
    cout << 2 << endl;
}

// 4. Print<string&, int, double&>(string&, int&&, double&)
void Print(string& arg0, int&& arg1, double& arg2) {
    cout << 3 << endl;
}

比如调用Print(s,3445,q);的过程:

  • 对每个参数独立推导:s (左值) → string&;3445 (右值) → int ;q (左值) → double。因此 Args = [string&, int, double&]
  • 引用折叠+实例化:
    void Print<string&, int, double&>(string& arg0, int&& arg1, double& arg2)
    {
        cout << 3 << endl;  // sizeof...(args) = 3
    }

3. 参数包扩展

这个就是让你能把每个参数都取出来,C++支持很多种方法,我这里具体讲递归展开这一个方法。

// 基准情况(用于结束递归)
void _print() 
{
    std::cout << "end" << std::endl;
}

// 递归情况
template<typename T, typename... Args>
void _print(T first, Args... rest) 
{
    std::cout << first << " ";
    _print(rest...);  // 递归展开
}

// 调用函数
template <class ...Args>
void Print(Args... args)
{
    _print(args...);
}

// 使用
Print(1, 2.5, "hello", 'a');  // 输出: 1 2.5 hello a end

核心原理:递归模板实例化 + 参数包分割

  • 递归模板设计:通过将参数包分割为"第一个参数"和"剩余参数包",逐步处理每个参数
  • 编译期递归展开:在编译阶段,编译器为每次递归调用生成新的模板实例
  • 基准情况终止:当参数包为空时,匹配无参数的基准函数,终止递归
  • 类型安全推导:每个参数保持其原始类型,编译器自动推导模板参数

具体过程:

(_print写成了ShowList,原理一样)

编译器其实实例化出了这几个函数(递归过程从下往上):

// 基准函数
void _print() { std::cout << "end" << std::endl; }

// 递归生成的模板实例
void _print<char>(char first) { 
    std::cout << first << " "; 
    _print(); 
}

void _print<const char*, char>(const char* first, char rest) { 
    std::cout << first << " "; 
    _print(rest); 
}

void _print<double, const char*, char>(double first, const char* rest1, char rest2) { 
    std::cout << first << " "; 
    _print(rest1, rest2); 
}

void _print<int, double, const char*, char>(int first, double rest1, const char* rest2, char rest3) { 
    std::cout << first << " "; 
    _print(rest1, rest2, rest3); 
}

还有一种包扩展方法,了解一下:

template <class T>
const T& GetArg(const T& x)
{
	cout << x << " ";
	return x;
}

template <class ...Args>
void Arguments(Args... args)
{}

template <class ...Args>
void Print(Args... args)
{
	// 注意GetArg必须返回或者到的对象,这样才能组成参数包给Arguments 
	Arguments(GetArg(args)...);
}
// 本质可以理解为编译器编译时,包的扩展模式 
// 将上面的函数模板扩展实例化为下面的函数 
//void Print(int x, string y, double z)
//{
// Arguments(GetArg(x), GetArg(y), GetArg(z));
//}

4. 实际应用

(1)完美转发

如果要用 Args&&... args 的完美转发,就需要用到上面的包扩展,这样去传:

std::forward<Args>(args)... 

具体实例如下:

#include <iostream>
#include <utility>

void target(int& x) {
    std::cout << "左值引用: " << x << std::endl;
}

void target(int&& x) {
    std::cout << "右值引用: " << x << std::endl;
}

template<typename... Args>
void perfect_forward(Args&&... args) {
    std::cout << "参数个数: " << sizeof...(Args) << " -> ";
    target(std::forward<Args>(args)...);
}

int main() {
    int a = 10;
    
    perfect_forward(a);      // 左值
    perfect_forward(20);     // 右值
    perfect_forward(a + 5);  // 右值
}

(2)emplace类型接口

C++11在容器中增加了这样的函数接口:

        这个函数的作用就是insert,前面是迭代器,后面是数据,要是再最后尾部插入,就直接调用emplace_back,它的主要特点是,再插入某些需要深拷贝类型的时候,相比 insert 与 push_back 效率更高

工作原理

      它是支持多参数传入,但是不代表它能插入多个值,他只能插入一个值,你想vector<int>,你再怎么调用,它也就只有一个参数。所以,它的目的就是给那些有多个参数的类或者自定义的类插入时使用的。但下面我们先用list<string>这个容器讲一下具体原理:

list<string> lt;
lt.push_back("111111111111");

具体过程:

  • 从const char*隐式构造临时string类型的对象temp

  • 在list尾部开一块空间,创建新节点

  • 利用移动构造或者拷贝构造把temp转移到新开地空间上

list<string> lt;
lt.emplace_back("111111111111"); 

具体过程:

  • 在list尾部开一块空间,创建新节点
  • 在这一内存上直接构造"111111111111" (利用的是 placement new(定位new))

我们具体看一个多参数的例子:

#include <iostream>
#include <map>
#include <string>

class Person {
    std::string name;
    int age;
public:
    Person(const std::string& n, int a) : name(n), age(a) {
        std::cout << "构造 Person: " << n << ", " << a << std::endl;
    }
    
    Person(const Person&) {
        std::cout << "拷贝构造 Person" << std::endl;
    }
    
    Person(Person&&) {
        std::cout << "移动构造 Person" << std::endl;
    }
};

int main() {
    std::map<int, Person> people;
    
    // 每个容器的emplace在用法上有一些细节的差异,大家可以自己翻阅文档
    // 插入单个元素,但传递多个构造参数
    people.emplace(1, "张三", 25);  // 用多个参数构造Person
    people.emplace(2, "李四", 30);  // 用多个参数构造Person
    
    std::cout << "map大小: " << people.size() << std::endl;  // 输出: 2
}

emplace内部用的就是完美转发,这就可以自动展开参数包,它实例化后:

emplace<int, const char*, int>(int&&, const char*&&, int&&)

然后,它就会在已经开好的内存上,依次构造,这时你的参数必须匹配,内存位置上是[ int, string, int ](根据<类型>自动确定,后两个合起来也会被识别成Person类),那你就必须一一对应,多了少了类型不匹配都会报错。

五、新的类功能

1. 默认的移动构造和移动赋值

  • 原来C++类中,有6个默认成员函数:构造函数/析构函数/拷贝构造函数/拷贝赋值重载/取地址重载/const取地址重载,最后重要的是前4个,后两个用处不大,默认成员函数就是我们不写编译器会生成一个默认的。C++11新增了两个默认成员函数,移动构造函数和移动赋值运算符重载。

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

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

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

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

class Simple {
public:
    string name;
    int value;

    Simple(const char* n = "", int v = 0) : name(n), value(v) {
        cout << "构造: " << name << endl;
    }

    ~Simple() {
        cout << "析构: " << name << endl;
    }
};

// 没有任何自定义函数,编译器会生成默认移动构造和移动赋值
class Test1 
{
public:
    Simple obj;
    int data;
};

// 自定义了析构函数,不会生成默认移动构造和移动赋值
class Test2 {
public:
    Simple obj;
    int data;

    ~Test2() {}  // 有自定义析构函数
};

// 自定义了拷贝构造,不会生成默认移动构造和移动赋值  
class Test3 {
public:
    Simple obj;
    int data;
    Test3() = default;
    Test3(const Test3& other) : obj(other.obj), data(other.data) {} 
};

int main() 
{
    Test1 t1;
    t1.obj = Simple("A", 1);
    t1.data = 100;

    Test1 t2 = move(t1);  // 调用默认移动构造
    Test1 t3;
    t3 = move(t2);        // 调用默认移动赋值

    Test2 t5;
    t5.obj = Simple("B", 2);
    t5.data = 200;

    Test2 t6 = move(t5);  // 调用拷贝构造(因为没有移动构造)

    Test3 t7;
    t7.obj = Simple("C", 3);
    t7.data = 300;

    Test3 t8 = move(t8);  // 调用拷贝构造(因为没有移动构造)
    return 0;
}

2. 成员变量声明时给缺省值

成员变量声明时给缺省值是给初始化列表用的,如果没有显式在初始化列表初始化,就会在初始化列表用这个缺省值初始化,详见:C++ 类与对象 2.1

3. default和delete

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

  • 如果想要限制某些默认函数的生成,在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)
	//	:_name(p._name)
	//	, _age(p._age)
	//{}
	Person(Person&& p) = default;

	Person(const Person& p) = delete;

private:
	string _name;
	int _age;
};

int main()
{
	Person s1;
	const Person& s2 = s1;
	Person s3 = std::move(s1);
	//Person s4(s2);
    //报错:error C2280: “Person::Person(const Person &)”: 尝试引用已删除的函数

	return 0;
}

4. final与override

final详见:C++继承全揭秘:原来编程也能“拼爹” 二.6

override详见: C++多态:代码也能“七十二变” 二.6

六、lambda表达式

1. lambda表达式语法

  • lambda表达式本质是一个匿名函数对象,跟普通函数不同的是它可以定义在函数内部。lambda表达式使用层面而言没有类型,所以我们一般是用auto或者模板参数定义的对象去接收lambda对象。

  • lambda表达式的格式:[capture-list] (parameters) -> return type { function body }

  • [capture-list]:捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用,捕捉列表可以传值和传引用捕捉,具体细节我们下边讲。捕捉列表为空也不能省略。

  • (parameters):参数列表,与普通函数的参数列表功能类似,如果不需要参数传递,则可以连同()一起省略

  • ->return type:返回值类型,用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。一般返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。

  • {function body}:函数体,函数体内的实现跟普通函数完全类似,在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量,函数体为空也不能省略。

int main()
{
    double t = 6;
    auto add1 = [] {};  //最简单的lambda表达式
    auto add2 = []()->void {};
    auto add3 = [](int a, int b)->int {return a + b; };
    cout << add3(11, 98) << endl;
    int a = 0, b = 1;
    auto swap1 = [](int& x, int& y)
        {
            int tmp = x;
            x = y;
            y = tmp;
        };
    swap1(a, b);
    cout << a << ":" << b << endl;
    return 0;
}

2. 捕捉列表

lambda表达式中默认只能用lambda函数体和参数中的变量,如果想用外层作用域中的变量就需要进行捕捉。

(1)第一种捕捉方式是在捕捉列表中显式的传值捕捉和传引用捕捉,捕捉的多个变量用逗号分割。[x, y, &z] 表示x和y值捕捉,z引用捕捉,当引用捕捉不能是右值。

int a = 0, b = 1, c = 2, d = 3;
auto func1 = [a, &b]
    {
        // 值捕捉的变量不能修改,引用捕捉的变量可以修改 
        //a++;
        b++;
        int ret = a + b;
        return ret;
    };
cout << func1() << endl;

(2)第二种捕捉方式是在捕捉列表中隐式捕捉,我们在捕捉列表写一个 = 表示隐式值捕捉,在捕捉列表写一个&表示隐式引用捕捉,这样我们lambda表达式中用了那些变量,编译器就会自动捕捉那些变量。

// 隐式值捕捉 
// 用了哪些变量就捕捉哪些变量 
auto func2 = [=]
    {
        int ret = a + b + c;
        return ret;
    };
cout << func2() << endl;

auto func3 = [&]
    {
        a++;
        c++;
        d++;
    };
func3();
cout << a << " " << b << " " << c << " " << d << endl;

(3)第三种捕捉方式是在捕捉列表中混合使用隐式捕捉和显式捕捉。[=, &x]表示其他变量隐式值捕捉,x引用捕捉;[&, x, y]表示其他变量引用捕捉,x和y值捕捉。当使用混合捕捉时,第一个元素必须是&或=,并且&混合捕捉时,后面的捕捉变量必须是值捕捉,同理=混合捕捉时,后面的捕捉变量必须是引用捕捉。

// 混合捕捉1 
auto func4 = [&, a, b]
    {
        //a++;
        //b++;
        c++;
        d++;
        return a + b + c + d;
    };
func4();
cout << a << " " << b << " " << c << " " << d << endl;

// 混合捕捉2
auto func5 = [=, &a, &b]
    {
        a++;
        b++;
        /*c++;
        d++;*/

        return a + b + c + d;
    };
func5();
cout << a << " " << b << " " << c << " " << d << endl;

(4)lambda表达式如果在函数局部域中,他可以捕捉lambda位置之前定义的变量,不能捕捉静态局部变量和全局变量,静态局部变量和全局变量也不需要捕捉,lambda表达式中可以直接使用。这也意味着lambda表达式如果定义在全局位置,捕捉列表必须为空

int x = 0;
// 捕捉列表必须为空,因为全局变量不用捕捉就可以用,没有可被捕捉的变量 
auto func1 = []()
    {
        x++;
    };

int main()
{
    static int m = 0;
    auto func6 = []
        {
            int ret = x + m;
            return ret;
        };
}

(5)默认情况下,lambda捕捉列表是被const修饰的,也就是说传值捕捉过来的对象不能修改,mutable加在参数列表的后面可以取消其常量性,也就是说使用该修饰符后,传值捕捉的对象就可以修改了,但是修改还是形参对象,不会影响实参。使用该修饰符后,参数列表不可省略(即使参数为空)。

int a = 0, b = 1, c = 2, d = 3;
// 修改了不会影响外面被捕捉的值,因为是一种拷贝 
auto func7 = [=]()mutable
    {
        a++;
        b++;
        c++;
        d++;
        return a + b + c + d;
    };
cout << func7() << endl;
cout << a << " " << b << " " << c << " " << d << endl;
//0 1 2 3 

3. lambda的应⽤

void test() 
{
    vector<int> numbers = { 3, 1, 4, 1, 5, 9, 2, 6 };

    //1.  使用lambda进行排序(升序)
    sort(numbers.begin(), numbers.end(), [](int a, int b) {return a < b; });
    cout << "升序排序: ";
    for (int num : numbers)
        cout << num << " ";
    cout << endl;

    // 2. 使用lambda进行排序(降序)
    sort(numbers.begin(), numbers.end(), [](int a, int b) {return a > b; });
    cout << "降序排序: ";
    for (int num : numbers)
        cout << num << " ";
    cout << endl;


    // 3. 带捕获列表的lambda
    int threshold = 5;
    cout << "大于" << threshold << "的数: ";
    for_each(numbers.begin(), numbers.end(), [threshold](int num) 
        {
            if (num > threshold)
                cout << num << " ";
        });
    cout << endl;

    // 4. 返回lambda函数
    auto createMultiplier = [](int factor) {
        return [factor](int x) { return x * factor; };
        };

    auto doubleIt = createMultiplier(2);
    auto tripleIt = createMultiplier(3);

    cout << "5的双倍: " << doubleIt(5) << endl;
    cout << "5的三倍: " << tripleIt(5) << endl;

}

4. lambda的原理

     lambda的原理和范围for很像,编译后从汇编指令层的角度看,压根就没有lambda和范围for这样的东西。范围for底层是迭代器,而lambda底层是仿函数对象,也就说我们写了一个lambda以后,编译器会生成一个对应的仿函数的类。

      仿函数的类名是编译按一定规则生成的,保证不同的lambda生成的类名不同,lambda参数/返回类型/函数体就是仿函数operator()的参数/返回类型/函数体,lambda的捕捉列表本质是生成的仿函数类的成员变量,也就是说捕捉列表的变量都是lambda类构造函数的实参,当然隐式捕捉,编译器要看使用哪些就传那些对象,不是一下都捕捉。上面的原理,感兴趣的同学可以透过汇编层看一下,可以发现它和仿函数的指令是一样的。

七、包装器

1. function

(1)整体概述

      std::function 是一个类模板,也是一个包装器。 std::function 的实例对象可以包装存储其他的可以调用对象,包括函数指针、仿函数、 lambda 、 bind 表达式等,存储的可调用对象被称为 std::function 的目标。若 std::function 不含目标,则称它为空。调用空 std::function 的目标导致抛出 std::bad_function_call 异常。

      函数指针、仿函数、 lambda 等可调用对象的类型各不相同, std::function 的优势就是统一类型,对他们都可以进行包装,这样在很多地方就方便声明可调用对象的类型,下面的第二个代码样例展示了 std::function 作为map的参数,实现字符串和可调用对象的映射表功能。

function<返回值(参数列表)> 名称 = 被包装的东西;

(2)基本使用规则

using namespace std;

// 普通函数
int add(int a, int b) {
    return a + b;
}
// 函数对象(仿函数)
struct Multiply {
    int operator()(int a, int b) {
        return a * b;
    }
};
// Lambda表达式
auto subtract = [](int a, int b) {
    return a - b;
};

int main() {
    // 包装普通函数
    function<int(int, int)> f1 = add;
    cout << "add(3, 4) = " << f1(3, 4) << endl;
    
    // 包装函数对象
    function<int(int, int)> f2 = Multiply();
    cout << "multiply(3, 4) = " << f2(3, 4) << endl;
    
    // 包装lambda表达式
    function<int(int, int)> f3 = subtract;
    cout << "subtract(3, 4) = " << f3(3, 4) << endl;
    
    // 直接使用lambda
    function<int(int, int)> f4 = [](int a, int b) {
        return a / b;
    };
    cout << "divide(8, 4) = " << f4(8, 4) << endl;
    
    return 0;
}

(3)成员函数的包装

  • 在 C++ 中,获取非静态成员函数的地址必须使用 &ClassName::MemberFunction 的语法。也就是在包装非静态成员函数时一定要在他的前面加上&和类名:: (非静态成员函数和普通函数可加可不加)
  • 非静态成员函数的第一个参数可是this指针,所以包装的时候要传过去。
class Calculator 
{
public:
    int value;
    Calculator(int v = 0) : value(v) {}

    // 普通成员函数
    int add(int x) 
    {
        return value + x;
    }
    // 静态成员函数
    static int static_multiply(int a, int b) 
    {
        return a * b;
    }
    // const成员函数
    int get_value() const 
    {
        return value;
    }
};

int main() {
    Calculator calc(10);

    // 包装静态成员函数(和普通函数一样)
    function<int(int, int)> f1 = &Calculator::static_multiply;
    cout << "static_multiply(3, 4) = " << f1(3, 4) << endl;

    // 包装普通成员函数 - 需要对象指针/对象引用/对象本身
    function<int(Calculator*, int)> f2 = &Calculator::add;
    cout << "calc.add(5) = " << f2(&calc, 5) << endl;

    function<int(Calculator&, int)> f3 = &Calculator::add;
    cout << "calc.add(5) = " << f3(calc, 5) << endl;

    function<int(Calculator&&, int)> f4 = &Calculator::add;
    cout << "Calculator().add(5) = " << f4(Calculator(), 5) << endl;

    function<int(Calculator, int)> f5 = &Calculator::add;
    cout << "calc.add(5) = " << f5(calc, 5) << endl;

    // 包装const成员函数
    function<int(const Calculator&)> f6 = &Calculator::get_value;
    cout << "calc.get_value() = " << f6(calc) << endl;

    return 0;
}

为什么直接传对象可以?

      明明参数是指针,我们却可以传对象,是因为function包装成员函数时,内部同时存储了对象地址成员函数地址,调用时用存储的对象地址来调用存储的成员函数,用到了->* 这个操作符,传对象就先取地址在调用 (&calc)->*add 。

2. bind

bind 是一个函数模板,它也是一个可调用对象的包装器,可以把他看做一个函数适配器,对接收的fn可调用对象进行处理后返回一个可调用对象。 bind 可以用来调整参数个数和参数顺序。bind 也在<functional>这个头文件中。

  • 调用bind的一般形式:auto newCallable = bind(callable, arg_list); 其中newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callable的参数。当我们调用newCallable时,newCallable会调用callable,并传给它arg_list中的参数。

  • arg_list中的参数可能包含形如_n的名字,其中n是一个整数,这些参数是占位符,表示newCallable的参数,它们占据了传递给newCallable的参数的位置。数值n表示生成的可调用对象中参数的位置:_1为newCallable的第一个参数,_2为第二个参数,以此类推。_1/_2/_3....这些占位符放到placeholders的一个命名空间中。

#include <iostream>
#include<functional>
using namespace std;
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;

int Sub(int a, int b)
{
    return (a - b) * 10;
}

int SubX(int a, int b, int c)
{
    return (a - b - c) * 10;
}

class Plus
{
public:
    static int plusi(int a, int b)
    {
        return a + b;
    }
    double plusd(double a, double b)
    {
        return a + b;
    }
};

int main()
{
    auto sub1 = bind(Sub, _1, _2);
    cout << sub1(10, 5) << endl;
    // bind 本质返回的一个仿函数对象 
    // 调整参数顺序(不常用) 
    // _1代表第一个实参 
    // _2代表第二个实参 
    // ...
    auto sub2 = bind(Sub, _2, _1);
    cout << sub2(10, 5) << endl;

    // 调整参数个数 (常用) 
    auto sub3 = bind(Sub, 100, _1);
    cout << sub3(5) << endl;
    auto sub4 = bind(Sub, _1, 100);
    cout << sub4(5) << endl;

    // 分别绑死第123个参数 
    auto sub5 = bind(SubX, 100, _1, _2);
    cout << sub5(5, 1) << endl;
    auto sub6 = bind(SubX, _1, 100, _2);
    cout << sub6(5, 1) << endl;
    auto sub7 = bind(SubX, _1, _2, 100);
    cout << sub7(5, 1) << endl;

    // 成员函数对象进行绑死,就不需要每次都传递了 
    function<double(Plus&&, double, double)> f6 = &Plus::plusd;
    Plus pd;
    cout << f6(move(pd), 1.1, 1.1) << endl;
    cout << f6(Plus(), 1.1, 1.1) << endl;
    // bind一般用于绑死一些固定参数 
    function<double(double, double)> f7 = bind(&Plus::plusd, Plus(), _1, _2);
    cout << f7(1.1, 1.1) << endl;
    //和function一样,该传地址还传地址

    // 计算复利的lambda 
    auto func1 = [](double rate, double money, int year)->double {
        double ret = money;
        for (int i = 0; i < year; i++)
        {
            ret += ret * rate;
        }
        return ret - money;
        };
    // 绑死一些参数,实现出支持不同年华利率,不同金额和不同年份计算出复利的结算利息 
    function<double(double)> func3_1_5 = bind(func1, 0.015, _1, 3);
    function<double(double)> func5_1_5 = bind(func1, 0.015, _1, 5);
    function<double(double)> func10_2_5 = bind(func1, 0.025, _1, 10);
    auto func20_3_5 = bind(func1, 0.035, _1, 30);
    cout << func3_1_5(1000000) << endl;
    cout << func5_1_5(1000000) << endl;
    cout << func10_2_5(1000000) << endl;
    cout << func20_3_5(1000000) << endl;
    return 0;
}



后记:学完这篇可以看出来C++真的有一定难度,主要体现在右值引用和可变模板参数那里,那两块儿要理解透彻确实不容易,我们还有异常与智能指针没讲,我们放到下节课,如果有帮助,那就点个小心心吧!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐