一文搞定移动语义和完美转发

一文搞定移动语义和完美转发

浅拷贝和深拷贝

简单的区分: 浅拷贝:按字节拷贝,如果是指针变量则直接对指针地址进行拷贝 深拷贝:对内容进行拷贝,如果有指针变量则另外申请地址拷贝所指内容

写c++类时,如果类中有指针成员的话,通常我们都会格外注意,要注意拷贝构造函数的编写,因为如果简单的浅拷贝可能导致free一块未定义区域,是非法的。

如下面的程序:

#include<iostream>
using namespace std;
class HasPtrMem
{
public:
    HasPtrMem() :p(new int(0)) {}
    HasPtrMem(const HasPtrMem& h) :p(h.p) {}
    ~HasPtrMem() { delete p; }
    int* p;
};

int main()
{
    HasPtrMem a;
    HasPtrMem b(a);
    cout << *a.p << endl;
    cout << *b.p << endl;
}

构造函数申请了一块堆空间,而析构函数对该空间进行释放。在拷贝构造函数中,执行了浅拷贝,这会引发一个非常严重的问题:a的p申请了一块区域,对b执行了拷贝构造,则b的p指向了同一块区域,因此当程序执行结束时,a和b将纷纷执行其析构函数,假设a先执行其析构函数,则a将所指的区域进行了释放,那么释放后b的指针所指向的区域就成为了非法内存区域,即成为了dangling pointer 悬挂指针,对悬挂指针执行free释放内存将导致严重错误。

因此,通常我们会采用深拷贝的方法解决该问题:

#include<iostream>
using namespace std;
class HasPtrMem
{
public:
    int* p;
    HasPtrMem() :p(new int(0)) {}
    HasPtrMem(const HasPtrMem& h) :p(new int(*h.p)){}  //将拷贝构造函数改成深拷贝,重新申请一块堆上的内存,然后用h.p进行初始化
    ~HasPtrMem() { delete p; }
};

int main()
{
    HasPtrMem a;
    HasPtrMem b(a);
    cout << *a.p << endl;
    cout << *b.p << endl;
}

通过深拷贝,便可以解决浅拷贝的问题。使用深拷贝时,另外申请了一块区域并通过需要拷贝的对象的内容进行初始化,也就是其实指向的是不同的区域,只是内容相同。

看样子深拷贝完美解决了该问题,但是事实上确实这么完美吗?

移动语义

拷贝构造函数为指针成员进行内容拷贝的做法几乎是完美的,但是有些情况下该方法还是较为繁琐。下面的例子将说明:

#include<iostream>
using namespace std;
static int n_cstr;  //记录初始化次数
static int n_dstr;   //记录析构次数
static int n_cptr;   //记录拷贝构造函数次数
class HasPtrMem
{
public:
    int* p;
    HasPtrMem() :p(new int(0))
    {
        cout << "construct:" << ++n_cstr << endl;  // 输出construct以及构造的次数,并且将次数+1
    }
    HasPtrMem(const HasPtrMem& h) :p(new int(*h.p))
    {
        cout << "copy construct:" << ++n_cptr << endl;  //输出copy construct以及拷贝的次数,将次W数+1
    }
    ~HasPtrMem()
    {
        delete p;
        cout << "destruct:" << ++n_dstr << endl;  //输出destruct并且析构的次数,将次数+1
    }
};
HasPtrMem gettemp()  //构造一个HasPtrMem并返回
{
    HasPtrMem a = HasPtrMem();
    cout << a.p << endl;  //输出a的p所指的地址信息
    return a;
}
int main()
{
    HasPtrMem b=gettemp();
    cout << b.p << endl;  //输出b的p所指的地址信息
}

(Tips:现代编译器有诸多优化的方向,如果让gettemp直接返回一个HasPtMem()的话,编译器将不构造它,直到赋值给b时直接为b构建,此段代码具有普适性)

分析该代码:

gettemp()函数是产生一个HasPtrMem并输出地址后返回,写的是 深拷贝,因此将直接对内容进行拷贝,也就是预期是b的指针是指向另外申请的一块区域。

输出结果:

construct:1
00F54E70
copy construct:1
destruct:1
00F54EA0
destruct:2

说明b执行了一次拷贝构造函数,将内容进行了复制,从两个指针指向的区域不同可以看出是另外申请了一块区域。

仔细想想,这次拷贝真的是有必要的吗?如果只是利用返回值进行一个赋值操作而已,那么拷贝构造函数申请了一个新的区域,将原来的内容拷贝过来,但是之后就会对临时变量的区域进行了析构。那么,如果这个指针指向的堆内存是非常大的,那么这一次的拷贝花销还是比较大的。

既然之后也不会用到a的p所指区域,那么如果能够直接将原来的内存给b,那么就会省去这次拷贝操作,会非常的提高效率。

这便是移动构造函数所做的事情。这个行为称为“移动语义

利用移动构造函数后原程序:

