參考文章:
- [1] 基礎篇:lvalue,rvalue和move
- [2] 深入淺出 C++ 右值引用
- [3] Modern CPP Tutorial
- [4] 右值引用與轉移語義
刷 Leetcode 時,時不時遇到如下 2 種遍歷 STL 容器的寫法:
int main()
{
vector<int> v = {1, 2, 3, 4};
for (auto &x: v)
cout<<x<<' ';
cout<<endl;
for (auto &&x: v)
cout<<x<<' ';
cout<<endl;
}
一個困擾我很久的問題是 auto &
和 auto &&
有什麼區別?
左值、右值、純右值、將亡值
首先要明確一個概念,值 (Value) 和變數 (Variable) 並不是同一個東西:
- 值只有 類別(category) 的劃分,變數只有 型別(type) 的劃分。
- 值不一定擁有 身份(identity),也不一定擁有變數名(例如 表示式中間結果
i + j + k
)。
定義
左值(lvalue, left value),顧名思義就是賦值符號左邊的值。準確來說, 左值是表示式(不一定是賦值表示式)後依然存在的持久物件。
右值(rvalue, right value),右邊的值,是指表示式結束後就不再存在的臨時物件。
C++11 中為了引入強大的右值引用,將右值的概念進行了進一步的劃分,分為:純右值和將亡值。
純右值 (prvalue, pure rvalue),純粹的右值,要麼是純粹的字面量,例如
10, true
; 要麼是求值結果相當於字面量或匿名臨時物件,例如1+2
。非引用返回的臨時變數、運算表示式產生的臨時變數、原始字面量、Lambda 表示式都屬於純右值。
C++( 包括 C ) 中所有的表示式和變數要麼是左值,要麼是右值。通俗的左值的定義就是非臨時物件,那些可以在多條語句中使用的物件。所有的變數都滿足這個定義,在多條程式碼中都可以使用,都是左值。右值是指臨時的物件,它們只在當前的語句中有效。
例子:
int i = 0; // ok, i is lvalue, 0 is rval
// 右值也可以出現在賦值表示式的左邊, 但是不能作為賦值的物件,因為右值只在當前語句有效,賦值沒有意義。
// 0 作為右值出現在了”=”的左邊。但是賦值物件是 i 或者 j,都是左值。
(i > 0? i : j) = 233
總結:
- 所有變數都是左值。
- 右值都是臨時的,表示式結束後不存在,立即數、表示式中間結果都是右值。
特殊情況
需要注意的是,字串字面量只有在類中才是右值,當其位於普通函式中是左值。例如:
class Foo
{
const char *&&right = "this is a rvalue"; // 此處字串字面量為右值
// const char *&right = "hello world"; // error
public:
void bar()
{
right = "still rvalue"; // 此處字串字面量為右值
}
};
int main()
{
const char *const &left = "this is an lvalue"; // 此處字串字面量為左值
// left = "123"; // error
}
將亡值
將亡值 (xvalue, expiring value),是 C++11 為了引入右值引用而提出的概念 (因此在傳統 C++ 中,純右值和右值是同一個概念),也就是即將被銷燬、卻能夠被移動的值。將亡值表示式,即:
- 返回右值引用的函式的呼叫表示式
- 轉換為右值引用的轉換函式的呼叫表示式,例如
move
先看一個例子:
vector<int> foo()
{
vector<int> v = {1,2,3,4,5};
return v;
}
auto v1 = foo();
按照傳統 C++ 的方式(也是我們這些 C++ 菜鳥的理解),上述程式碼的執行方式為:foo()
在函式內部建立並返回一個臨時物件 v
,然後執行 vector<int>
的拷貝建構函式,完成 v1
的初始化,最後對 foo
內的臨時物件進行銷燬。
那麼,在某一時刻,就存在 2 份相同的 vector
資料。如果這個物件很大,就會造成大量額外的開銷。
在 v1 = foo()
中,v1
是一個左值,可以被繼續使用,但foo()
就是一個純右值, foo()
產生的那個返回值作為一個臨時值,一 旦被 v1
複製後,將立即被銷燬,無法獲取、也不能修改。
而將亡值就定義了這樣一種行為: 臨時的值能夠被識別、同時又能夠被移動。
在 C++11 之後,編譯器為我們做了一些工作,foo()
內部的左值 v
會被進行隱式右值轉換,等價於 static_cast<vector<int> &&>(v)
,進而此處的 v1
會將 foo
區域性返回的值進行移動。也就是後面將會提到的移動語義 std::move()
。
個人的理解是,這種語法的引入是為了實現與 Java 中類似的物件引用系統。
左值引用與右值引用
區分左值引用與右值引用的例子
先看一段程式碼:
int a;
a = 2; //a是左值,2是右值
a = 3; //左值可以被更改,編譯通過
2 = 3; //右值不能被更改,錯誤
int b = 3;
int* pb = &b; //pb是左值,&b是右值,因為它是由取址運算子返回的值
&b = 0; //錯誤,右值不能被更改
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues:
int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int k = j + 2; // ok, j+2 is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue
那麼問題來了:函式返回值是否只會是右值?當然不是。
vector<int> v(10, 0);
v[0] = 111;
顯然,v[0]
會執行 []
的符號過載函式 int& operator[](const int x)
, 因此函式的返回值也是可能為左值的。
深入淺出
要拿到一個將亡值,就需要用到右值引用 T &&
,其中 T
是型別。右值引用的宣告讓這個臨時值的生命週期得以延長,只要變數還活著,那麼將亡值將繼續存活。
C++11 提供了 std::move
這個方法將左值引數無條件的轉換為右值,有了它我們就能夠方便的獲得一個右值臨時物件,例如:
#include <iostream>
#include <string>
using namespace std;
void reference(string &str) { cout << "lvalue ref" << endl; }
void reference(string &&str) { cout << "rvalue ref" << endl; }
int main()
{
string lv1 = "string,"; // lv1 is lvalue
// string &&r1 = lv1; // 非法,右值引用不能引用左值
string &&rv1 = std::move(lv1); // 合法,move 可將左值轉移為右值
cout << rv1 << endl;
// string &lv2 = lv1 + lv1; // 非法,非常量引用的初始值必須為左值
const string &lv2 = lv1 + lv1; // 合法,常量左值引用能夠延長臨時變數的生命週期
cout << lv2 << endl;
string &&rv2 = lv1 + lv2; // 合法,右值引用延長臨時物件生命週期(通過 rvalue reference 引用 rval)
rv2 += "Test";
cout << rv2 << endl;
reference(rv2); // 輸出 "lvalue ref"
// rv2 雖然引用了一個右值,但由於它是一個引用,所以 rv2 依然是一個左值。
// 也就是說,T&& Doesn’t Always Mean “Rvalue Reference”, 它既可以繫結左值,也能繫結右值
}
為什麼不允許非常量引用繫結到左值?
一種解釋如下(C++ 真傻逼)。
這個問題相當於解釋下面一段程式碼:
int i = 233;
int &r0 = i; // ok
double &r1 = i; // error
const double &r3 = i; // ok
因為 double &r1
型別與 int i
不匹配,所以不行,那為什麼 const double &r3 = i
是可以的?因為它實際上相當於:
const double t = (double)i;
const double &r3 = t;
在 C++ 中,所有的臨時變數都是 const
型別的,所以沒有 const
就不行。
移動語義
先看一段程式碼,熟悉一下 move
做了些什麼:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string a = "sinkinben";
string b = move(a);
cout << "a = \"" << a << "\"" << endl;
cout << "b = \"" << b << "\"" << endl;
}
// Output
// a = ""
// b = "sinkinben"
然後看完下面一段程式碼,結束這一回合。
template <class T> swap(T& a, T& b){
T tmp(a); //現有兩份a的拷貝,tmp和a
a = b; //現有兩份b的拷貝,a和b
b = tmp; //現有兩份tmp的拷貝,b和tmp
}
//試試更好的方法,不會生成額外的拷貝
template <class T> swap(T& a, T& b){
T tmp(std::move(a)); //只有一份拷貝,tmp
a = std::move(b); //只有一份拷貝,a
b = std::move(tmp); //只有一份拷貝,b
}
個人感覺,b = move(a)
這一語義操作,是把變數 b
繫結到資料 a
的記憶體區域上,從而避免了無意義的資料拷貝操作。
下面這一段程式碼可以印證我的這個觀點。
#include <iostream>
class A
{
public:
int *pointer;
A() : pointer(new int(1))
{
std::cout << "構造" << pointer << std::endl;
}
A(A &a) : pointer(new int(*a.pointer))
{
std::cout << "拷貝" << pointer << std::endl;
} // 無意義的物件拷貝
A(A &&a) : pointer(a.pointer)
{
a.pointer = nullptr;
std::cout << "移動" << pointer << std::endl;
}
~A()
{
std::cout << "析構" << pointer << std::endl;
delete pointer;
}
};
// 防止編譯器優化
A return_rvalue(bool test)
{
A a, b;
if (test)
return a; // 等價於 static_cast<A&&>(a);
else
return b; // 等價於 static_cast<A&&>(b);
}
int main()
{
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}
/* Output
構造0x7f8477405800
構造0x7f8477405810
移動0x7f8477405810
析構0x0
析構0x7f8477405800
obj:
0x7f8477405810
1
析構0x7f8477405810
*/
對於 queue
或者 vector
,我們也可以通過 move
提高效能:
// q is a queue
auto x = std::move(q.front());
q.pop();
// v is a vertor
v.push_back(std::move(x));
如果 STL 中的元素「體積」都很大,這麼做也能節省一點開銷,提高效能。
完美轉發
恕我直言,這個翻譯是個辣雞。英文名叫 Perfect Forwarding .
這是為了解決這樣一個問題:實參被傳入到函式中,當它被再傳到另一個函式中,它依然是一個左值或右值。
template <class T>
void f2(T t){ cout<<"f2"<<endl; }
template <class T>
void f1(T t){
cout<<"f1"<<endl;
f2(t);
//如果t是右值,我們希望傳入f2也是右值;如果t是左值,我們希望傳入f2也是左值
}
//在main函式裡:
int a = 2;
f1(3); //傳入右值
f1(a); //傳入左值
在引進?巴拉巴拉的這一套機制之前,即 C++11之前的情況是怎麼樣的呢?當我們從 f1
呼叫 f2
的時候,不管傳入 f1
的是右值還是左值,因為 t
是一個變數名,傳入 f2
的時候都變成了左值,這就會造成因為呼叫 T
的拷貝建構函式而生成不必要的拷貝浪費大量資源。
那麼現在有一個叫 forward
的函式,就可以這樣做:
template <class T>
void f2(T t){ cout<<"f2"<<endl; }
template <class T>
void f1(T&& t) { //這是通用引用,而不是右值引用
cout<"f1"<<endl;
f2(std::forward<T>(t)); //std::forward<T>(t)用來把t轉發為左值或右值,決定於T
}
這樣,f1
呼叫 f2
的時候,呼叫的就是移動建構函式而不是拷貝建構函式,可以避免不必要的拷貝,這就叫「完美轉發」。
完美轉發,傻逼到家。
結語
本文開始提出的問題 auto &
和 auto &&
有什麼區別?這個問題就更復雜了,涉及到 Universal Reference 這個概念,可以參考這 2 篇文章:
有空再說。
傻逼 C++ 。