C++11之右值引用:移动语义和完美转发(带你了解移动构造函数纯右值将亡值右值引用std::moveforward等新概念)

Posted 林夕07

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++11之右值引用:移动语义和完美转发(带你了解移动构造函数纯右值将亡值右值引用std::moveforward等新概念)相关的知识,希望对你有一定的参考价值。

C++11新特性集合

C++11之正则表达式(regex_match、regex_search、regex_replace)

C++11之线程库(Thread、Mutex、atomic、lock_guard、同步)

C++11之智能指针(unique_ptr、shared_ptr、weak_ptr、auto_ptr)浅谈内存管理

C++11之强制类型转换(static_cast,const_cast,dynamic_cast,reinterpret_cast)

C++11之Lanbda表达式(匿名函数)

文章目录

一、Pointer to member(指针成员)与copy constructor(拷贝构造函数)

当一个类中出现一个指针成员变量时,就需要十分小心的实现拷贝构造函数。一不小心就会出现memory leak(内存泄漏)或者crtls valid heap pointer(block)(浅拷贝问题)。

浅拷贝

这里我有一个HasPtrMem类有一个成员变量int* d;,具体见下方代码:

#include <iostream>

using namespace std;

class HasPtrMem

public:
	HasPtrMem() :d(new int(0))
	
		cout << "call constructor : "  << ++n_cstr << endl;
	
	
	~HasPtrMem()
	
		delete d;
		d = nullptr;
		cout << "call destructor : " << ++n_dstr << endl;
	

	// 为了测试方便 将作用范围设置为public
public:
	int* d;
;

int main()

	HasPtrMem a;
	HasPtrMem b(a);

	cout << "*a.d:" << *a.d << endl;
	cout << "*b.d:" << *b.d << endl;

	return 0;

我们在构造时在堆区分配一个int类型大小的内存,在析构时被释放掉之前堆区分配的内存。在主函数中,我们定义了一个对象a,然后再通过拷贝函数初始化对象b(注意:这里的拷贝构造函数是采用系统默认生成的)。等价于下面的代码:

	HasPtrMem(const HasPtrMem& h) :d(h.d)
	
		cout << "call default copy constructor : " << ++n_cptr << endl;
	

只是做了一个浅拷贝(相当于俩个对象的指针变量都指向了同一块开辟的空间)。那么在析构函数中执行delete时就会造成crtls valid heap pointer(block)错误(这里我姑且称为重复释放堆区内存错误)。因为在调用一个析构函数后,那么成员指针变量d就成了悬挂指针。因为此时d指向的是一块被释放的内存,所以当再次调用析构函数时就会造成严重的运行错误。

调试情况下的错误:

运行情况下的错误:

这里就需要我们自己去编写深拷贝构造函数。具体代码如下:

	HasPtrMem(const HasPtrMem& h) :d(new int(*h.d))
	
		cout << "call deep copy constructor : " << ++n_cptr << endl;
	

二、移动语义

虽然说为了指针成员变量编写拷贝构造函数是必然的,但是在一些情况下,我们并不需要这样的拷贝构造函数。例如下面这种情况:

#include <iostream>

using namespace std;

class HasPtrMem

public:
	HasPtrMem() :d(new int(0))
	
		cout << "call constructor : "  << ++n_cstr << endl;
	

	HasPtrMem(const HasPtrMem& h) :d(new int(*h.d))
	
		cout << "call copy constructor : " << ++n_cptr << endl;
	

	HasPtrMem operator++(int) // i++
	
		HasPtrMem old = *this;//拷贝构造
		++old.d;
		return old;//拷贝构造
	

	~HasPtrMem()
	
		// 这里为了防止报错 注释掉释放语句
		//delete d;
		//d = nullptr;
		cout << "call destructor : " << ++n_dstr << endl;
	

	// 为了测试方便 将作用范围设置为public
public:
	int* d;

	// 记录每个函数被调用的次数
	static int n_cstr;
	static int n_dstr;
	static int n_cptr;
;

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;

int main()

	HasPtrMem a;
	a++;
	
	return 0;

运行结果:

call constructor : 1
call copy constructor : 1
call copy constructor : 2
call destructor : 1
call destructor : 2
call destructor : 3

是不是很意外,为什么调用了三次构造函数呢????
第一次调用是HasPtrMem a;语句引起的,调用的是无参构造函数。
第二次调用是HasPtrMem old = *this;语句引起的,调用的是拷贝构造函数。
第三次调用是return old;语句引起的,调用的是拷贝构造函数,用来将对象返回。

