带你一起探索 c++11 中右值引用、移动构造、&&、move、forward

程序大乐猿

共 7922字,需浏览 16分钟

 ·

2021-04-02 15:13

本文将介绍带你一步步的了解 c++11 中:

  • 右值、右值引用

  • 移动构造函数

  • && 解密

  • move  移动语义

  • forward 完美转发


产生原由

先来看下面这个已经在熟悉不过的类:
class Object{public:   //无参构造函数   Object() : m_num(new int(10))  {       std::cout << "contr function..." << std::endl;       printf("m_num 地址:%p\n", m_num);  }
//拷贝构造函数 Object(const Object& o) : m_num(new int(*o.m_num)) { std::cout << "copy contr function..." << std::endl; }

private: int* m_num;};
        我们知道,某个类中如果含有指向堆内存的(一般含有指针)成员变量,如果不编写拷贝构造函数,那么编译器将调用默认的拷贝构造函数,即只进行浅拷贝。即下图中只拷贝了 a 指针,将会出现 a、b指向同一块内存,为了防止堆区地址双重释放,那么应该编写 拷贝构造函数 防止浅拷贝发生问题。即在拷贝构造函数中重新分配一块内存进行初始化。


问题:在 c++11 中引入了右值引用和移动构造函数又是为了什么呢?

