一段小代碼秒懂C++右值引用和RVO(返回值優化)的誤區

關於C++右值引用的參考文檔裏面有明確提到,右值引用能夠延長臨時變量的週期。如:html

std::string&& r3 = s1 + s1; // okay: rvalue reference extends lifetime

看到這裏的時候,Binfun有點崩潰,就這就能延長生命週期?這個和如下的這樣的命令有啥本質的區別嗎?c++

std::string r3 = s1 + s1

因此Binfun寫了一段小代碼來測試一下右值引用的延長生命週期的特性,如:ide

#include <stdio.h>
#include <utility>//std::move

class result {
public:
  int val;
  result() { printf("constructor() [%p]\n", this); }
  result(result& r): val(r.val) { printf("copying from [%p] to [%p]\n", &r, this); }
  result(result&& r): val(r.val) { printf("moving from [%p] to [%p]\n", &r, this); }
  result(int i): val(i) { printf("constructor(%d) [%p]\n", val, this); }
  ~result() { printf("destructor() [%p]\n", this); }
};

result process(int i)
{
  printf("In process function\n");
  return result(i);
}

int main()
{
  printf("---step1---\n");
  result s1 = process(1);
  printf("---step2---\n");
  result &&s2 = process(2);

  printf("---vals:---\n");
  printf("s1 addr:[%p], val:[%d]\n", &s1, s1.val);
  printf("s2 addr:[%p], val:[%d]\n", &s2, s2.val);
}

而後Binfun自信滿滿地敲了編譯並執行命令:函數

g++ new_move.cpp -std=c++11 -O2 && ./a.out

看到打印的時候Binfun再一次崩潰了:測試

---step1---
In process function
constructor(1) [0x7ffd94c8aca0]
---step2---
In process function
constructor(2) [0x7ffd94c8acb0]
---vals:---
s1 addr:[0x7ffd94c8aca0], val:[1]
s2 addr:[0x7ffd94c8acb0], val:[2]
destructor() [0x7ffd94c8acb0]
destructor() [0x7ffd94c8aca0]

這……沒有任何區別啊,C++國際標準委員會逗我玩呢?優化

RVO和右值引用

實際上是有區別的,先聽我解釋一下RVO這個概念:返回值優化this

返回值優化(Return value optimization,縮寫爲RVO)是C++的一項編譯優化技術。即刪除保持函數返回值的臨時對象。這可能會省略屢次複製構造函數c++11

在調用process函數的時候居然沒有臨時變量產生(能夠看到構造函數只運行了一次),那應該是被RVO了。既然是編譯優化技術,那麼應該有編譯選項關閉,RVO優化在C++裏面也叫copy_elision(複製消除)優化。使用如下命令便可取消RVO:code

g++ new_move.cpp -std=c++11 -fno-elide-constructors -O2 && ./a.out

編譯後打印以下:htm

---step1---
In process function
constructor(1) [0x7ffe849b8a70]
moving from [0x7ffe849b8a70] to [0x7ffe849b8ab0]
destructor() [0x7ffe849b8a70]
moving from [0x7ffe849b8ab0] to [0x7ffe849b8aa0]
destructor() [0x7ffe849b8ab0]
---step2---
In process function
constructor(2) [0x7ffe849b8a70]
moving from [0x7ffe849b8a70] to [0x7ffe849b8ab0]
destructor() [0x7ffe849b8a70]
---vals:---
s1 addr:[0x7ffe849b8aa0], val:[1]
s2 addr:[0x7ffe849b8ab0], val:[2]
destructor() [0x7ffe849b8ab0]
destructor() [0x7ffe849b8aa0]

能夠看到在step1中調用process函數的時候,構造產生了一個變量(地址爲0x7ffe849b8a70),而後函數返回時將這個變量移動構造到了另外一個臨時變量(地址爲0x7ffe849b8ab0),接着賦值給s2(地址爲0x7ffe849b8aa0)時再一次調用了移動構造函數。

之因此以上調用的都是移動構造,這是由於編譯器識別出這些變量都是「將亡值」,也就是說編譯器知道這個變量接下來都會離開它的做用域,即將會被析構掉,此時認定它是一個右值&&,因此也就調用的是移動構造函數。打印中也體現了這一點,0x7ffe849b8a70和0x7ffe849b8ab0被move constructor以後立馬就被析構掉了。

而step2就不同了,咱們看到了移動構造函數只被調用了一次。而這一次0x7ffe849b8ab0並無被析構掉,這一次它被保留了下來,它的生命週期被延長了,直到main函數結束時它纔會析構掉。

以上能夠看到右值引用的確能夠延長右值變量的生命週期。固然儘管在RVO的光環下,只須要構造一次就已經到位了,就不必去延長生命週期了-。-|||

std::move和右值引用的坑

另外Binfun進一步理解移動語義的時候,發現了一個坑,但願你們關注一下。這個坑會致使奇怪的問題發生……也會致使很隱蔽的bug……

