C++11/14介紹(三)——語言運行期的強化(三)

右值引用

一、左值,右值的純右值、將亡值,右值

  • 左值:賦值符號左邊的值。左值是表達式後依然存在的持久對象

  • 右值:右邊的值。指表達式結束後就不在存在的臨時對象,C++11中爲了引入強大的右值引用,將右值的概念進行了進一步的劃分,分爲

    • 純右值:純粹的右值,要麼是純粹的字面量,ex:10,true;要麼是求值結果相當於字面量或匿名臨時對象,ex:1+2。

    • 非引用返回的臨時變量、運算表達式產生的臨時變量、原始字面量、lambda表達式都屬於純右值

    • 將亡值:即將被銷燬、卻能夠被移動的值,比如函數返回值,ex:

      std::vector<int> foo() {
          std::vector<int> temp = {1, 2, 3, 4};
          return temp;
      }
      
      std::vector<int> v = foo();
      

      在這樣的代碼中,函數 foo的返回值temp在內部創建然後被賦值給 v,然而 v獲得這個對象時,會將整個temp拷貝一份,然後把 temp 銷燬,如果這個 temp 非常大,這將造成大量額外的開銷(這也就是傳統 C++ 一直被詬病的問題)。在最後一行中,v 是左值、foo() 返回的值就是右值(也是純右值)。

      但是,v 可以被別的變量捕獲到,而 foo() 產生的那個返回值作爲一個臨時值,一旦被 v複製後,將立即被銷燬,無法獲取、也不能修改。

      將亡值就定義了這樣一種行爲:臨時的值能夠被識別、同時又能夠被移動。

二、右值引用和左值引用

C++11提供了std::move將左值參數無條件的轉換爲右值,可以方便的獲得一個右值臨時對象

#include <iostream>
#include <string>

void reference(std::string& str) {
    std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
    std::cout << "右值" << std::endl;
}

int main()
{
    std::string  lv1 = "string,";       // lv1 是一個左值
    // std::string&& r1 = s1;           // 非法, s1 在全局上下文中沒有聲明
    std::string&& rv1 = std::move(lv1); // 合法, std::move 可以將左值轉移爲右值
    std::cout << "rv1 = " << rv1 << std::endl;      // string,

    const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能夠延長臨時變量的生命週期
    // lv2 += "Test";                   // 非法, 引用的右值無法被修改
    std::cout << "lv2 = "<<lv2 << std::endl;      // string,string

    std::string&& rv2 = lv1 + lv2;      // 合法, 右值引用延長臨時對象的生命週期
    rv2 += "string";                    // 合法, 非常量引用能夠修改臨時變量
    std::cout << "rv2 = " << rv2 << std::endl;      // string,string,string,

    reference(rv1);                     // 輸出左值
    reference(rv2);                     // 輸出左值
}

rv2 雖然引用了一個右值,但由於它是一個引用,所以 rv2 依然是一個左值。

三、移動語義

傳統C++通過拷貝構造函數和賦值操作符爲類對象設計了拷貝/複製的概念,但爲了實現對資源的移動操作,調用者必須先複製,再析構的方式。也就是將對象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;
    else return 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;
}

輸出

構造0x780d90    //對象a地址
構造0x780f30    //對象b地址
移動0x780f30    //對象b中的移動構造,延長生命週期
析構0           //對象b的析構,將亡值指針設置爲nullptr
析構0x780d90    //對象a析構
obj:
0x780f30
1
析構0x780f30
  • 首先在 return_rvalue內部構造兩個A對象,於是獲得兩個構造函數的輸出
  • 函數返回後,產生一個將亡值,被A的移動構造(A(A&&))引用,從而延長生命週期,並將這個右值中的指針拿到,保存到obj中,而 將亡值 的指針被設置爲nullptr,防止了這塊內存區域被銷燬。

涉及標準庫的例子:

#include <iostream> // std::cout
#include <utility>  // std::move
#include <vector>   // std::vector
#include <string>   // std::string

int main() {

    std::string str = "Hello world.";
    std::vector<std::string> v;

    // 將使用 push_back(const T&), 即產生拷貝行爲
    v.push_back(str);
    // 將輸出 "str: Hello world."
    std::cout << "str: " << str << std::endl;

    // 將使用 push_back(const T&&), 不會出現拷貝行爲
    // 而整個字符串會被移動到 vector 中,所以有時候 std::move 會用來減少拷貝出現的開銷
    // 這步操作後, str 中的值會變爲空
    v.push_back(std::move(str));
    // 將輸出 "str: "
    std::cout << "str: " << str << std::endl;

    return 0;
}

四、完美轉發

在右值引用中,一個聲明的右值引用其實是一個左值,這就爲參數轉發(傳遞)造成了困難

void reference(int& v) {
    std::cout << "左值" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通傳參:";
    reference(v);   // 始終調用 reference(int& )
}
int main() {
    std::cout << "傳遞右值:" << std::endl;
    pass(1);        // 1是右值, 但輸出左值

    std::cout << "傳遞左值:" << std::endl;    
    int v = 1;
    pass(v);        // v是左引用, 輸出左值

    return 0;
}

輸出:

傳遞右值:
普通傳參:左值
傳遞左值:
普通傳參:左值

對於pass(1)來說,雖然傳遞的是右值,但由於v是一個引用,所以同時也是左值。因此reference(v)會輸出[左值],而對於pass(v)而言,v是一個左值,應該是不能傳給pass(T&&)的,但是卻傳過去了,其原因如下:

這是基於引用坍縮規則:在傳統C++中,我們不能夠對一個引用類型繼續進行引用,但C++由於右值引用的出現而放寬了這一做法,從而產生了引用坍縮規則,允許對引用進行引用,既能左引用又可以右引用。遵循以下規則:

函數形參類型 實參參數類型 推導後函數形參類型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

因此,模板函數中使用 T&& 不一定能進行右值引用,當傳入左值時,此函數的引用將被推導爲左值。更準確的講,無論模板參數是什麼類型的引用,當且僅當實參類型爲右引用時,模板參數才能被推導爲右引用類型。這才使得 v 作爲左值的成功傳遞。

完美轉發就是基於上述規律產生的。所謂完美轉發,就是爲了讓我們在傳遞參數的時候,保持原來的參數類型(左引用保持左引用,右引用保持右引用)。爲了解決這個問題,我們應該使用 std::forward 來進行參數的轉發(傳遞)

#include <iostream>
#include <utility>
void reference(int& v) {
    std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通傳參:";
    reference(v);
    std::cout << "std::move 傳參:";
    reference(std::move(v));
    std::cout << "std::forward 傳參:";
    reference(std::forward<T>(v));

}
int main() {
    std::cout << "傳遞右值:" << std::endl;
    pass(1);

    std::cout << "傳遞左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
}

輸出:

傳遞右值:
普通傳參:左值引用
std::move 傳參:右值引用
std::forward 傳參:右值引用
傳遞左值:
普通傳參:左值引用
std::move 傳參:右值引用
std::forward 傳參:左值引用

無論傳遞參數爲左值還是右值,普通傳參都會將參數作爲左值進行轉發,所以 std::move總會接受到一個左值,從而轉發調用了reference(int&&)輸出右值引用。

唯獨 std::forward 即沒有造成任何多餘的拷貝,同時完美轉發(傳遞)了函數的實參給了內部調用的其他函數。

std::forward和 std::move一樣,沒有做任何事情,std::move 單純的將左值轉化爲右值,std::forward 也只是單純的將參數做了一個類型的轉換,從實現來看,std::forward(v) 和 static_cast<T&&>(v) 是完全一樣的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章