C++11在左值引用的基礎上增加右值引用

前言

右值引用這個詞是最開始是學習 easylogging++ 這個日誌開源項目的時候遇到的,當時遇到 && 這樣的寫法先是一愣,還有這種寫法?難道是引用的地址?結果查詢資料才明白這叫做右值引用。

右值引用的出現

其實右值引用是在 C++11 時增加的新內容,在此之前,引用是沒有左值和右值之分的,只存在一種引用,也就是後來 C++11 標準中的左值引用,而右值引用的提出主要是爲了解決之前左值引用出現的一些尷尬的問題。

左值和右值

說到右值引用需要先了解下左值和右值,這也是我自己學習的過程,之前在 《簡單聊聊C/C++中的左值和右值》 這篇筆記中總結過,可以簡單理解左值就是放在 = 左邊,可以取到地址,可以被賦值的表達式,而右值通常是放在 = 右側,不能取地址,只能被當成一個“值”的表達式。

右值引用的作用

右值引用的出現並不是爲了取代左值引用,也不是和左值引用形成對立,而是充分利用右值內容來減少對象構造和析構操作,以達到提高程序代碼效率的目的。

也就是說增加右值引用這個特性是爲了提高效率,之前的總結中也提到過,在 C++11 中還引入了 std::move() 函數,並用這個函數改寫了 std::remove_if() 函數,這就是提高效率的例子。

使用 std::move() 函數意味着放棄所有權,對於一個左值,如果我們明確放棄對其資源的所有權,則可以通過 std::move() 來將其轉爲右值引用,放棄所有權的這個操作不一定都是方便的,比如 std::auto_ptr 這個第一代的智能指針,就是因爲轉移了所有權,使用起來不太方便,纔在最新標準中被廢棄的。但如果你明確要轉移所有權,並且合理使用,有時可以有效的提高程序效率。

引用類型的對比

在學習使用右值引用之前先複習一下左值引用,對比學習更有利於我們的記憶。

左值引用

int i = 22;
int& j = i;

j = 11;

上面這幾行代碼就是最常見左值引用的例子,變量 j 引用了變量 i 的存儲位置,修改變量 j 就修改了變量 i 的值,但是如果引用一個值會怎麼樣呢?比如下面這行代碼:

int& j = 22;

編譯這行代碼會得到一個編譯錯誤:

error: invalid initialization of non-const reference of type ‘int&’ from an rvalue of type ‘int’
int& j = 22;

像上面這種問題,可以使用常量引用來解決。

常量引用

針對上面的編譯錯誤,改成常量引用就可以通過編譯了,就像這樣:

const int& j = 22;

使用常量引用來引用數字常量22,可以編譯通過是因爲內存上產生了臨時變量保存了22這個數據,這個臨時變量是可以進行取地址操作的,因此變量 j 引用的其實是這個臨時變量,相當於下面的這兩句:

const int temp = 22;
const int &j = temp;

看到這裏我們發現常量引用可以解決引用常量的問題,那麼爲什麼非得新增一個右值引用呢?那是因爲使用常引用後,我們只能通過引用來讀取數據,無法去修改數據,這在很多情況下是很不方便的。

右值引用

常量引用可以使用右值引用來改寫,改寫之後可以正常編譯,並且還可以進行修改:

int&& j = 22;

這句代碼有兩個需要注意的點,第一是右值引用是 C++11 中才增加的,所以需要增加 --std=c++11 這個編譯選項才能正常編譯,第二是右值引用的兩個地址符需要連着寫成 &&, 如果中間有空格寫成 & & 會被認爲是引用的引用而導致編譯錯誤,這是不符合語法的。

右值引用的示例

前面對引用類型進行了對比,但是還沒有發現右值引用的好處,接下來用一個例子來展示一下增加右值引用之前的寫法,和使用右值引用的寫法,通過對比來了解一下右值引用究竟有什麼好處。

我們來實現一個自定義緩衝區,先使用最常見的方法來實現拷貝構造函數和拷貝賦值函數,簡單實現如下,功能不太完整,但是可以說明右值引用的作用:

常量引用實現

#include <iostream>
#include <cstring>
using namespace std;


class CBuffer
{
public:
    // 構造函數
    CBuffer(int size = 1024): m_size(size)
    {
        cout << "CBuffer(int)" << endl;
        m_buffer = new char[size];
    }

    // 析構函數
    ~CBuffer()
    {
        cout << "~CBuffer()" << endl;
        delete[] m_buffer;
        m_buffer = nullptr;
        m_size = 0;
    }