这里最致命的问题在于:拷贝构造函数调用的次数。测试类中我们只有一个指针成员变量,但是如果在实际生产中有庞大的指针成员变量,那么来回拷贝就十分的影响效率。并且这里的拷贝是毫无意义的,不会影响最后的结果。而且这种影响效率的代码程序员是很难发现的。(但是现在较为智能的编译器就会进行优化的)

例如下面这中情况:

#include <iostream>

using namespace std;

class HasPtrMem

public:
	HasPtrMem() :d(new int(0))
	
		cout << "call constructor : "  << ++n_cstr << endl;
	

	HasPtrMem(const HasPtrMem& h) :d(new int(*h.d))
	
		cout << "call copy constructor : " << ++n_cptr << endl;
	

	~HasPtrMem()
	
		// 这里为了防止报错 注释掉释放语句
		//delete d;
		//d = nullptr;
		cout << "call destructor : " << ++n_dstr << endl;
	

	// 为了测试方便 将作用范围设置为public
public:
	int* d;

	// 记录每个函数被调用的次数
	static int n_cstr;
	static int n_dstr;
	static int n_cptr;
;

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;

HasPtrMem GetTemp()

	return HasPtrMem();


int main()

	HasPtrMem a = GetTemp();

	return 0;

运行结果:
只有一次构造和析构的调用,这就是编译器对函数返回值进行优化的效果。

call constructor : 1
call destructor : 1

C++11的处理方式-移动语义

我们还是以这段代码为例:

HasPtrMem a = GetTemp();

在下图的上半部分可以看到从临时变量中拷贝构造函数a的做法、就是在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝之a.d。在构造函数完成后,临时对象将进行析构,其所拥有的内存资源也会被释放。
而下半部分则是在构造时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存,那么在构造函数完成后,临时对象被析构,a就从其中偷偷的拿到了临时对象所拥有的内存资源了。

将上述这种情况称为移动语义(Move semantics)。

具体看下面这段代码,我在HasPtrMem这个类中添加一个移动构造函数HasPtrMem(HasPtrMem&& h) 。这与拷贝构造函数不同之处在于,移动拷贝构造函数接受一个右值引用的参数类型。在这个函数中我们可以看到移动构造函数使用了参数h的成员d初始化了this对象的成员d(这里类似于浅拷贝),然后把h的成员变量d置为nullptr。这就是移动构造的全部流程。

这里所谓的“偷内存”,本质上就是this.d = h.d;h.d=nullptr;这俩行代码。说白了就是将别人申请好内存转交给自己,重点在于转交哦,也就是h.d=nullptr;语句的含义。

这里我分别打印了临时变量申请的空间地址和主函数最终接受的空间地址,我们观察俩者是否一致。

#include <iostream>

using namespace std;

class HasPtrMem

public:
	HasPtrMem() :d(new int(0))
	
		cout << "call constructor : "  << ++n_cstr << endl;
	

	HasPtrMem(const HasPtrMem& h) :d(new int(*h.d))
	
		cout << "call copy constructor : " << ++n_cptr << endl;
	

	HasPtrMem(HasPtrMem&& h) :d(h.d)
	
		h.d = nullptr;//将临时值的指针成员置为空
		cout << "Move the constructor : " << ++n_mvtr << endl;
	
	
	~HasPtrMem()
	
		delete d;
		d = nullptr;

		cout << "call destructor : " << ++n_dstr << endl;
	

	// 为了测试方便 将作用范围设置为public
public:
	int* d;

	// 记录每个函数被调用的次数
	static int n_cstr;
	static int n_dstr;
	static int n_cptr;
	static int n_mvtr;
;

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvtr = 0;

HasPtrMem GetTemp()

	HasPtrMem h;
	cout << "Resource from " << __func__ << ": " << hex << h.d << endl;
	return h;


int main()

	HasPtrMem a = GetTemp();
	cout << "Resource from " << __func__ << ": " << hex << a.d << endl;
	return 0;

运行结果:

call constructor : 1
Resource from GetTemp: 000002761E7616D0
Move the constructor : 1
call destructor : 1
Resource from main: 000002761E7616D0
call destructor : 2

可以看到这里没有调用拷贝构造函数,而是调用了移动构造函数,移动构造的结果就是使GetTemp中的h的指针成员变量h.dmian函数中的a的指针成员变量a.d都指向了相同的堆区地址。这就有点像浅拷贝咯。该堆区内存在函数返回时,成功的避开了被析构的过程,而且变成了a变量的资源。如果这个操作不是仅仅4个字节的移动而且非常庞大的堆内存移动时,那么效率高的不是一星半点哦。

