首頁 > 軟體

C++右值參照與移動建構函式基礎與應用詳解

2023-02-14 06:00:44

1.右值參照

右值參照是 C++11 引入的與 Lambda 表示式齊名的重要特性之一。它的引入解決了 C++ 中大量的歷史遺留問題, 消除了諸如 std::vector、std::string 之類的額外開銷, 也才使得函數物件容器 std::function 成為了可能。

1.1左值右值的純右值將亡值右值

要弄明白右值參照到底是怎麼一回事,必須要對左值和右值做一個明確的理解。

左值 (lvalue, left value),顧名思義就是賦值符號左邊的值。準確來說, 左值是表示式(不一定是賦值表示式)後依然存在的持久物件。

右值 (rvalue, right value),右邊的值,是指表示式結束後就不再存在的臨時物件。

而 C++11 中為了引入強大的右值參照,將右值的概念進行了進一步的劃分,分為:純右值、將亡值。

純右值 (prvalue, pure rvalue),純粹的右值,要麼是純粹的字面量,例如 10, true; 要麼是求值結果相當於字面量或匿名臨時物件,例如 1+2。非參照返回的臨時變數、運算表示式產生的臨時變數、 原始字面量、Lambda 表示式都屬於純右值。

需要注意的是,字面量除了字串字面量以外,均為純右值。而字串字面量是一個左值,型別為 const char 陣列。例如:

#include <type_traits>
int main() {
    // 正確,"01234" 型別為 const char [6],因此是左值
    const char (&left)[6] = "01234";
    // 斷言正確,確實是 const char [6] 型別,注意 decltype(expr) 在 expr 是左值
    // 且非無括號包裹的 id 表示式與類成員表示式時,會返回左值參照
    static_assert(std::is_same<decltype("01234"), const char(&)[6]>::value, "");
    // 錯誤,"01234" 是左值,不可被右值參照
    // const char (&&right)[6] = "01234";
}

但是注意,陣列可以被隱式轉換成相對應的指標型別,而轉換表示式的結果(如果不是左值參照)則一定是個右值(右值參照為將亡值,否則為純右值)。例如:

const char*   p   = "01234";  // 正確,"01234" 被隱式轉換為 const char*
const char*&& pr  = "01234";  // 正確,"01234" 被隱式轉換為 const char*,該轉換的結果是純右值
// const char*& pl = "01234"; // 錯誤,此處不存在 const char* 型別的左值
將亡值 (xvalue, expiring value),是 C++11 為了引入右值參照而提出的概念(因此在傳統 C++ 中, 純右值和右值是同一個概念),也就是即將被銷燬、卻能夠被移動的值。
將亡值可能稍有些難以理解,我們來看這樣的程式碼:
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 之後,編譯器為我們做了一些工作,此處的左值 temp 會被進行此隱式右值轉換, 等價於 static_cast<std::vector<int> &&>(temp),進而此處的 v 會將 foo 區域性返回的值進行移動。 也就是後面我們將會提到的移動語意。

1.2右值參照和左值參照

要拿到一個將亡值,就需要用到右值參照:T &&,其中 T 是型別。 右值參照的宣告讓這個臨時值的生命週期得以延長、只要變數還活著,那麼將亡值將繼續存活。

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 = lv1; // 非法, 右值參照不能參照左值
    std::string&& rv1 = std::move(lv1); // 合法, std::move可以將左值轉移為右值
    std::cout << rv1 << std::endl; // string,
    const std::string& lv2 = lv1 + lv1; // 合法, 常數左值參照能夠延長臨時變數的生命週期
    // lv2 += "Test"; // 非法, 常數參照無法被修改
    std::cout << lv2 << std::endl; // string,string,
    std::string&& rv2 = lv1 + lv2; // 合法, 右值參照延長臨時物件生命週期
    rv2 += "Test"; // 合法, 非常數參照能夠修改臨時變數
    std::cout << rv2 << std::endl; // string,string,string,Test
    reference(rv2); // 輸出左值
    return 0;
}

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