下面的代码中调用 getObj 函数初始化一个对象 oo1, 分析其执行过程:
1、getObj 函数中初始化一个临时对象 temp, 调用构造函数;
2、将临时对象赋值给 oo1, 调用拷贝构造函数;
class Object{...}
Object getObj() { //1、初始化一个临时对象 temp, 调用构造函数; Object temp; return temp;}
int main() { //2、将临时对象赋值给 `oo1`, 调用拷贝构造函数; Object oo1 = getObj(); return 0;};
执行结果:
contr function...          m_num 地址:00E7F6D8copy contr function...
根据执行结果,与预期一致。

问题: 如果在第一步中,在调用 getObj时,创建的临时对象 temp 在构造过程中如果要进行大量的初始化工作(特别耗时),并且其用完后将被释放;第二步中,将临时对象拷贝给 oo1 时,也需要进行大量的拷贝工作。oo1 在生命周期结束后也将释放。
思考: 中间产生了临时对象 temp 只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?
答案: c++ 11 的右值引用。

右 值

 c++11 中引入了右值的概念,使用 && 标记。
 不必要去记其概念,只需要知道怎么去判别即可,可以被取地址的即为左值,反之为右值
int x = 1000;int y = 2000;x = y;
其中, 等号左边的 x、y 可以取地址,即为左值;等号右边 1000、 2000 为右值;处于等号左右的 x = y 中,因为其都可以进行取地址,所以都为左值。

右值引用

        右值引用也即是一个引用,和左值引用一样,只不过左值引用是左值的别名。右值不具备名字,所以只能使用右值引用标记它。因为左值引用和右值引用都是别名,不拥有所绑定对象的内存,所以必须进行初始化操作。右值被右值引用接收后重新有了名字,只要该引用变量存活,,右值也将存活。即右值引用可以延长某块内存的存活时间。
int&& data = 1000;  //必须进行初始化
class Object{public: Object() { std::cout << "contr function..." << std::endl; } Object(const Test& a) { std::cout << "copy contr function..." << std::endl; }};
Object getObj(){ return Object();}
int main(){ int a1; int &&a2 = a1; // error Object& t = getObj(); // error Object && t = getObj(); const Object& t = getObj(); return 0;}
  • int &&a2 = a1; a1 具有名字,其为左值,左值赋值给右值引用  错误
  • Object& t = getObj(); getObj 函数返回一个没有名字的右值,将一个右值赋值给左值引用  错误
  • Object && t = getObj(); 右值赋值给右值引用  正确
  • const Object& t = getObj(); 常量左值引用被成为万能引用,既可以引用左值也可以引用右值 正确

性能优化

介绍完右值和右值引用, 再回到上面的问题:中间产生了临时对象temp只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?
class Object{...}
Object getObj() { //1、初始化一个临时对象 temp, 调用构造函数; Object temp; return temp;}
int main() { //2、将临时对象赋值给 `oo1`, 调用拷贝构造函数; Object oo1 = getObj(); return 0;};
getObj 函数中创建的临时对象(堆上)构建完成后,还没有使用,就释放掉了,那么如果可以复用这个临时对象,将会对性能有很大帮助。
那么如何复用呢?给该类编写移动构造函数即可。
class Object{...   //移动构造函数   Object(Object&& o) {       m_num = o.m_num;       o.m_num = nullptr;       std::cout << "move contr function..." << std::endl;  }
private: int* m_num;}
Object getObj() { //1、初始化一个临时对象 temp, 调用构造函数; Object temp; return temp;}
int main() { //2、 调用移动构造函数 Object oo1 = getObj(); return 0;};
执行结果:
contr function...m_num 地址:00CCF5B0move contr function...
执行结果调用了移动构造函数。那么我们分析移动构造函数中发生了什么?
//移动构造函数Object(Object&& o) {   m_num = o.m_num;   o.m_num = nullptr;   std::cout << "move contr function..." << std::endl;}...
Object oo1 = getObj();
因为临时对象用完就释放,白白构造那么长时间。在执行 Object oo1 = getObj(); 这条语句是,调用移动构造函数将临时对象 temp 所指的内存直接赋值给 oo1 对象的指针(m_num = o.m_num;); 然后避免 temp 出了作用域销毁内存,则将 temp 指向的内存置空(o.m_num = nullptr;)。oo1 对象直接拥有了 构造 temp 时分配的内存。


上图充分的展示了拷贝构造函数和移动构造函数的关系,可以看出,移动构造函数整体上少分配了一块内存,相当于浅拷贝,只不过最后将原始指针置空,因此极大的节省了空间和时间。


问题: 什么时候会调用移动构造函数?
答案:要求右侧的对象是一个临时对象,才会调用移动构造函数,如果没有移动构造函数,则将调用拷贝构造函数。因此可以看出,移动构造函数不是必须存在的,只是为了性能优化而存在的。


问题: 怎么编写移动构造函数呢?
从上面可以看出,移动构造函数实质是为了复用其他对象的资源而产生的,这种资源往往是堆内存的资源。那么在编写移动构造函数过程中,只需要转移该类中关于堆上的资源即可。


右值符号 &&  解密

在很多代码模板函数中经常会出现诸如以下的代码:

1static_cast<typename remove_reference<_Ty>::type&&>(_Arg)   2typename<class T> void function(T&& t)

代码中的 && 会不会让你晕头转向?如果是,那么和我一起解密吧!


c++ 中有一种叫做未定义的引用类型,通常有以下两种方式:

自动类型推导的 auto&&
模板类型推导的 T&&

有一种特列 const T&&, 表示右值引用,不属于未定义类型引用。


那么接下来记住两个规则即可,不对,是一个规则(引用折叠):

使用右值推导 T&& 和 auto&& 得到的是一个右值引用类型;其他的都是左值引用类型。

int a = 10;int b = 250;auto&& x = a;  //a 是一个左值, auto&& 表示左值引用auto&& y = 100;  //100 是右值, auto&& 表示右值引用
int&& a1 = 5;auto&& b1 = a1; //a1是右值引用,不是右值,所以b1 是左值引用
int a2 = 10; int& a3 = a2;//a2是左值,a3为左值引用auto&& c1 = a3;// a3是左值引用, c1 即是左值引用类型auto&& c2 = a2; // a2是左值, c2 即是左值引用类型
const int& d1 =3; const int&& d2 = 4;auto&& e1 = d1; //d1是常量左值引用, e1 即为常量左值引用auto&& e2 = d2; //d2是常量右值引用, e2 即为常量左值引用
通过以上既可以理解,使用右值推导 T&& 和 auto&& 得到的都是右值引用类型,其他的都是左值引用类型。
void printX(int &x){   std::cout << "l-value: " << x << std::endl;}
void printX(int &&x){ cout << "r-value: " << x << endl;}
void forward(int &&x){ printX(x);}
int main(){ int a = 100; printX(a); printX(20); forward(500);
return 0; system("pause");};

上面定义了两个重载函数 printX, 先对上面的输出结果进行推导:

1、 printX(a); 其中 a 是一个左值,那么调用第一个 printX函数,输出应该是左值;

2、 printX(20); 其中 20 是右值, 那么调用第二个printX函数, 输出应该是右值;

3、 forward(500), 其中 500 是右值,forward 形参x 是右值引用类型,继续调用printX,由于此时的右值具备的名字,所以将退化成一个左值,所以将调用第一个 printX 函数, 输出应该是左值;

        对于最后以重情况可能稍微难以理解,只需要记住,右值引用在传递的过程中将会退化成左值引用。这也是 std::forward 为了防止退化成左值引用,所以才出现的,被誉为完美转发 ,即不做任何变动的转发。将上述 forward 函数中的 printX(x)改成 printX(std::forward<int>(x)) 将会调用第二个函数,输出应该是右值。

输出结果:

l-value: 100r-value: 20l-value: 500

结果完全正确。



std::move

在上述的例子中,有一种情况是不能进行赋值的,即用一个左值初始化一个右值引用;

int a = 10;int&& b = a;  //error 无法将右值引用绑定到左值
所以 std::move 函数应运而生, move 通常被理解为“移动”, 但在本文中被译为“转移”更加合适,即转移所有权,将你的房产名字转给你的老婆,房子本身不变,只是所有者发生了变化。

再来看 std::move 的源代码:

template<class _Ty> inline_CONST_FUN typename remove_reference<_Ty>::type&&move(_Ty&& _Arg) _NOEXCEPT{// forward _Arg as movablereturn (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));}

使用 std::move 后,上述代码可以更改为:

int a = 10;int&& b = std::move(a);  //ok

对于这种将左值转化为右值的方法有什么用处呢?

vector<string> vec;vec.push_back("wang");vec.push_back("zhuo");.....//插入一百万条数据.....vector<string> vec1 = vec;vector<string> vec2 = std::move(vec);

如果用 vec 这个左值直接初始化 vec1,将会发生大量的内存拷贝。

如果用 vec2 = std::move(vec), 直接将 vec 的所有权转移给 vec2即可。

用处?在对于拥有大量的堆内存或者动态数组时候,使用 std::move 可以有效的节省时间效率。


如果将 std::move 和 移动构造函数结合起来,尽可能重复利用资源, 移动构造函数接收的是一个右值引用类型。



std::forward

上文也提及到了完美转发 forward,即在右值引用传递的过程中,为了防止被编译器当作左值处理,使其以原由类型进行转发,引入了 forward。

原型如下:

std::forward<T>(x);

当forward 的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值,否则将被转换成右值。

template<typename T>void printX(T& t){   std::cout << "left value"<< std::endl;}
template<typename T>void printX(T&& t){ std::cout << "rifht value " <<std::endl;}
template<typename T>void test(T && v){ printX(v); printX(move(v)); printX(forward<T>(v));}
int main(){ test(100); int num = 10; test(num); test(forward<int>(num)); test(forward<int&>(num)); test(forward<int&&>(num));
return 0;}
1test(100), 100 是右值,test形参是未定义引用类型,即根据上文提到的 ”使用右值推导 T&&auto&& 得到的是一个右值引用类型“ 形参 v 是一个具有名字的右值引用,但编译器将其视为左值。

  • 传递给第一个函数 printX变为左值,调用第一个,输出左值。

  • 使用move(v) 之后,左值 v 被 move 成右值,输出右值。

  • printX(forward<T>(v)), T为右值引用,因此最终将会成为右值, 输出右值

2、test(forward<int&>(num));  模板参数为int&, 根据 ”当forward 的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值“ , 将会得到一个左值,test 形参为未定义类型 T&&, 根据 ”使用右值推导 T&&auto&& 得到的是一个右值引用类型,反之为左值引用“,即test 形参为左值引用类型。

  • printX(v);  v 是左值, 输出左值

  • printX(move(v)); 左值经过 move 后成为右值,输出右值

  • printX(forward<T>(v)); T类型为 int& ,v将被转换成左值, 输出左值。

你学会了吗?其他的几种留给你自己分析。


浏览 33
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报