#include<iostream>
using namespace std;
static int n_cstr;  //记录初始化次数
static int n_dstr;   //记录析构次数
static int n_cptr;   //记录拷贝构造函数次数
class HasPtrMem
{
public:
    int* p;
    HasPtrMem() :p(new int(0))
    {
        cout << "construct:" << ++n_cstr << endl;  // 输出construct以及构造的次数,并且将次数+1
    }
    HasPtrMem(const HasPtrMem& h) :p(new int(*h.p))
    {
        cout << "copy construct:" << ++n_cptr << endl;  //输出copy construct以及拷贝的次数,将次W数+1
    }
    HasPtrMem(HasPtrMem&& h) :p(h.p) 
    {
        h.p = nullptr; 
        cout << "Move construct" << endl; 
    }
    ~HasPtrMem()
    {
        delete p;
        cout << "destruct:" << ++n_dstr << endl;  //输出destruct并且析构的次数,将次数+1
    }
};
HasPtrMem gettemp()  //构造一个HasPtrMem并返回
{
    HasPtrMem a = HasPtrMem();
    *a.p = 10;
    cout << a.p << endl;  //输出
    return a;
}
int main()
{
    HasPtrMem b(gettemp());
    cout << b.p << endl;  //输出b的p所指的地址信息
}

运行结果:

construct:1
013EC630
Move construct
destruct:1
013EC630
destruct:2

移动构造的输入是一个右值引用(下面会详讲),行为是将h的p直接赋值给了主体的p,然后将h的p赋值为nullptr,这样之后h调用析构函数时也将不去释放任何内存,从而实现了一个内容的“窃取”。 需要注意的是一定要将原来的指针赋为空,否则原来的对象析构时将导致现在的指针悬挂。

分析结果需要注意两个p的地址是相同的,因此是符合预期的。相较于直接深拷贝,移动语义省去了申请新内存区域和拷贝内容以及释放一块内存区域。

也就是说,移动语义实现了从一个对象到另一个对象的资源转移的过程!

最基础的内容就是这样,之后将详将右值引用以及关于移动语义完美转发的更多细节。【这一部分还是比较抽象的,挺难】

右值引用

c语言中常说左值、右值。通常,通常认为左值是变量,右值是一个计算式。通常,我们这样区分,可以取地址的、有名字的就是左值,不能取地址、没有名字的就是右值。 例如在 a=b+c 式子中,a就是一个左值,(b+c)就是一个右值。

在C++11中,右值通常是两个概念:将亡值(将要被移动的对象,包括返回右值引用 T&&)的函数返回值、std::move的返回值等 纯右值(一些运算表达式的值、非引用返回的函数返回的临时变量等)

所谓的右值引用,就是必须绑定到右值的引用,通常用 &&获得右值引用,因此,右值引用通常只能绑定到一个将要销毁的对象,因此,可以自由地将一个右值引用的资源移动到另一个资源而不受影响(因为之后不会再对原资源进行任何操作,除了析构释放)

右值引用只是某个对象的另一个名字而已,可以将右值引用绑定到表达式上,但不能将一个右值引用直接绑定到一个左值上

对于返回非引用类型的函数,可以将一个const的左值引用或者一个右值引用绑定到这样的表达式

右值引用只能绑定到临时对象,这意味着所引用的对象将要被销毁并且该对象没有其他用户,因此使用右值引用的代码可以自由接管所引用对象的资源

【常量左值引用是一个万金油!无论左值还是右值,常量还是非常量,都可以进行绑定,因此如果移动构造函数不符合,那么也至少可以执行拷贝构造函数】

  • 常量左值引用:全能类型,常用于拷贝语义
  • 非常量右值引用:用于移动语义和完美转发
void doWork(TYPE&& param) {
	// ops and expressions using std::move(param)
}

param是左值还是右值?param是一个左值。左值和右值与类型是没有关系的,即int既可以是左值,也可以是右值。区别左值和右值的唯一方法就是其定义,即能否取到地址。在这里,我们明显可以对param进行取地址操作,所以它是一个左值。也就是说,但凡有名字的“右值”,其实都是左值。

move函数

首先需要记住,move函数不move任何东西,它唯一的作用,就是将一个左值强制转换为右值引用,继而就可以通过右值引用使用该值,以用于移动语义。不过,被转化的左值生命期没有因为左右值的变化而发生变化。

需要注意,被转化为右值后的值将不再使用,通常转换为右值的对象是一个声明周期快结束的对象。

int &&rr=std::move(rr1);  //it is right
//写这个意味着,除了对rr1赋值或者销毁它之外,将不再使用它

使用move函数应该使用std::move,避免潜在的名字冲突。

实际上,为了保证移动语义的正确传递,程序员编写移动构造函数时,应该总是使用std::move转换拥有形如堆内存、文件句柄等资源的成员为右值,这样如果成员支持移动构造的话便可以轻松实现移动语义,哪怕成员没有移动构造函数,也至少将调用接收常量左值的构造函数实现拷贝构造,也不会出现问题。

拷贝构造和移动语义

通常实现时,实现一个接收const左值引用的拷贝构造函数,一个接收指向非const的右值引用的移动构造函数。