注意,這裡有一個很有趣的歷史遺留問題,我們先看下面的程式碼:

#include <iostream>
int main() {
    // int &a = std::move(1);    // 不合法,非常數左參照無法參照右值
    const int &b = std::move(1); // 合法, 常數左參照允許參照右值
    std::cout << a << b << std::endl;
}
第一個問題,為什麼不允許非常數參照系結到非左值?這是因為這種做法存在邏輯錯誤:
void increase(int & v) {
    v++;
}
void foo() {
    double s = 1;
    increase(s);
}

由於 int& 不能參照 double 型別的引數,因此必須產生一個臨時值來儲存 s 的值, 從而當 increase() 修改這個臨時值時,呼叫完成後 s 本身並沒有被修改。

第二個問題,為什麼常數參照允許繫結到非左值?原因很簡單,因為 Fortran 需要。

2.移動建構函式

傳統 C++ 通過拷貝建構函式和賦值操作符為類物件設計了拷貝/複製的概念,但為了實現對資源的移動操作, 呼叫者必須使用先複製、再解構的方式,否則就需要自己實現移動物件的介面。 試想,搬家的時候是把家裡的東西直接搬到新家去,而不是將所有東西複製一份(重買)再放到新家、 再把原來的東西全部扔掉(銷燬)

傳統的 C++ 沒有區分『移動』和『拷貝』的概念,造成了大量的資料拷貝,浪費時間和空間。 右值參照的出現恰好就解決了這兩個概念的混淆問題,例如:

#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;
}

在上面的程式碼中:

首先會在 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;
}

2.1完美的移動轉發

前面我們提到了,一個宣告的右值參照其實是一個左值。這就為我們進行引數轉發(傳遞)造成了問題:

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 l = 1;
    pass(l); // l 是左值, 輸出左值
    return 0;
}

對於 pass(1) 來說,雖然傳遞的是右值,但由於 v 是一個參照,所以同時也是左值。 因此 reference(v) 會呼叫 reference(int&),輸出『左值』。 而對於pass(l)而言,l是一個左值,為什麼會成功傳遞給 pass(T&&) 呢?

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

函數形參型別實參引數型別推導後函數形參型別
T&左參照T&
T&右參照T&
T&&amp;左參照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));
    std::cout << "static_cast<T&&> 傳參: ";
    reference(static_cast<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 傳參: 右值參照
static_cast<T&&> 傳參: 右值參照
傳遞左值:
              普通傳參: 左值參照
       std::move 傳參: 右值參照
    std::forward 傳參: 左值參照
static_cast<T&&> 傳參: 左值參照

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

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

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

讀者可能會好奇,為何一條語句能夠針對兩種型別的返回對應的值, 我們再簡單看一看 std::forward 的具體實現機制,std::forward 包含兩個過載:

template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
        " substituting _Tp is an lvalue reference type");
    return static_cast<_Tp&&>(__t);
}

在這份實現中,std::remove_reference 的功能是消除型別中的參照, std::is_lvalue_reference 則用於檢查型別推導是否正確,在 std::forward 的第二個實現中 檢查了接收到的值確實是一個左值,進而體現了坍縮規則。

當 std::forward 接受左值時,_Tp 被推導為左值,所以返回值為左值;而當其接受右值時, _Tp 被推導為 右值參照,則基於坍縮規則,返回值便成為了 && + && 的右值。 可見 std::forward 的原理在於巧妙的利用了模板型別推導中產生的差異。

這時我們能回答這樣一個問題:為什麼在使用迴圈語句的過程中,auto&& 是最安全的方式? 因為當 auto 被推導為不同的左右參照時,與 && 的坍縮組合是完美轉發。

到此這篇關於C++右值參照與移動建構函式基礎與應用詳解的文章就介紹到這了,更多相關C++右值參照內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com