    // 拷貝構造
    CBuffer(const CBuffer &origin): m_size(origin.m_size)
    {
        cout << "CBuffer(const CBuffer&)" << endl;
        m_buffer = new char[origin.m_size];
        memcpy(m_buffer, origin.m_buffer, m_size);
    }

    // 賦值重載
    CBuffer& operator=(const CBuffer &origin)
    {
        cout << "operator=(const CBuffer&)" << endl;
        if (this == &origin) return *this;

        delete[] m_buffer;

        m_size = origin.m_size;
        m_buffer = new char[origin.m_size];
        memcpy(m_buffer, origin.m_buffer, m_size);

        return *this;
    }

    int get_size()
    {
        return m_size;
    }

    static CBuffer gen_buffer(const int size)
    {
        CBuffer temp_buffer(size);
        return temp_buffer;
    }

private:
    char *m_buffer;
    int m_size;
};

int main()
{
    CBuffer b1;
    CBuffer b2(b1);
    cout << "b1.size = " << b1.get_size() << endl;
    cout << "b2.size = " << b2.get_size() << endl;

    b2 = CBuffer::gen_buffer(100);
    return 0;
}

運行結果是:

CBuffer(int)
CBuffer(const CBuffer&)
b1.size = 1024
b2.size = 1024
CBuffer(int)
operator=(const CBuffer&)
~CBuffer()
~CBuffer()
~CBuffer()

這個例子不具有實用性,只爲了說明問題,CBuffer 這個類定義爲了拷貝構造函數並且重載了 = 運算符,兩個函數參數均使用常量引用的類型,這就是一般的寫法。

但是這樣實現有一個問題,因爲參數是常量引用,所以沒辦法修改原對象的值,我們看到拷貝構造和賦值重載兩個函數中都有申請空間和拷貝的操作,這種操作在操作內存較大的對象是比較耗時,所以應該儘量避免,我們想到可以使用新對象的指針指向舊對象指針來解決,這樣就不用拷貝了,可是這樣修改會導致兩個對象指向同一塊內存,這個問題需要解決。

改爲左值引用實現報錯

如果兩個對象指向同一塊內存,那麼對象在析構的時候就會將一塊內存釋放兩次導致奔潰,這時考慮在拷貝構造或者賦值重載時,將原來對象的指針設置成空就可以了,但是參數是常量沒有辦法修改啊,那我們將 const 關鍵字去掉試試,將兩個函數改成這樣:

    // 拷貝構造
    CBuffer(CBuffer &origin): m_size(origin.m_size)
    {
        cout << "CBuffer(CBuffer&)" << endl;
        m_buffer = origin.m_buffer;

        origin.m_buffer = nullptr;
        origin.m_size = 0;
    }

    // 賦值重載
    CBuffer& operator=(CBuffer &origin)
    {
        cout << "operator=(CBuffer&)" << endl;
        if (this == &origin) return *this;

        m_buffer = origin.m_buffer;

        origin.m_buffer = nullptr;
        origin.m_size = 0;

        return *this;
    }

看起來沒有什麼問題,但是編譯的時候會報錯:

error: invalid initialization of non-const reference of type ‘CBuffer&’ from an rvalue of type ‘CBuffer’
b2 = CBuffer::gen_buffer(100);
^
note: initializing argument 1 of ‘CBuffer& CBuffer::operator=(CBuffer&)’
CBuffer& operator=(CBuffer &origin)

這個錯誤是什麼意思呢?其實說的就是在調用 CBuffer::gen_buffer(100); 函數時,會產生一個臨時對象,這個臨時對象在賦值給 b2 是會調用
CBuffer& operator=(CBuffer &origin) 函數,但是這個函數的參數是一個左值引用類型,而臨時對象是一個右值,無法綁定到左值引用上,所以報錯了。

還有拷貝構造函數也是有相同的問題,當寫出類似 b2 = CBuffer(CBuffer(1000)) 類型會產生臨時對象的語句時,同樣會因爲左值引用不能綁定到右值上而報錯,這時候就要請出右值引用了。

改爲右值引用實現

對於賦值重載函數,我們使用右值引用將其改寫爲:

    // 賦值重載
    CBuffer& operator=(CBuffer &&origin)
    {
        cout << "operator=(CBuffer&&)" << endl;
        if (this == &origin) return *this;

        m_buffer = origin.m_buffer;

        origin.m_buffer = nullptr;
        origin.m_size = 0;

        return *this;
    }

這時可以正常通過編譯,並且只是修改了指針的指向,並沒有申請和拷貝另外一份內存。

