【C++】C++11——右值引用和移动语义|可变参数模板

文章目录


一、左值引用和右值引用

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

左值引用和右值引用的定义

左值 是一个表示数据的表达式 (如变量名或解引用的指针),我们可以对左值取地址,也可以对左值赋值 (const 左值不能赋值); 左值既可以出现在赋值符号的左边,也可以出现在赋值符号的右边;左值引用 就是给左值的引用,给左值取别名。

cpp 复制代码
int main()
{
	//以下的a、b、c均为左值
	int* a = new int(0);
	int b = 1;
	const int c = 2;

	// 左值引用给左值取别名
	int& ref1 = a;
	int& rb = b;
	return 0;
}

右值 也是一个表示数据的表达式,如字面常量、表达式返回值、函数返回值等等右值可以出现在赋值符号的右边,但不能出现出现在赋值符号的左边,右值不能取地址。

常见的右值和右值引用:

cpp 复制代码
int main()
{
	int a, b, x, y;

	//常见的右值
	10;
	x + y;
	fmin(x, y);

	// 左值引用给左值取别名
	int& ref1 = a;

	// 左值引用给右值取别名
	const int& ref2 = (a + b);

	// 右值引用给右值取别名
	int&& ref3 = (a + b);

	// 右值引用给move后左值取别名
	//int&& ref4 = a;
	int&& ref4 = move(a);

	return 0;
}

需要注意的是:

  • 为什么函数返回值是右值: 当函数返回的是一个局部变量时,因为局部变量出了函数生命周期就会结束,所以返回时会将该变量拷贝到寄存器中,然后返回这个寄存器中的内容,而寄存器中的变量是临时变量,临时变量具有常性,属于右值。
  • 为什么右值不能取地址: 在 C++中,右值则是一个临时使用的、不可寻址的内存值;右值没有独立的内存空间,它只是存储在寄存器或其他临时内存空间中的一个值;我们也不能把右值放入内存中,因为右值没有确定的内存位置,所以右值不能取地址。

注意: 虽然右值不能取地址,但是给右值取别名后,会导致右值被存储到特定位置,拥有独立的内存空间,所以可以取到该位置的地址;换句话来说,虽然右值引用引用的是右值,但右值引用本身是一个左值 ,所以如果我们不希望改变右值引用,我们就需要将右值引用定义为 const 右值引用。


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

  • 左值引用不能直接引用右值,但是 const 左值引用可以引用右值,因为 const 左值引用也是只读的,而权限可以平移:
cpp 复制代码
int main()
{
	// 左值引用只能引用左值,不能引用右值。
	int a = 10;
	int& ra1 = a;
	//int& ra2 = 10;  // 编译失败,因为10是右值

	// const左值引用既可引用左值,也可引用右值。
	const int& ra3 = 10;
	const int& ra4 = a;
	return 0;
}
  • 右值引用也不可以直接引用左值,但是右值引用可以引用 move 后的左值:
cpp 复制代码
int main()
{
	// 右值引用只能右值,不能引用左值。
	int&& r1 = 10;
	// error C2440: "初始化": 无法从"int"转换为"int &&"
	int a = 10;
	//int&& r2 = a;

	// 右值引用可以引用move以后的左值
	int&& r3 = std::move(a);

	return 0;
}

二、右值引用的使用场景和意义

左值引用的短板

我们先来看一下左值引用可以解决的问题:

  • 做参数:a、减少拷贝,提高效率。b、做输出型参数。
  • 做返回值:a、减少拷贝,提高效率。b、引用放回,可以修改返回对象(比如:operator[ ])

左值引用既可以引用左值又可以引用右值,那为什么C++11还要提出右值引用呢,其实左值引用无法解决一些场景问题,所以就提出了右值引用。

函数返回对象是一个局部变量时,就不能使用左值引用返回,而只能传值返回了,因为局部对象出了函数作用域就不存在了,此时引用的就是一个野指针;如下:

cpp 复制代码
//左值引用的短板------不能解决局部对象的返回值问题
template <class T>
T func1(const T& x) {
	T tmp;
	//...

	return tmp;  //出这个函数tmp会自动销毁
}