#include<iostream>
using namespace std;
static int n_cstr;  //记录初始化次数
static int n_dstr;   //记录析构次数
static int n_cptr;   //记录拷贝构造函数次数
class HasPtrMem
{
public:
    int* p;
    HasPtrMem() :p(new int(0))
    {
        cout << "construct:" << ++n_cstr << endl;  // 输出construct以及构造的次数,并且将次数+1
    }
    HasPtrMem(const HasPtrMem& h) :p(new int(*h.p))
    {
        cout << "copy construct:" << ++n_cptr << endl;  //输出copy construct以及拷贝的次数,将次W数+1
    }
    HasPtrMem(HasPtrMem&& h) :p(h.p) 
    {
        h.p = nullptr; 
        cout << "Move construct" << endl; 
    }
    ~HasPtrMem()
    {
        delete p;
        cout << "destruct:" << ++n_dstr << endl;  //输出destruct并且析构的次数,将次数+1
    }
};

采用上面的这个类,

int main()
{
    HasPtrMem a;
    cout << a.p << endl;
    HasPtrMem b(a);
    cout << b.p << endl;  //输出b的p所指的地址信息
}
/* 
运行结果:  调用了copy函数
construct:1
00774E70
copy construct:1
00774EA0
destruct:1
destruct:2
*/

可以看到调用了拷贝构造函数,地址是不同的。

int main()
{
    HasPtrMem a;
    cout << a.p << endl;
    HasPtrMem b(std::move(a));
    cout << b.p << endl;  //输出b的p所指的地址信息
}
/*
运行结果:   调用了move函数
construct:1
0157D9E8
Move construct
0157D9E8
destruct:1
destruct:2
*/

而通过move函数,将a转换为一个右值,从而调用了移动拷贝。

但是,如果a是一个const对象,那么会发生什么?

int main()
{
    const HasPtrMem a;
    cout << a.p << endl;
    HasPtrMem b(std::move(a));
    cout << b.p << endl;  //输出b的p所指的地址信息
}
/*运行结果
construct:1
011F4E70
copy construct:1
011F4EA0
destruct:1
destruct:2
*/

可以看到调用了copy拷贝构造函数,也就是说,哪怕用了move也不一定是会调用移动语义的,如果move的对象不是const那么将调用移动语义,否则仍是拷贝构造,因为移动构造会改变对象的内容,而const是拒绝改变的,因此会直接采用copy。

理解std::move和std::forward

这一部分的核心是:

  • std::move执行一个无条件的对rvalue的转化。对其本身而言,它不move任何东西,只是强制转化为右值
  • std::forward在参数被绑定为rvalue的情况下才会将它转化为rvalue
  • std::move和std::forward在runtime是什么都不做的

std::move是无条件地将其参数转化为一个右值,std::forward当特定条件满足时才会执行它的转换。

std::move的一个简洁实现方式:

template<typename T>
typename remove_reference<T>::type&&
move(T&& param)
{
	using ReturnType=typename remove_reference<T>::type&&;
	return static_cast<ReturnType> (param);
}

std::move实现时首先在 T上执行一个std::remove_reference,它去除T身上的引用,保证&&应用到了一个非引用类型上,确保返回的是右值引用。

首先还是需要注意,用了move确实会返回右值,但是如果原来的参数是const类型,则返回的也是const 右值引用,是不会和移动语义所匹配的,不过,由于const 左值引用具有万金油属性,所以会匹配到左值引用上去,从而实现一个拷贝构造。因此,如果想对这些对象执行move操作,就不要把它们声明为const,对const对象的move请求通常会悄悄的执行到copy操作上。

对于std::forward,只有当它的参数被一个lvalue初始化时,才会进行cast即转化操作。std::forward<T>()std::move()相区别的是,move()会无条件的将一个参数转换成右值,而forward()则会保留参数的左右值类型

区分通用引用和右值引用

通用引用(universal reference)。构成通用引用有两个条件:

  1. 必须满足T&&这种形式
  2. 类型T必须是通过推断得到的

看这段代码:

template <typename T>
class TestClass {
	public:
		void func(T&& t) {} 
}

因为T在初始化时已经知道是什么类型,所以不是推断得到是,是一个右值引用。

可以构成通用引用的有如下几种可能:

  • 函数模板参数(function template parameters)

     template <typename T>
     void f(T&& param);
    
  • auto声明(auto declaration)

     auto && var = ...;
    
  • typedef声明(typedef declaration)

  • decltype声明(decltype declaration)

对于通用引用来说,传进来的如果是左值引用那就是左值引用,如果是右值引用那就是右值引用,因此是实现forward的一个很好的方式

需要记住的东西:

如果一个函数的template parameter有着T&&d 格式,且有一个deduce type T。或者一个对象被声明为 auto &&,那么这个parameter或者object就是一个通用引用

如果type的声明的格式不完全是type &&,或者type deduction没有发生,那么type &&表示的是一个rvalue reference

对于通用引用来说,传进来的如果是左值引用那就是左值引用,如果是右值引用那就是右值引用

猜你喜欢

转载自blog.csdn.net/dingdingdodo/article/details/108142643