C++11 右值引用詳解


一、左值和右值

左值與右值是C++中表達式的屬性,在C++11中,每個表達式有兩個屬性:類型(type,除去引用特性,用於類型檢查)和值類型(value category,用於語法檢查,比如一個表達式結果是否能被賦值)。值類型包括3個基本類型:lvalueprvaluexrvalue。后兩者又統稱為rvaluelvalue我們稱為左值,可以將左值看成是一個可以獲取地址的量,它可以用來標識一個對象或函數。rvalue稱為右值,最簡單的解釋是所有不是左值的量就是右值。

通常情況下,判斷某值是左值還是右值有兩種方法:

1、可位於賦值號(=)左側的表達式就是左值;反之,只能位於賦值號右側的表達式就是右值。例如,

int v1 = 5;
5 = v1;       // 錯誤,5不能為左值
/**
 * 變量 a 就是一個左值,而字面量 5 就是一個右值。
 * C++ 中的左值也可以當做右值使用,如下:
 */
int v2 = 10;  // b 被聲明為一個左值
v1 = v2;      // a、b 都是左值,而 b 可以當作右值使用

2、有名稱的、可以獲取到存儲地址的表達式為左值,反之為右值。以上面定義的變量 v1、v2 為例,v1 和 v2 是變量名,且通過 &v1 和 &v2 可以獲得他們的存儲地址,因此 v1 和 v2 都是左值;反之,字面量 5、10,它們既沒有名稱,也無法獲取其存儲地址(字面量通常存儲在寄存器中,或者和代碼存儲在一起),因此 5、10 都是右值。

注意:以上 2 種判定方法只適用於大部分場景,而非全部場景,更多內容請參考cppreference

二、左值引用

左值引用在C++11之前被簡稱為引用,語法簡單並可以被 const 修飾,對於 non-const 引用,只能通過 non-const 左值來初始化。例如,

int v       = 20;
int &rv1    = v;    // non-const 引用可以被 non-const 左值初始化
const int y = 10;
int &rv2    = y;    // 非法。non-const 引用不能被 const 左值初始化
int &rv3    = 10;   // 非法。non-const 引用不能被右值初始化

而 const 引用的限制就很少了:

int v       = 10;
const int x = 20;
​
const int &rv1 = v;     // const 引用可以被 non-const 左值初始化
const int &rv2 = x;     // const 引用可以被 const 左值初始化
const int &rv3 = 9;     // const 引用可以被右值初始化

三、右值引用

C++11之前,右值被認為是無用資源,所以在c++11中引入右值引用,就是為了重用右值。定義右值引用需要使用&&

int &&rrv = 200;

右值引用一定不能被左值所初始化,只能使用右值初始化:

int v = 20;           // 左值
int &&rrv1 = v;       // 非法。右值引用無法被左值初始化
const int &&rrv2 = v; // 非法。右值引用無法被左值初始化

原因:右值引用的目的是為了延長用來初始化的對象生命周期,對於左值,其生命周期與其作用域有關,則沒必要延長。若延長,則會出現下面的情況。

int v = 20;         // 左值
int &&rv = v * 2;   // v*2 的結果是個右值,rv延長其生命周期
int y = rv + 2;     // 因此 rv 可被重用,y = 42
rv = 100;           // 一旦初始化一個右值引用變量,該變量就成為左值並可以被賦值

重要:初始化之后的右值引用將變成一個左值,如果是 non-const 還可以被賦值。

右值引用還可以用作函數參數被傳遞,如下:

// 接收左值
void fun(int &lref)
{
    std::cout << "l-value reference" << std::endl;
}
​
// 接收右值
void fun(int &&rref)
{
    std::cout << "r-value reference" << std::endl;
}
​
int main()
{
    int v = 10;
    fun(v);     // output: l-value reference
    fun(10);    // output: r-value reference
    
    return 0;
}

可以看到,函數參數要區分開右值引用與左值引用,這是兩個不同的重載版本。另外,如果你定義了下面的函數:

void fun(const int &clref)
{
    std::cout << "l-value const reference" << std::endl;
}

則其不僅可以接收左值,而且可以接收右值(前提是你沒有提供接收右值引用的重載版本)。

四、左右值引用使用場景

引用類型 可以引用的值類型 ^^ ^^ ^^ 使用場景
  非常量左值 常量左值 非常量右值 常量右值  
非常量左值引用 Y N N N
常量左值引用 Y Y Y Y 常用於類中構建拷貝構造函數
非常量右值引用 N N Y N 移動語義、完美轉發
常量右值引用 N N Y Y 無實際用途

五、移動語義和完美轉發