这种情况下下编译器会使用这个局部对象拷贝构造一个临时对象,然后再返回这个临时对象,也就是说,会比引用返回多一次拷贝构造 ;当局部对象是一个需要进行深拷贝的自动类型时,比如 vector<vector>,拷贝构造的代价就很大了。而右值引用的提出就是为了补足左值引用存在的这些短板的。


移动构造和移动赋值

假设我们要在自己实现的string类中实现一个 to_string 函数,如下:

cpp 复制代码
cjl::string to_string(int value)
{
	bool flag = true;
	if (value < 0)
	{
		flag = false;
		value = 0 - value;
	}

	cjl::string str;
	while (value > 0)
	{
		int x = value % 10;
		value /= 10;

		str += ('0' + x);
	}

	if (flag == false)
	{
		str += '-';
	}

	std::reverse(str.begin(), str.end());
	return str;
}

由于 to_string 函数返回的 str 是一个局部对象,所以这里我们只能使用传值返回,而传值返回就需要进行深拷贝。

正常情况下应该是 两个拷贝构造 (多出来一次构造是 to_string 函数内部构造 str);但是我们发现这里只有一次拷贝构造。这其实是因为当遇到连续构造的场景时编译器会进行优化,直接使用 str 来拷贝构造得到 s,而不再创建临时对象。

但是优化只适用于少数场景,大部分情况下还是会拷贝构造产生临时对象,比如:

为了将 str 的资源直接转移给 s,中间不发生拷贝构造 ,我们就可以使用右值引用来发挥这个功能了。

C++11 中的右值广义的来说一共分为两种:

  • 纯右值: 内置类型表达式的值;
  • 将亡值: 自定义类型表达式的值;所谓的将亡值就是指生命周期马上就要结束的值,一般来说匿名对象、临时对象、move 后的自定义类型都可以看做是将亡值。

注: 上面我们说右值不能取地址其实是右值的严格定义,但其实将亡值也是可以被当作右值看待的,而将亡值有独立的内存空间,可以取地址;所以对于是否是右值我们要灵活看待。

既然将亡值的生命周期马上就要结束了,那么在拷贝构造中我们就可以直接将将亡值的资源拿过来给我自己使用,这样我就不用再去一个一个 new 节点了,将亡值也不用去一个一个释放节点了,两全其美。现在,我们重载一个右值引用版本的构造函数 -- 移动构造 ,这样当实参类型为右值的对象需要进行拷贝构造时就会调用此函数 ;在函数中,我们直接拿走将亡值的资源,从而使得深拷贝变为了浅拷贝,显著提高了程序的效率。

cpp 复制代码
// 移动构造
string(string&& s)
	:_str(nullptr)
{
	cout << "string(string&& s) -- 移动拷贝" << endl;
	swap(s);
}


本来这里 str 会先拷贝构造一个临时对象,由于临时对象属于右值,所以会直接调用移动拷贝来构造 s;但是这里编译器进行了优化,直接将 str 识别为右值,让它来移动构造 s,所以通过移动构造 (右值引用) 我们成功将深拷贝变为了浅拷贝。

这里我们需要注意的是:

只有当实参为右值时 才会匹配 移动构造构造函数进行优化 ,当实参为左值时编译器在匹配参数还是会匹配形参为 const T& 的拷贝构造函数;因为编译器不知道我们是否还会对左值进行操作,所以它不敢拿走左值的资源来构造新的对象。

💕 移动赋值

和移动构造同理,只是移动赋值中将亡值还需要释放掉我之前的资源,不过这个过程是自动的:

cpp 复制代码
//移动赋值
string& operator=(string&& s)
{
    cout << "string& operator=(string&& s) -- 移动语义" << endl;
    swap(s);
    return *this;
}

网上有的人说右值引用延长了变量的生命周期,这种说法其实是不准确的 ;因为右值引用只是将该变量的资源转移给另外一个变量,让它的资源能够不随着该变量的销毁而被释放,而该变量本身的生命周期是没有变的。