std::move() 函數

如果我們將拷貝構造函數的參數也改成右值引用的形式:

    // 拷貝構造
    CBuffer(CBuffer &&origin): m_size(origin.m_size)
    {
        cout << "CBuffer(CBuffer&)" << endl;
        m_buffer = origin.m_buffer;

        origin.m_buffer = nullptr;
        origin.m_size = 0;
    }

編譯時就會發現編譯錯誤:

error: use of deleted function ‘constexpr CBuffer::CBuffer(const CBuffer&)’
CBuffer b2(b1);
^
note: ‘constexpr CBuffer::CBuffer(const CBuffer&)’ is implicitly declared
as deleted because ‘CBuffer’ declares a move constructor or move assignment operator
class CBuffer

其本質問題就是主函數中 CBuffer b2(b1); 這一句引起的,因爲變量 b1 是一個左值,但是拷貝構造函數接受的是右值引用,所以類型不匹配導致了編譯錯誤,這時可以使用 std::move() 函數改成這條語句爲 CBuffer b2(std::move(b1)); 就可以正常編譯運行了,運行結果爲:

CBuffer(int)
CBuffer(CBuffer&)
b1.size = 0
b2.size = 1024
CBuffer(int)
operator=(CBuffer&&)
~CBuffer()
~CBuffer()
~CBuffer()

查看運行結果會發現 b1.size = 0,因爲 b1 調用了 std::move() 函數,轉移了資源的所有權,內部已經被“掏空”了,所以在明確所有權轉移之後,不要再直接使用變量 b1 了。

萬能引用

聽到這個名字就感覺很厲害,什麼是萬能引用,其實就是可以同時接受左值和右值的引用類型,但是這種完能引用只能發生在推導的情況下,下面給出了一個例子:

#include <iostream>
using namespace std;

template<typename T>
void func(T&& val)
{
    cout << val << endl;
}

int main()
{
    int year = 2020;
    func(year);
    func(2020);
    return 0;
}

這段代碼中 T&& val 就是萬能引用,因爲是在模板中,類型需要推導,如果是在普通函數中 T&& val 這個形式就是右值引用。

左值引用和右值引用判定的函數

文中多次提到左值和右值,可能剛學習這塊內容的小夥伴會有些懵,其實 C++ 中提供了判定左值引用和右值引用的函數,頭文件爲 <type_traits>,函數名爲 is_referenceis_rvalue_referenceis_lvalue_reference,看名字就可以知道他們的用途,看下面的例子就更清楚了。

#include <iostream>
#include <type_traits>
using namespace std;

int main()
{
    int i = 22;
    int& j = i;
    int&& k = 11;

    cout << "i is_reference: " << is_reference<decltype(i)>::value << endl;
    cout << "i is_lvalue_reference: " << is_lvalue_reference<decltype(i)>::value << endl;
    cout << "i is_rvalue_reference: " << is_rvalue_reference<decltype(i)>::value << endl;


    cout << "j is_reference: " << is_reference<decltype(j)>::value << endl;
    cout << "j is_lvalue_reference: " << is_lvalue_reference<decltype(j)>::value << endl;
    cout << "j is_rvalue_reference: " << is_rvalue_reference<decltype(j)>::value << endl;


    cout << "k is_reference: " << is_reference<decltype(k)>::value << endl;
    cout << "k is_lvalue_reference: " << is_lvalue_reference<decltype(k)>::value << endl;
    cout << "k is_rvalue_reference: " << is_rvalue_reference<decltype(k)>::value << endl;
    return 0;
}

運行結果如下,滿足返回1,否則返回0:

i is_reference: 0
i is_lvalue_reference: 0
i is_rvalue_reference: 0
j is_reference: 1
j is_lvalue_reference: 1
j is_rvalue_reference: 0
k is_reference: 1
k is_lvalue_reference: 0
k is_rvalue_reference: 1

總結

  • 右值引用的寫法爲 T&& val,兩個地址符要挨在一起,在模板中被稱爲萬能引用
  • 注意左值引用和右值引用的使用區別,其實本質都是爲了減少無效的拷貝
  • std::move() 函數會轉移對象的所有權,轉移操作之後將左值轉爲右值引用,原對象不可再直接使用
  • 可以使用 is_referenceis_rvalue_referenceis_lvalue_reference 來判斷引用類型

陪伴是最長情的告白,等待是最極致的思念
五一離家返工了,心裏有些不是滋味,爲了家出來奮鬥卻將“家”拋在了身後,珍惜眼前人吧~

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