rvalue 參考


參考〉中談到,參考是物件的別名,在 C++ 中,「物件」這個名詞,不單只是指類別的實例,而是指記憶體中的一塊資料,那麼可以參考字面常量嗎?常量無法使用 & 取址,例如無法 &10,因此以下會編譯錯誤:

int &r = 10; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

不過,加上 const 的話倒是可以:

const int &r = 10; 

常量是記憶體中臨時的資料,無法對常量取址,因此編譯器會將以上轉換為像是:

const int _n = 10;
const int &r = _n;

實際上,r 並不是真的參考至 10,而是 10 被複製給 _n,然後 r 參考至 _n,如果不加上 const,那麼你可能會以為變更了 r,就是變更了 10 位址處的值,因此就要求你一定得加上 const,不讓你改了。

為什麼會需要參考至常量?通常跟函式呼叫相關,這之後文件再來討論;類似地,以下會編譯失敗:

int a = 10;
int b = 20;
int &r = a + b; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

這是因為 a + b 運算出的結果,會是在臨時的記憶體空間中,無法取址;類似地,若想通過編譯,必須加上 const

int a = 10;
int b = 20;
const int &r = a + b; 

不過在 C++ 11 之後,像以上的運算式,可以直接參考了:

int a = 10;
int b = 20;
int &&rr = a + b;

在以上的程式中,int&& 是 rvalue 參考(rvalue reference),rr 參考了 a + b 運算結果的空間,相對於以下的程式來說比較有效率:

int a = 10;
int b = 20;
int c = a + b; // 將 a + b 的結果複製給 c

因為不必有將值複製、儲存至 c 的動作,效率上比較好,特別是當 rvalue 運算式會產生龐大物件的時候,複製就會是個成本考量,例如 s1s2 若是個很長的 string,那麼 s1 + s2 的結果還會複製給目標 string 的話:

string result = s1 + s2;

改用以下會比較經濟:

string &&result = s1 + s2;

相對於 rvalue 參考,int& 這類參考就被稱為 lvalue 參考;只不過,lvalue 或 rvalue 是什麼?方才編譯錯誤的訊息中,似乎也出現了 lvalue、rvalue 之類的字眼,這些是什麼?

lvalue、rvalue 是 C++ 對運算式(expression)的分類方式,一個粗略的判別方式,是看看 & 可否對運算式取址,若可以的話,運算式是 lvalue,否則是個 rvalue。

若要精確的定義,可以參考〈Value categories〉,該文件中 History 的區段,有談到運算式分類的歷史,最早是從 CPL 開始對運算式區分為左側模式(left-hand mode)與右側模式(right-hand mode),左、右是指運算式是在指定的左或右側,有些運算式只有在指定的左側才會有意義。

C 語言有類似的分類方式,分為 lvalue 與其他運算式,l 似乎暗示著 left 的首字母,不過實際上,並非以指定的左、右來分類,lvalue 是指可以識別物件的運算式,白話點的說法是,運算式的結果會是個有名稱的物件。

到了 C++ 98,非 lvalue 運算式被稱為 rvalue,一些 C 中非 lvalue 的運算式成了 lvalue,到了 C++ 11,運算式又被重新分類為〈Value categories〉中的結果。

許多文件取 lvalue、rvalue 的 l、r,將它們分別譯為左值、右值,就運算式的分類歷史來說,不能說是錯,不過嚴格來說,C++ 中 lvalue、rvalue 的 l、r,並沒有左、右的意思,lvalue、rvalue 只是個分類名稱。

在〈Value categories〉一開頭,可以看到目前的 C++ 標準,將運算式更細分為 glvalue、prvalue、xvalue、lvalue 與 rvalue,g 暗示為 generalized,pr 暗示為 pure,x 暗示為 eXpiring,就涵蓋關係而言,使用圖來表示會比較清楚:

rvalue 參考

具體來說,哪個運算式屬於哪個分類,〈Value categories〉都有舉例,當然,容易看到眼花花…

方才談到,一個粗略的判別方式,是看看 & 可否對運算式取址,若可以的話,運算式是 lvalue,否則是個 rvalue;另一個白話點的判別方式是,lvalue 運算式的結果會是個有名稱的物件,例如 a,rvalue 的結果是暫時性存在於記憶體,例如 a + b

那麼 ++ii++ 呢?在〈遞增、遞減、指定運算〉中談過,++i 運算結果是遞增後的 i,也就是 ++i 運算結果是個有名稱的物件,因此可以使用 lvalue 參考:

int i = 10;
int &r = ++i; // OK

然而 i++ 運算結果是遞增前的 i,暫時性存在於記憶體,若不指定給變數的話就不見了,因此 i++ 是個 rvalue,因此以下會編譯失敗:

int i = 10;
int &r = i++; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

C++ 11 開始,若想參考 i++ 運算時暫時存在於記憶體中遞增前的 i,可以使用 rvalue 參考:

int i = 10;
int &&rr = i++; // OK

哪些是 lvalue,而哪些又是 rvalue,基本上還是以〈Value categories〉的定義為準,不清楚的話就查一下。

使用 rvalue 參考通常是為了效率上的考量,

還有個 std::move(定義於 utility 標頭檔)用來實現移動語義(move semantics),例如實現移動建構式(move constructor),這需要在認識類別定義、複製建構式等之後才能細談,就現階段而言,可以從 string 來稍微認識一下,例如,以下會將 s1 的資料複製給 s2

string s2 = s1;    // s1 是個 string,而這邊會複製 s1 的內容給 s2

s1 指定給 s2 後,就不再會用到原本的內容,那麼複製就是不必要的成本,若能把 s1 的內容直接移給 s2 的話就好了,C++ 11 開始可以這麼做:

string s2 = std::move(s1);

這麼一來,s1 的資料就被移至 s2 了,在這之後不能立即使用 s1 來取值,因為資料轉移出去了,取值結果是不可預期的,只能銷毀 s1,或者是重新指定字串給 s1

來看個簡單的示範:

#include <iostream> 
#include <string>
using namespace std; 

int main() { 
    string s1 = "abc";
    string s2 = s1;     //  複製 s1 的資料

    cout << s1 << endl; // 顯示 "abc"
    cout << s2 << endl; // 顯示 "abc"
} 

跟移動版本比較一下:

#include <iostream> 
#include <string>
#include <utility>

using namespace std; 

int main() { 
    string s1 = "abc";
    string s2 = std::move(s1);    //  轉移 s1 的資料

    // cout << s1 << endl;        // 這時取值結果不可預期
    cout << s2 << endl;           // 顯示 "abc"

    s1 = "xyz";                   // OK
    cout << s1 << endl;           // 這時可以取值
} 

移動版本之所以能夠運作,是因為 string 的建構式之一,使用了 rvalue 參考,而 std::move 的作用,其實是告訴編譯器,將指定的 lvalue 當成是 rvalue(某些程度就是一種 cast),以選擇定義了 rvalue 參考的建構式,而建構式中實現了移動來源資料的演算。

因為 move 這個名稱太平凡了,為了避免名稱衝突,建議包含 std 名稱空間,也就是使用 std::move