1)概念

  • 移動語義:將內存的所有權從一個對象轉移到另外一個對象,高效的移動用來替換效率低下的復制,對象的移動語義需要實現移動構造函數(move constructor)和移動賦值運算符(move assignment operator)。

  • 完美轉發:定義一個函數模板,該函數模板可以接收任意類型參數,然后將參數轉發給其它目標函數,且保證目標函數接受的參數其類型與傳遞給模板函數的類型相同。

2)移動語義詳解

有了右值引用的概念,就可以理解移動語義了。前面講到,一個對象的移動語義的實現是通過移動構造函數與移動賦值運算符來實現的。所以,為了理解移動語義,我們從一個對象出發,下面創建一個動態數組類:

template <typename T>
class DynamicArray
{
public:
    explicit DynamicArray(int size) : m_size(size), m_array(new T[size])
    {
        std::cout << "Constructor: dynamic array is created!" << std::endl;
    }
    
    virtual ~DynamicArray()
    {
        delete[] m_array;
        std::cout << "Destructor: dynamic array is destroyed!" << std::endl;
    }
    
    // 拷貝構造函數
    DynamicArray(const DynamicArray& rhs) : m_size(rhs.m_size)
    {
        m_array = new T[m_size];
        
        for (int i = 0; i < m_size; ++i)
        {
            m_array[i] = rhs.m_array[i];
        }
        std::cout << "Copy constructor: dynamic array is created!" << std::endl;
    }
    
    // 重載賦值運算符
    DynamicArray& operator = (const DynamicArray &rhs)
    {
        std::cout << "Copy assignment operator is called." << std::endl;
        
        if (&rhs == this)
        {
            return *this;
        }
        delete[] m_array;
​
        m_size = rhs.m_size;
        m_array = new T[m_size];
        
        for (int i = 0; i < m_size; ++i)
        {
            m_array[i] = rhs.m_array[i];
        }
​
        return *this;
    }
    
    // 重載索引運算符
    T& operator [] (int index)
    {
        // 不進行邊界檢查
        return m_array[index];
    }
    
    const T& operator [] (int index) const
    {
        return m_array[index];
    }
    
    int Size() const 
    {
        return m_size;
    }
​
private:
    T  *m_array;
    int m_size;
};

我們通過在堆上動態分配內存來實現動態數組類,類中實現拷貝構造函數、賦值運算符重載以及索引操作符重載。現假設定義一個生產動態數組的工廠函數:

// 生成 int 動態數組的工廠函數
DynamicArray<int> ArrayFactor(int size)
{
    DynamicArray<int> arr(size);
    return arr;
}

然后使用以下代碼進行測試:

int main()
{
    DynamicArray<int> arr = ArrayFactor(10);
    return 0;
}

輸出:

Constructor: dynamic array is created!
Copy constructor: dynamic array is created!
Destructor: dynamic array is destroyed!
Destructor: dynamic array is destroyed!

現在,讓我們來解讀一下這個輸出。首先,調用ArrayFactor()函數,內部創建了一個動態數組,所以普通構造函數被調用。然后將這個動態數組返回,但是這個對象是函數內部的,函數外是無法獲得的,所以要生成一個臨時對象,然后用這個動態數組初始化,函數最終返回的是臨時對象。很明顯這個動態數組即將消亡,所以其是右值,那么在構建臨時對象時,會調用拷貝構造函數(沒有右值的版本,但是右值可以傳遞給const左值引用參數)。但是問題又來了,因為返回的這個臨時對象又拿去初始化另外一個對象arr,當然調用也是拷貝構造函數。調用兩次拷貝構造函數完全沒有必要,編譯器也會這么想,所以將其優化:直接拿函數內部創建的動態數組去初始化arr。因此僅有一次拷貝構造函數被調用,但是一旦完成arr的創建,那個動態數組對象就被析構了。最后arr離開其作用域被析構。不難發現盡管編譯器做了優化,但是還是導致對象被創建了兩次,函數內部創建的動態數組僅僅是一個中間對象,用完后就被析構了,有沒有可能直接將其申請的空間直接轉移到arr,那么資源得以重用,實際上只用申請一份內存。但是問題的關鍵是復制構造函數執行的是復制,不是轉移,無法實現這樣的功能。此時,則需要移動構造函數:

// 移動構造函數
DynamicArray::DynamicArray(DynamicArray &&rhs) 
    : m_size(rhs.m_size), m_array(rhs.m_array)
{
    rhs.m_size = 0;
    rhs.m_array = nullptr;
    std::cout << "Move constructor: dynamic array is moved!" << std::endl;
}
​
// 移動賦值運算符
DynamicArray& DynamicArray::operator = (DynamicArray &&rhs)
{
    std::cout << "Move assignment operator is called." << std::endl;
    
    if(&rhs == this)
    {
        return *this;
    }
    delete[] m_array;
    
    m_size  = rhs.m_size;
    m_array = rhs.m_array;
    
    rhs.m_size  = 0;
    rhs.m_array = nullptr;
    
    return *this;
}