总结:

  • 左值引用 让形参成为实参的别名,直接减少拷贝;
  • 右值引用 通过实现移动构造和移动赋值,将将亡值的资源进行转移,间接减少拷贝。(浅拷贝的类不需要进行资源转移,所以也就没有移动赋值和移动拷贝)

STL中的容器也都是增加了移动构造和移动赋值的,同时,STL容器的插入接口函数也增加了右值引用的版本。


万能引用和完美转发

万能引用 是一个 函数模板 ,且函数的形参类型为右值引用;对于这样的函数模板,编译器能够自动根据实参的类型 -- 左值/ const 左值/ 右值/ const 右值,自动推演实例化出不同的形参类型分别为 左值引用/ const 左值引用/ 右值引用/ const 右值引用 的函数;如下:

cpp 复制代码
//万能引用
template<typename T>
void PerfectForward(T&& t)
{
	//fun(t);
}

int main()
{
	int a;
	const int b = 8;
	PerfectForward(a);	//左值
	PerfectForward(b);	//const 左值
	PerfectForward(10);	//右值
	PerfectForward(std::move(b));  //const 右值

	return 0;
}

不管实参为什么类型,模板函数都能正确接受并实例化为对应的引用类型,所以我们把形参为右值引用的函数模板叫做万能引用 。其中,当实参为左值const 左值时,T&& 会被实例化为 T& 或 const T&,我们称其为 引用折叠 ,即 将 && 折叠为 &


💕 完美转发

我们上面讲解了万能引用,但是万能引用存在一个很大的问题: 万能引用实例化后函数的形参的属性全部都是左值 -- 如果实参为左值/ const 左值,则实例化函数的形参是左值/ const 左值;如果实参是右值/ const 右值,虽然实例化函数的形参是右值引用/ const 右值引用,但是右值引用本身是左值;所以就会出现下面这种情况:

cpp 复制代码
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<typename T>
void PerfectForward(T&& t)
{
	Fun(t);
}

int main()
{
	PerfectForward(10);           // 右值

	int a;
	PerfectForward(a);            // 左值
	PerfectForward(std::move(a)); // 右值

	const int b = 8;
	PerfectForward(b);		      // const 左值
	PerfectForward(std::move(b)); // const 右值

	return 0;
}

为了在传参的过程中能够保留对象原生类型属性,C++11 又设计出了完美转发 -- forward


总结:C++11 的右值引用之旅:

  • 旅程一:为了弥补左值引用局部对象返回会发生拷贝构造的问题,C++11 设计出了右值引用;右值引用可以通过移动构造和移动赋值实现资源转移,将深拷贝转化为浅拷贝,从而提高程序效率,这是 C++11 中非常重要的一个设计;
  • 同时,C++11 还为 STL 中的容器都提供了右值版本的插入接口,但由于右值引用本身是左值,所以往下一层传递时不能保证其仍然是右值,所以C++11 又设计出了 move,但盲目的对左值进行 move 会导致错误。
  • 旅程二:为了让模板函数能同时接受 (const) 左值和 (const) 右值并正确实例化为对应的引用类型,C++11 又设计出了万能引用,附带的又引出了引用折叠这个概念;但是这样奇怪的设计让许多学习 C++11 的人苦不堪言。
  • 旅程三:万能引用的设计又带来了新的问题 -- 不管是左值引用还是右值引用,其本身都是左值,所以往下一层传递时又要面对类型丢失的问题,但是这里使用之前的 move 已经不能解决问题了,所以 C++11 又又又设计出了完美转发,来保证传参的过程中对象原生类型属性能够保持不变。

三、新的类功能

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

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值重载
  5. 取地址重载
  6. const 取地址重载

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

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

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

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