在說這個例子的時候,咱們先介紹一下std::move(懂的能夠略過-。-)

文章最下面的參考連接裏面的文章有一段寫得特別棒,以下:

關於move函數內部究竟是怎麼實現的,其實std::move函數並不「移動」,它僅僅進行了類型轉換。下面給出一個簡化版本的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);
}

代碼很短,可是估計很難懂。首先看一下函數的返回類型,remove_reference在頭文件中,remove_reference 有一個成員type,是T去除引用後的類型,因此remove_reference ::type&&必定是右值引用,對於返回類型爲右值的函數其返回值是一個右值(準確地說是xvalue)。因此,知道了std::move函數的返回值是一個右值。而後,咱們看一下函數的參數,使用的是通用引用類型(&&),意味者其能夠接收左值,也能夠接收右值。可是無論怎麼推導,ReturnType的類型必定是右值引用,最後std::move函數只是簡單地調用static_cast將參數轉化爲右值引用。

仍是一樣的result類和一樣的process函數,咱們修改一下main函數爲:

int main()
{
  printf("---step1---\n");
  result &&s1 = std::move(process(1));
  printf("---step2---\n");
  process(2);

  printf("---vals:---\n");
  printf("s1 addr:[%p], val:[%d]\n", &s1, s1.val);
}

猜猜最後打印中s1.val的值是1仍是2?

g++ new_move.cpp -std=c++11 -O2 && ./a.out

以上的編譯選項的打印以下:

---step1---
In process function
constructor(1) [0x7ffdf1352db0]
destructor() [0x7ffdf1352db0]
---step2---
In process function
constructor(2) [0x7ffdf1352db0]
destructor() [0x7ffdf1352db0]
---vals:---
s1 addr:[0x7ffdf1352db0], val:[2]

g++ new_move.cpp -std=c++11 -fno-elide-constructors -O2 && ./a.out

以上的編譯選項,打印結果以下:

---step1---
In process function
constructor(1) [0x7ffe7d59f350]
moving from [0x7ffe7d59f350] to [0x7ffe7d59f380]
destructor() [0x7ffe7d59f350]
destructor() [0x7ffe7d59f380]
---step2---
In process function
constructor(2) [0x7ffe7d59f350]
moving from [0x7ffe7d59f350] to [0x7ffe7d59f380]
destructor() [0x7ffe7d59f350]
destructor() [0x7ffe7d59f380]
---vals:---
s1 addr:[0x7ffe7d59f380], val:[2]

很惋惜都是2,無論有沒有RVO優化都是2。

Binfun的理解是,由於std::move的顯式聲明的關係,result &&s1 = 這種讓右值生命值延長的方法失效了,最終s1指向的是已經被析構掉的右值地址(如上的0x7ffe7d59f380),而由於編譯器優化等級的關係,編譯器會從新回收並利用這個地址。因此在調用process(2)的時候會從新使用0x7ffe7d59f380這個地址。

若是編譯選項的優化等級沒那麼高的話(如下把優化等級降爲O1),會暫時避免這個問題:

g++ new_move.cpp -std=c++11 -O1 -fno-elide-constructors && ./a.out

打印以下:

---step1---
In process function
constructor(1) [0x7ffca4276e50]
moving from [0x7ffca4276e50] to [0x7ffca4276e80]
destructor() [0x7ffca4276e50]
destructor() [0x7ffca4276e80]
---step2---
In process function
constructor(2) [0x7ffca4276e50]
moving from [0x7ffca4276e50] to [0x7ffca4276e90]
destructor() [0x7ffca4276e50]
destructor() [0x7ffca4276e90]
---vals:---
s1 addr:[0x7ffca4276e80], val:[1]

能夠看到以上0x7ffca4276e80雖然被析構了,可是沒有那麼快被從新利用起來,第二次使用的是0x7ffca4276e90,因此結果是正確的1。可是result &&s1 = std::move(process(1))這樣的使用方式應該堅定不要用!

因此記住啊,這樣能夠:

result s1 = std::move(process(1)); //OK

這樣也能夠:

result &&s1 = process(1); //OK

這樣不能夠哦!:

result &&s1 = std::move(process(1)); //Error sometimes

參考連接

  1. https://www.cnblogs.com/tianfang/archive/2013/01/26/2878356.html
  2. https://zhuanlan.zhihu.com/p/107445960
  3. https://www.yhspy.com/2019/09/01/C-編譯器優化之-RVO-與-NRVO/
  4. https://zhuanlan.zhihu.com/p/54050093
  5. http://stupefydeveloper.blogspot.com/2008/10/c-rvo-and-nrvo.html
  6. https://en.cppreference.com/w/cpp/language/reference
  7. https://zh.wikipedia.org/wiki/返回值優化
  8. https://en.wikipedia.org/wiki/Copy_elision
  9. https://zh.wikipedia.org/wiki/值_(電腦科學)
  10. 極客時間專欄《現代C++實戰30講》 03 | 右值和移動究竟解決了什麼問題?
相關文章
相關標籤/搜索