为什么需要移动语义?

这里你可以会说上面的GetTemp函数我完全可以用引用或者指针的形式传入,就可以避免上述这个问题。但是我想说的是通过返回值的形式返回可以使用链式编程。类似于cout << GetTemp().d << endl;语句。

移动语义并不是新概念,在C++98/03中,它就已经存在了,例如:智能指针的拷贝、列表拼接(list::splice)、容器内的置换(swap on containers)等等,这些操作都包含了从一个对象向另一个对象的资源转义的过程。

一旦用到临时变量,移动构造语义就会被执行。

左值、右值和右值引用

在C/C++语言中,我们对于左值和右值应该都不陌生。也常常听到编译器抱怨“不可以修改的左值”。一般来说,在等号左侧的称为左值,在等号右侧的称为右值。

在C++11中,右值是由俩个新概念组成,一个是将亡值(xvalue,eXpiring Value),另一个是纯右值(prvlaue,Pure Rvalue)。

纯右值就是C++98标准中右值的概念,用于辨别临时变量和一些不跟对象关联的值。例如:非引用返回的函数返回的临时变量值、a=1+3中的1+3产生的临时值、2、‘c’、true、lambda表达式等等,这些都称为纯右值。

将亡值是C++11新引入的概念,用于和右值引用相关的表达式。这种表达式通常是 要被移动的对象,比如返回右值引用T&&的函数返回值、std::move的返回值(后面有介绍)、或者转换为T&&
的类型转换的返回值(后面有介绍)。

在C++11中,所有的值都必属于左值、将亡值、纯右值三者之一。

当实际上我们只对左值有概念,而对于右值的定义很少,也很难归类。

在C++11中,右值引用就是对一个右值进行引用的类型。由于右值没有名称所以我们只能通过引用的方式找打它的存在。例如

T&& a = RetureRvalue();

假设RetureRvalue方法会返回一个右值,那么我们就可以声明一个变量a进行右值引用,等价于RetrureRvalue方法返回的临时变量。

C++98声明的引用叫左值引用。C++11声明的引用叫右值引用。无论是左值引用还是右值引用都必须在定义时候初始化。

C++98的引用

在C++98标准中就已经规定过左值引用能否绑定到右值上,初始化由右值进行完成。例如:

int RetureRvalue()

	return 1;


int main()

	int& a = RetureRvalue();//error:initial value of reference to non-const must be an lvalue

	const int& b = RetureRvalue();

	return 0;


a的初始化会报纸编译错误,b可以正常初始化。
下面图中就是抛出的错误:
翻译过来就是:引用非const对象的初始值必须是左值

这里你可能有个疑惑就是为什么加了const就可以执行了呢?

因为在C++98标准中常量左值引用就是一个”万能“的引用类型。它可以接受非常量左值、常量右值、右值对其进行初始化。而且在使用右值进行初始化时候,常量左值引用还可以像右值引用一样将右值的生命期延长。这与C++11的右值引用唯一区别就是常量左值所引用的右值只能是只读的。

测试常量左值引用

我实现了一个结构体Copyable,其中手动实现了一个拷贝构造函数。分别测试值传递和引用传递调用的拷贝构造函数的次数。代码如下:

#include <iostream>

using namespace std;

struct Copyable

 Copyable()
 Copyable(const Copyable& other) //实现拷贝构造函数 方便观察日志输出
 
  cout << "Copied" << endl;
 
;

Copyable ReturnRvalue()

 return Copyable();


void AcceptVal(Copyable) // 这里因为我不会使用参数,所以我省略参数,防止编译器抱怨说未使用的参数



void AcceptRef(const Copyable&)

 


int main()

 cout << "Pass by value: " << endl;
 AcceptVal(ReturnRvalue()); // 临时值被拷贝传入

 cout << "Pass by reference: " << endl;
 AcceptRef(ReturnRvalue()); // 临时值被引用传入
 return 0;

运行结果:
由于我的vs2022最低标准库为C++14,所以这次我使用的vc6.0环境进行测试。

这里对按值传递的调用做一个解释:
第一个copied是ReturnRvalue方法返回调用的,第二个copied是AcceptVal方法形参进行拷贝。

各种引用类型以及可以引用的类型