我们在讲解新的类的功能之前先看一个重要的问题,情况如下图所示:

  • 情况一 虽然 const& 会延长对象的生命周期,但是当我们返回局部对象的const&时,由于出了该函数的作用域,函数栈帧就会销毁,同时该对象所在的空间的数据就会被释放,这块空间已经被归还给操作系统了,但是我们却还要使用它给拷贝给新创建的对象,因此导致非法访问内存。程序奔溃。因此const& 会延长局部对象的生命周期仅仅是在同意作用域中起作用。出了作用域,无论如何该对象都会被销毁。
  • 情况二 这次我们依然返回局部对象的引用,但是这次我们使用一个const&来接受,由于const& 依然是给返回的局部对象的引用取别名,因此不会去操作内存,但是该局部对象所在的栈帧已经被销毁了, 当执行下一条语句时,会开辟新的栈帧,新的栈帧中的地址可能会占用该局部对象所在的地址,因此该地址就会被占用,所以该地址中的内容可能会被篡改,导致s1变成了随机值。

当我们不返回局部对象的引用时,并且使用引用来接受该值时则不会出现任何问题,因为编译器做优化后会在该函数return之前将str识别为将亡值,先将该对象移动拷贝一份,然后再释放掉该对象,这样该对象的资源就会被转移走了,然后在外面使用const& 来接收该返回值(移动拷贝后的值)完全没有问题。

下面我们来看一下新的类功能

cpp 复制代码
class Person
{
public:
	Person(const char* name = "", int age = 0)
		:_name(name)
		, _age(age)
	{}

private:
	cjl::string _name; // 自定义类型
	int _age = 1;		   // 内置类型
};

写了析构函数后:

简单来说,如果你什么都没有实现,或者只实现了一个构造函数,那么编译器会自动生成移动拷贝和移动赋值;自动生成的对于内置类型完成值拷贝,对于自定义类型看自定义类型是否实现了移动构造或移动赋值,实现了就调用自定义类型的移动构造或移动赋值,没有实现就调用自定义类型拷贝构造和赋值重载。


类成员变量初始化

C++11允许在类定义时给成员变量初始缺省值,默认生成构造函数会使用这些缺省值初始化,这

个我们在前面类和对象的博客默认就讲了,这里就不再细讲了


default 和 delete

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

由于默认移动构造和移动赋值函数的生成条件十分苛刻,所以 C++11 提供了 default 关键字,它可以显示指定生成某个默认成员函数;比如我们提供了拷贝构造,就不会生成移动构造了,那么我们可以使用 default 关键字显示指定移动构造生成;如下:

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

假如我们要设计一个类,它不允许被拷贝,传统的做法是将拷贝构造函数定义为私有函数,但这种做法只防止了在类外进行拷贝,而在类内我们仍然可以调用拷贝构造函数完成拷贝,此时编译器在编译时不会发生错误,只有运行起来对同一块空间析构两次时才会报错

cpp 复制代码
class A {
public:
	A() {
		_ptr = new int[10]{ 0 };
	}

	~A() {
		delete[] _ptr;
	}

	//在类内进行拷贝
	void func() {
		A tmp(*this);
		//...
	}

private:
	//将拷贝构造定义为私有,防止在类外进行拷贝
	A(const A& a)
		: _ptr(a._ptr)
	{}

	int* _ptr;
};

int main()
{
	A a;
	a.func();
	return 0;
}

那么我们如何才能让一个类既不能在外部被拷贝,也不能在内部被拷贝呢? 其实我们可以只给出拷贝构造函数的声明,且声明为私有;这样,只要调用了拷贝构造函数,那么在链接时一定会发生错误:

C++11 中提供了一种更为便捷的方法 ------ 在函数声明加上 =delete 即可,delete 关键字可以阻止函数的自动生成,我们称被 =delete 修饰的函数为删除函数;如下:

注意:default 关键字都只能针对默认成员函数使用;而 delete 关键字既可以对默认成员函数使用,也可以对非默认成员函数和普通函数使用


四、可变参数模板

C++11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板,相比C++98/03,类模版和函数模版中只能含固定数量的模版参数,可变模版参数无疑是一个巨大的改进。然而由于可变模版参数比较抽象,使用起来需要一定的技巧,所以这块还是比较晦涩的。现阶段呢,我们掌握一些基础的可变参数模板特性就够我们用了,所以这里我们点到为止,以后大家如果有需要,再可以深入学习。

