[CPP] 左值 lvalue,右值 rvalue 和移動語義 std::move

sinkinben發表於2020-11-10

參考文章:

刷 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++ 。

相關文章