下面列出了在C++11中各种引用类型可以引用的值的类型。注:只要能够绑定右值,就能延长右值的生命周期。

判断引用类型

有时候我们不知道该类型是否为引用类型,所以在<type_traits>头文件中提供了三个模板类:is_rvalue_referenceis_lvalue_referenceis_reference。例如:

    #include <type_traits>
    
	cout << boolalpha << is_rvalue_reference<int&&>::value << endl; // true
	cout << boolalpha << is_lvalue_reference<int&>::value << endl; // true
	cout << boolalpha << is_reference<int&>::value << endl; // true

三、std::move 强制转换右值

在C++11中,标准库<utility>中提供了一个方法std::move,它的功能是将一个左值强制转换为右值且延长生命周期,所以千万不要别它的名字所忽悠哦。std::move 等价于 static_cast<T&&>(lvalue)

证明std::move延长生命周期

创建了一个Moveable类,并实现了移动构造函数以及拷贝构造函数。正常调用move方法,就会导致a.p资源被剥夺,变成nullptr

#include <iostream>
using namespace std;

class Moveable

public:
	Moveable() :p(new int(3)) 
	~Moveable() // 析构函数
	
		delete p;
		p = nullptr;
	
	Moveable(const Moveable& m):p(new int(*m.p)) // 拷贝构造函数

	Moveable(Moveable&& m):p(m.p) // 移动构造函数
	
		m.p = nullptr;
	


public:
	int* p;
;

int main()

	Moveable a;

	Moveable c(move(a));

	if(a.p != nullptr)
	
		cout << "a.p =" << *a.p << endl;
	
	else
	
		cout << "a.p = nullptr" << endl;
	

	return 0;

运行结果

a.p = nullptr

成功验证了我们的猜想,move方法本质就是调用移动构造函数,上述的列子中a.p就变成了悬挂指针,在访问时就会造成验证错误(如下图)。

一般来说,要使用move方法就必须清楚a.p将不再被使用,而且需要转换成为右值引用还是一个生命周期将要结束的对象。

使用场景

这里定义了俩个类:HugeMemMoveable,其中Moveable类中有一个成员变量是HugeMem类型的。在Moveable类的移动构造函数中我们使用了move方法。将传入的m.ptr强制转换为右值,用于Moveable类初始化。

#include <iostream>

using namespace std;

class HugeMem

public:
	HugeMem(int size):size(size > 0 ? size : 1)
	
		ptr = new int[size];
	
	~HugeMem()
	
		delete[] ptr;
		ptr = nullptr;
	
	HugeMem(HugeMem&& hm):size(hm.size),ptr(hm.ptr) // 移动构造函数
	
		hm.ptr = nullptr;
		hm.size = 0;
	

public:
	int* ptr;
	int size;
;

class Moveable

public:
	Moveable():ptr(new int(3)), h(1024)
	~Moveable()
	
		delete ptr;
		ptr = nullptr;
	

	Moveable(Moveable&& m):ptr(m.ptr),h(move(m.h))
	
		m.ptr = nullptr;
	

public:
	int* ptr;
	HugeMem h;
;

Moveable GetTemp()

	Moveable temp = Moveable();

	cout << hex << "Huge Mem from " << __func__ << " " << temp.h.ptr << endl;

	return temp;


int main()

	Moveable a(GetTemp());

	cout << hex << "Huge Mem from " << __func__ << " " << a.h.ptr << endl;

	return 0;

这里因为GetTemp()这行代码执行完成后就会被释放,所以刚好转交过去是不会有任何问题的。

假如这里不使用move会有什么问题呢?
这里需要你注释掉HugeMem类的移动过构造函数,然后将Moveable(Moveable&& m):ptr(m.ptr),h(move(m.h))改为Moveable(Moveable&& m):ptr(m.ptr),h(m.h)。由于改动较少,这里我就不贴代码了。只展示一下运行结果,至于为什么会错误读者应该很清楚了吧。

总结

<

以上是关于C++11之右值引用:移动语义和完美转发(带你了解移动构造函数纯右值将亡值右值引用std::moveforward等新概念)的主要内容,如果未能解决你的问题,请参考以下文章

[c++11]右值引用移动语义和完美转发

C++11 ——— 右值引用和移动语义

[转][c++11]我理解的右值引用移动语义和完美转发

C++11:移动语义Move Semantics和完美转发Perfect Forwarding

C++11:移动语义Move Semantics和完美转发Perfect Forwarding

C++11:移动语义Move Semantics和完美转发Perfect Forwarding