下面就是一个基本可变参数的函数模板

cpp 复制代码
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}

上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为"参数 包" , 它里面包含了0到N(N>=0)个模版参数 。我们无法直接获取参数包args中的每个参数的,只能 通过展开参数包的方式来获取参数包中的每个参数 ,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数。由于语法不支持使用args[i]这样方式获取可变

参数,所以我们的用一些奇招来一一获取参数包的值。

💕 计算可变参数的个数:

cpp 复制代码
//可变参数的模板
template<class ...Args>
void ShowList(Args...args)
{
	cout << sizeof...(args) << endl;
}
int main()
{
	string str("hello");
	ShowList();
	ShowList(1);
	ShowList(1, 'A');
	ShowList(1, 'A', str);
	return 0;
}

当我们看到可变参数模板时,很自然会联想到使用如下方法来依次取出参数包中的每个参数:

cpp 复制代码
template <class ...Args>
void ShowList(Args... args)
{
	//求参数包中参数的个数
	cout << sizeof...(args) << endl;

	//依次取出参数包中的每个参数--error
	for (int i = 0; i < sizeof...(args); i++) {
		cout << args[i] << endl;
	}
}

虽然上面这种方法非常好理解,但是C++11标准中并不允许以这种方式来取出参数包中的参数,而是使用另外两种非常晦涩的方式来完成,如下:

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

将参数包中的第一个参数赋值给 val,将剩下的 n-1 个参数以类似于递归子问题的方式逐个取出,当参数包为空时再调用最后一次,至此将参数包中的参数全部取出;

cpp 复制代码
//递归终止函数
void ShowList()
{
	cout << "ShowList()" << endl;
}

//展开函数,参数包args包含N个参数(N>=0)
template<class T, class...Args>
void ShowList(const T& val, Args...args)
{
	cout << "ShowList(" << val << ",参数包args有" << sizeof...(args) << "个参数)" << endl;
	ShowList(args...); // -- 递归调用
}

int main()
{
	string str("hello");
	ShowList(1, 'A', str);
	return 0;
}

💕 逗号表达式方式展开参数包

这种方式是利用了数组初始化的特性,我们在用0初始化数组时需要知道列表中参数的个数,而参数的个数需要通过展开参数包获得。

可以看到,C++11 提供的这两种参数包展开的方式比起 args[i] 这种方式真的是晦涩太多了,特别是逗号表达式展开,但是没办法,语言就是这么规定的;不过也不用太在这里纠结,参数包展开能看懂就行,我们并不需要去深究它的底层原理。

cpp 复制代码
//逗号表达式展开参数包
template <class T>
void PrintArg(const T& val)
{
	cout << val << " ";
}

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

int main()
{
	string str("hello world");
	ShowList(1);
	ShowList(1, 'A');
	ShowList(1, 'A', str);
	return 0;
}
cpp 复制代码
template <class T>
int PrintArg(T t)
{
	cout << t << " ";

	return 0;
}

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

相关推荐
szuzhan.gy10 分钟前
DS查找—二叉树平衡因子
数据结构·c++·算法
忒可君19 分钟前
C# winform 报错:类型“System.Int32”的对象无法转换为类型“System.Int16”。
java·开发语言
GuYue.bing29 分钟前
网络下载ts流媒体
开发语言·python
火云洞红孩儿34 分钟前
基于AI IDE 打造快速化的游戏LUA脚本的生成系统
c++·人工智能·inscode·游戏引擎·lua·游戏开发·脚本系统
StringerChen37 分钟前
Qt ui提升窗口的头文件找不到
开发语言·qt
数据小爬虫@43 分钟前
如何利用PHP爬虫获取速卖通(AliExpress)商品评论
开发语言·爬虫·php
java1234_小锋1 小时前
MyBatis如何处理延迟加载?
java·开发语言
FeboReigns2 小时前
C++简明教程(4)(Hello World)
c语言·c++
FeboReigns2 小时前
C++简明教程(10)(初识类)
c语言·开发语言·c++
学前端的小朱2 小时前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具