上面是移動構造函數與移動賦值操作符的實現,相比復制構造函數與復制賦值操作符,前者沒有再分配內存,而是實現內存所有權轉移。那么使用相同的測試代碼,其結果是:

Constructor: dynamic array is created!
Move constructor: dynamic array is moved!
Destructor: dynamic array is destroyed!
Destructor: dynamic array is destroyed!

可以看到,在拷貝時調用的是移動構造函數,那么函數內部申請的動態數組直接被轉移到arr。從而減少了一份相同內存的申請與釋放。注意析構函數被調用兩次,這是因為盡管內部進行了內存轉移,但是臨時對象依然存在,只不過第一次析構函數析構的是一個nullptr,這不會對程序有影響。其實通過這個例子,我們也可以看到,一旦已經自己創建了拷貝構造函數與重載賦值運算符后,編譯器不會創建默認的移動構造函數和移動賦值運算符,這點要注意。最好能將其手動實現。

總結:這就是移動語義,用移動而不是復制來避免無必要的資源浪費,從而提升程序的運行效率。其實在C++11中,STL的容器都實現了移動構造函數與移動賦值運算符,這將大大優化了STL容器的使用效率。

3)std::move

通過對移動語義技巧的講解,我們知道對象的移動語義是依靠移動構造函數和移動賦值操作符實現的。但是前提是傳入的必須是右值,但是有時候你需要將一個左值也進行移動語義(因為你已經知道這個左值后面不再使用),那么就必須提供一個機制來將左值轉化為右值。在C++中,std::move就是專為此而生,看下面的例子:

std::vector<int> v1 = {1, 2, 3, 4};
std::vector<int> v2 = v1;            // 通過運算符重載進行拷貝,v2 是 v1 的副本
std::vector<int> v3 = std::move(v1); // 通過移動構造函數移動語義,v3 與 v1 交換, v1 為空, v3 = {1, 2, 3, 4}

可以看到,通過std::move可以將v1轉化為右值,從激發v3的移動構造函數,實現移動語義。

C++中利用std::move實現移動語義的一個典型函數是std::swap(實現兩個對象的交換)。C++11之前,std::swap的實現如下:

template <typename T>
void swap(T &a, T &b)
{
    T tmp(a);  // 調用復制構造函數
    a = b;     // 復制賦值運算符
    b = tmp;   // 復制賦值運算符
}

從上面的實現可以看出:共進行了3次復制。如果類型T比較占內存,那么交換的代價是非常昂貴的。但是利用移動語義,卻可以更加高效地交換兩個對象:

template <typename T>
void swap(T& a, T& b)
{
    T tmp(std::move(a));    // 調用移動構造函數
    a = std::move(b);       // 調用移動賦值運算符
    b = std::move(tmp);     // 調用移動賦值運算符
}

僅通過三次移動,實現兩個對象的交換,由於沒有復制,效率更高。

此時,你可能會想,std::move函數內部到底是怎么實現的。其實std::move函數並不“移動”,它僅僅進行了類型轉換。下面給出一個簡化版本的std::move

template <typename T>
typename remove_reference<T>::type&& move(T &&param)
{
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
}

代碼很短,但是估計很難懂。首先看一下函數的返回類型,remove_reference在頭文件中,remove_reference<T>有一個成員type,是T去除引用后的類型,所以remove_reference<T>::type&&一定是右值引用,對於返回類型為右值的函數其返回值是一個右值(准確地說是xvalue)。所以,知道了std::move函數的返回值是一個右值。然后,我們看一下函數的參數,使用的是通用引用類型(&&),意味者其可以接收左值,也可以接收右值。其推導規則如下:如果實參是左值,推導后的形參是左值引用,如果是右值,推導出來的是右值引用。但是不管怎么推導,ReturnType的類型一定是右值引用,最后std::move函數只是簡單地調用static_cast將參數轉化為右值引用。所以,std::move什么也沒有做,只是告訴編譯器將傳入的參數無條件地轉化為一個右值。所以,當你使用std::move作用於一個對象時,你只是告訴編譯器這個對象要轉化為右值,然后就有資格進行移動語義了。

下面舉一個由於誤用std::move而無效的例子。假如你在設計一個標注類,其構造函數接收一個std::string類型參數作為標注文本,你不希望它被修改,所以標注為const,然后將其復制給其的一個數據成員,你可能會使用移動語義,現假設的移動語義設計如下:

class Annotation
{
public:
    explicit Annotation(const std::string& text) : 
    m_text (std::move(text)) {}
​
    const std::string& getText() const 
    {
        return m_text;
    }
    
private:
    std::string m_text;
};

當使用以下代碼進行測試時:

int main()
{
    std::string text("hello");
    Annotation ant(text);
​
    std::cout << ant.getText() << std::endl;  // output: hello
    std::cout << text << std::endl;           // output: hello 不是空,移動語義沒有實現
return 0;
}

我們會發現移動語義並沒有被實現,這是為什么呢?首先,從直觀上看,假如你移動語義成功了,那么text會發生改變,這會違反其const屬性。所以,這樣不大可能成功。其實,std::move函數會在推導形參時會保持形參的const屬性,所以其最終返回的是一個const右值引用類型,那么m_text(std::move(text))到底會調用什么構造函數呢?我們知道std::string的內部有兩個構造函數可能會被匹配:

class string
{
    // ...
    string(const string &rhs);   // 拷貝構造函數
    string(string &&rhs);        // 移動構造函數
};

那么到底會匹配哪個呢?肯定的是移動構造函數不會被匹配,因為不接受const對象,則拷貝構造函數會被匹配,因為前面講過const左值引用可以接收右值,const右值更可以。所以,我們其實調用了復制構造函數,那么移動語義當然無法實現。所以,如果你想進行移動語義,則不要把std::move引用在const對象上。

4)std::forward和完美轉發

完美轉發就是創建一個函數,該函數可以接收任意類型的參數,然后將這些參數按原來的類型轉發給目標函數,完美轉發的實現要依靠std::forward函數。下面就定義了這樣一個函數:

// 目標函數
void foo(const std::string &str);   // 接收左值
void foo(std::string &&str);        // 接收右值
​
template <typename T>
void Wrapper(T &&param)
{
    foo(std::forward<T>(param));  // 完美轉發
}

首先要有一點要明確,不論傳入Wrapper的參數是左值還是右值,一旦傳入之后,param一定是左值,然后我們來具體分析這個函數:

  • 當一個為std::string類型的右值傳遞給Wrapper時,T被推導為std::stringparam為右值引用類型,但是一旦傳入后,param就變成了左值,所以直接轉發給foo函數,將丟失param的右值屬性,那么std::forward就確保傳入foo的值還是一個右值;

  • 當類型為const std::string的左值傳遞給Wrapper時,T被推導為const std::string&paramconst左值引用類型,傳入后,param仍為const左值類型,所以直接轉發給foo函數,沒有問題,此時應用std::forward函數可以看成什么也沒有做;

  • 當類型為std::string的左值傳遞給Wrapper時,T被推導為std::string&param為左值引用類型,傳入后,param仍為左值類型,所以直接轉發給foo函數,沒有問題,此時應用std::forward函數可以看成什么也沒有做;

綜上所述,Wrapper函數可以實現完美轉發,其關鍵點在於使用了std::forward函數確保傳入的右值依然轉發為右值,而對左值傳入不做處理。

那么,std::forward到底怎么實現的,如下:

template <typename T>
T&& forward(typename remove_reference<T>::type &param)
{
    return static_cast<T &&>(param);
}

代碼依然與std::move一樣簡潔,我們結合Wrapper來看,如果傳入Wrapper函數中的是std::string左值,那么推導出Tstd::string&,則將調用std::foward<std::string&>,根據std::foward的實現,其實例化為:

std::string& && forward(typename remove_reference<std::string&>::type &param)
{
    return static_cast<std::string& &&>(param);
}

連續出現3個&符號有點奇怪,我們知道C++不允許引用的引用,那么其實編譯器這里進行是引用折疊(reference collapsing,大致就是后面的引用消掉),因此,變成:

std::string& forward(std::string &param)
{
    return static_cast<std::string &>(param);
}

上面的代碼就很清晰了,一個左值引用的參數,然后還是返回左值引用,此時的std::foward就是什么也沒有做,因為傳入與返回完全一樣。

那么如果傳入Wrapper函數中的是std::string右值,那么推導出Tstd::string,那么將調用std::foward<std::string>,根據std::foward的實現,其實例化為:

std::string&& forward(typename remove_reference<std::string>::type &param)
{
    return static_cast<std::string &&>(param);
}

簡化成:

std::string&& forward(std::string &param)
{
    return static_cast<std::string &&>(param);
}

參數依然是左值引用(這點是一致的,因為前面說過傳入std::forward中的實參一直是左值),但是返回的是右值引用,此時的std::foward就是將一個左值轉化了右值,這樣保證傳入目標函數的實參是右值!

綜上,可以看到std::foward函數是有條件地將傳入的參數轉化為右值,而std::move無條件地將參數轉化為右值,這是兩者的區別。但是本質上,兩者什么沒有做,最多就是進行了一次類型轉換。

六、Reference

1、cppreference C++參考手冊

2、Modern C++ 學習筆記:C++右值引用

3、C語言中文網:C++右值引用

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM