1. 程式人生 > >C++11的右值引用

C++11的右值引用

右值引用 語法 factor htm 類型 har start 移動 hand

右值引用是C++11 引入的新特性。它解決了兩類問題:實現移動語義和完美轉發。本文大絕大部分內容,來自於文章:http://kuring.me/post/cpp11_right_reference/

一:左值(lvalue)和右值(rvalue

最初,在C中,左值和右值的定義如下:左值,是一個可以出現在賦值操作符左邊或者右邊的表達式;而右值是只能出現在賦值操作符右邊的表達式。比如:

int a = 42;
int b = 43;

// a and b are both l-values:
a = b; // ok
b = a; // ok
a = a * b; // ok

// a * b is an rvalue:
int c = a * b; // ok, rvalue on right hand side of assignment
a * b = 42; // error, rvalue on left hand side of assignment

然而對於當前的C++而言,這種定義已不再正確了。可以用另外一種方法來區分左值和右值:如果一個表達式綁定了一塊內存,並且允許使用&取地址操作符得到該內存地址,則該表達式就是左值。非左值的表達式就是右值。

也就是說:左值是可以取地址,有名字的值,是一個綁定某內存空間的表達式,可以使用&操作符獲取內存地址。右值:不能取地址,即非左值的都是右值,沒有名字的值,是一個臨時值,表達式結束後右值就沒有意義了。對於臨時對象,它可以存儲於寄存器中,所以是沒辦法用“取地址&”運算符;對於常量,它可能被編碼到機器指令的“立即數”中,所以是沒辦法用“取地址&”運算符;

如果對左值右值的嚴格定義感興趣,可以參考Mikael Kilpel?inen的文章:《ACCU article》

二:右值引用

C++11為了實現移動語義,引入了右值引用的概念。首先看一個C++11之前,沒有實現移動語義的函數返回類對象的例子:

class MyString {
public: 
    MyString() { 
        _data = nullptr; 
        _len = 0; 
        printf("Constructor is called!\n");
    } 
    MyString(const char* p) { 
        _len = strlen (p); 
        _init_data(p); 
        cout << "Constructor is called! this->_data: " << (long)_data << endl;
    } 
    MyString(const MyString& str) { 
        _len = str._len; 
        _init_data(str._data); 
           cout << "Copy Constructor is called! src: " << (long)str._data << " dst: " << (long)_data << endl;
    }
    ~MyString() { 
        if (_data)
        {
            cout << "DeConstructor is called! this->_data: " << (long)_data << endl; 
            free(_data);
        }
        else
        {
            std::cout << "DeConstructor is called!" << std::endl; 
        }
    } 
    MyString& operator=(const MyString& str) { 
        if (this != &str) { 
            _len = str._len; 
            _init_data(str._data); 
        } 
           cout << "Copy Assignment is called! src: " << (long)str._data << " dst" << (long)_data << endl; 
        return *this; 
    } 
    operator const char *() const {
        return _data;
    }
private: 
    char *_data; 
    size_t   _len; 
    void _init_data(const char *s) { 
        _data = new char[_len+1]; 
        memcpy(_data, s, _len); 
        _data[_len] = ‘\0‘; 
    } 
}; 

MyString foo()
{
    MyString middle("123");
    return middle;
}

int main() { 
    MyString a = foo(); 
    return 1;
}

使用的編譯命令是:

g++ -std=c++11 -fno-elide-constructors -g  -o leftright leftright.cpp

之所以要加上-fno-elide-constructors選項,是因為g++編譯器默認情況下會對函數返回類對象的情況作返回值優化處理,這不是我們討論的重點。

上述代碼運行結果如下:

Constructor is called! this->_data: 29483024 // foo函數中middle對象的構造函數
Copy Constructor is called! src: 29483024 dst: 29483056 //foo函數的返回值,也就是臨時對象的構造,通過middle對象調用復制構造函數
DeConstructor is called! this->_data: 29483024 // foo函數中middle對象的析構
Copy Constructor is called! src: 29483056 dst: 29483024 // a對象構造,通過臨時對象調用復制構造函數
DeConstructor is called! this->_data: 29483056 // 臨時對象析構
DeConstructor is called! this->_data: 29483024 // a對象析構

在上述例子中,臨時對象的構造、復制和析構操作所帶來的效率影響一直是C++中為人詬病的問題,臨時對象的構造和析構操作均對堆上的內存進行操作,而如果_data的內存過大,勢必會非常影響效率。從程序員的角度而言,該臨時對象是透明的。而這一問題正是C++11中需要解決的問題。

在C++11中解決該問題的思路為,引入了移動構造函數,移動構造函數的定義如下。

MyString(MyString &&str) {
    cout << "Move Constructor is called! src: " << (long)str._data << endl;
    _len = str._len;
    _data = str._data;
    str._data = nullptr;
}

在移動構造函數中我們竊取了str對象已經申請的內存,將其拿為己用,並將str申請的內存給賦值為nullptr。

移動構造函數和復制構造函數的不同之處在於移動構造函數的參數使用&&,這就是所謂的右值引用符號。參數不再是const,因為在移動構造函數需要修改右值str的內容。

當構造臨時對象時,或用臨時對象來構造其他對象的時候,移動語義會被調用。加入上面的代碼後,運行結果如下:

Constructor is called! this->_data: 22872080 // foo函數中middle對象構造
Move Constructor is called! src: 22872080 //foo函數的返回值,也就是臨時對象的構造,通過移動構造函數構造,將middle申請的內存竊取
DeConstructor is called! // foo函數中middle對象析構
Move Constructor is called! src: 22872080 // 對象a通過移動構造函數構造,將臨時對象的內存竊取
DeConstructor is called! // 臨時對象析構
DeConstructor is called! this->_data: 22872080 // 對象a析構

通過輸出結果可以看出,整個過程中僅申請了一塊內存,這也正好符合我們的要求了。

在C++11中,引入了右值引用的概念,使用&&來表示,如果X是一種類型的話,則X&&表示X的右值引用;而傳統的X&表示左值引用。

右值引用和左值引用都是屬於引用類型。無論是聲明一個左值引用還是右值引用,都必須立即進行初始化。而其原因可以理解為是引用類型本身自己並不擁有所綁定對象的內存,只是該對象的一個別名。

右值引用類似於傳統的左值引用,不過也有幾點不同,最重要的區別在於:在引入了右值引用後,在函數重載時可以根據是左值引用還是右值引用來區分。如果存在重載函數,以左值為實參,則會調用具有左值引用參數的函數,而以右值為實參,則調用具有右值引用參數的函數。

void fun(MyString &str)
{
    cout << "left reference" << endl;
}
void fun(MyString &&str)
{
    cout << "right reference" << endl;
}
int main() { 
    MyString a("456"); 
    fun(a); // 左值引用,調用void fun(MyString &str)
    fun(foo()); // 右值引用,調用void fun(MyString &&str)
    return 1;
}

可以像上面的例子一樣重載任何函數,但是在絕大多數情況下,這種重載應該只發生在重載復制構造函數以及復制操作符函數上,用於實現移動語義。

前面已經介紹過了移動構造函數的具體形式和使用情況,這裏對移動賦值操作符的定義再說明一下,並將main函數的內容也一起更改,將得到如下輸出結果。

MyString& operator=(MyString&& str) { 
    cout << "Move Operator= is called! src: " << (long)str._data << endl; 
    if (this != &str) { 
        if (_data != nullptr)
        {
            free(_data);
        }
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = nullptr;
    }     
    return *this; 
}

int main() { 
    MyString b;
    b = foo();
    return 1;
}

輸出結果如下,整個過程僅申請了一個內存地址:

Constructor is called! // 對象b構造函數調用
Constructor is called! this->_data: 14835728 // middle對象構造
Move Constructor is called! src: 14835728 // 臨時對象通過移動構造函數由middle對象構造
DeConstructor is called! // middle對象析構
Move Operator= is called! src: 14835728 // 對象b通過移動賦值操作符由臨時對象賦值
DeConstructor is called! // 臨時對象析構
DeConstructor is called! this->_data: 14835728 // 對象b析構函數調用

引入了右值引用之後,引用就可以劃分為 const左值引用、非const左值引用、const右值引用和非const右值引用四種類型。其中左值引用的綁定規則和之前是一樣的。

非const左值引用只能綁定到非const左值,不能綁定到const右值、非const右值和const左值。這一點可以通過const關鍵字的語義來判斷。

const左值引用可以綁定到任何類型,包括const左值、非const左值、const右值和非const右值,屬於萬能引用類型。其中綁定const右值的規則比較少見,但是語法上是可行的。

非const右值引用不能綁定到任何左值和const右值,只能綁定非const右值。

const右值引用類型僅是為了語法的完整性而設計的,比如可以使用const MyString &&right_ref = foo(),但是右值引用類型的引入主要是為了移動語義,而移動語義需要右值引用是可以被修改的,因此const右值引用類型沒有實際意義。

通過表格的形式對上文中提到的四種引用類型可以綁定的類型進行總結。

技術分享

三:強制移動語義std::move()

前文中我們通過右值引用給類增加移動構造函數和移動賦值操作符已經解決了函數返回類對象效率低下的問題。那麽還有什麽問題沒有解決呢?

在之前的C++98中,swap函數的實現形式如下:

template <class T> 
void swap ( T& a, T& b )
{
    T c(a); 
    a=b;
    b=c;
}

在該函數中,變量a、b、c均為左值,因此無法直接使用前面的移動語義。

但是該函數中能夠使用移動語義是非常合適的,僅是為了交換兩個變量,卻要反復申請和釋放資源。

在C++11的標準庫中引入了std::move()函數來解決該問題,該函數的作用為將其參數轉換為右值。在C++11中的swap函數就可以更改為了:

template <class T> 
void swap (T& a, T& b)
{
    T c(std::move(a)); 
    a=std::move(b); 
    b=std::move(c);
}

在使用了move語義以後,swap函數的效率會大大提升。增加一個MyString::display成員函數,並且更改main函數後測試如下:

void MyString::display()
{
    if (_data)
    {
        cout << "str is " << _data << "(" << (long)_data << ")" << endl;
    }
    else
    {
        cout << "nothing" << endl;
    }
}

int main() { 

    MyString d("123");
    MyString e("456");
    d.display();
    e.display();

    std::swap(d, e);
    d.display();
    e.display();

    return 1;
}

輸出結果如下,通過輸出結果可以看出對象交換是成功的:

Constructor is called! this->_data: 9498640  // 對象d構造
Constructor is called! this->_data: 9498672  // 對象e構造
str is 123(9498640)  // 對象d的內容
str is 456(9498672)  // 對象e的內容
Move Constructor is called! src: 9498640  // swap函數中的對象c通過移動構造函數構造
Move Operator= is called! src: 9498672    // swap函數中的對象a通過移動賦值操作符賦值
Move Operator= is called! src: 9498640    // swap函數中的對象b通過移動賦值操作符賦值
DeConstructor is called!  // swap函數中的對象c析構
str is 456(9498672)  // 對象d的內容
str is 123(9498640)  // 對象e的內容
DeConstructor is called! this->_data: 9498640  // 對象e析構
DeConstructor is called! this->_data: 9498672  // 對象d析構

註意,對於那些沒有實現移動語義的類型而言(沒有重載復制構造函數和賦值操作符的右值引用版本),新的swap的行為與老的swap一樣。

四:右值引用和右值的關系

這個問題就有點繞了,需要開動思考一下右值引用和右值是啥含義了。讀者會憑空的認為右值引用肯定是右值,其實不然。我們在之前的例子中添加如下代碼,並將main函數進行修改如下:

void test_rvalue_rref(MyString &&str)
{
    cout << "tmp object construct start" << endl;
    MyString tmp = str;
    cout << "tmp object construct finish" << endl;
}
int main() {
    test_rvalue_rref(foo());
    return 1;
}

輸出結果

Constructor is called! this->_data: 28913680
Move Constructor is called! src: 28913680
DeConstructor is called!
tmp object construct start
Copy Constructor is called! src: 28913680 dst: 28913712 // 可以看到這裏調用的是復制構造函數而不是移動構造函數
tmp object construct finish
DeConstructor is called! this->_data: 28913712
DeConstructor is called! this->_data: 28913680

我想程序運行的結果肯定跟大多數人想到的不一樣,“Are you kidding me?不是應該調用移動構造函數嗎?為什麽調用了復制構造函數?”。關於右值引用和左右值之間的規則是:

如果右值引用有名字則為左值,如果右值引用沒有名字則為右值。

通過規則我們可以發現,在我們的例子中右值引用str是有名字的,因此為左值,tmp的構造會調用復制構造函數。之所以會這樣,是因為如果tmp構造的時候調用了移動構造函數,則調用完成後str的申請的內存已經不可用了,如果在該函數中該語句的後面再次使用str變量,則會出現意想不到的問題。鑒於此,我們也就能夠理解為什麽有名字的右值引用是左值了。如果已經確定在tmp構造語句的後面不需要使用str變量了,可以使用std::move()函數將str變量從左值轉換為右值,這樣tmp變量的構造就可以使用移動構造函數了。

而如果我們調用的是MyString b = foo()語句,由於foo()函數返回的是臨時對象沒有名字屬於右值,因此b的構造會調用移動構造函數。

該規則非常的重要,要想能夠正確使用右值引用,該規則必須要掌握,否則寫出來的代碼會有一個大坑。

五:完美轉發

在泛型編程中,經常會遇到的一個問題是怎樣將一組參數原封不動的轉發給另外一個函數。這裏的原封不動是指,如果函數是左值,那麽轉發給的那個函數也要接收一個左值;如果參數是右值,那麽轉發給的函數也要接收一個右值;如果參數是const的,轉發給的函數也要接收一個const參數;如果參數是非const的,轉發給的函數也要接收一個非const值。

該問題看上去非常簡單,其實不然。看一個例子:

void fun(int &) { cout << "lvalue ref" << endl; } 
void fun(int &&) { cout << "rvalue ref" << endl; } 
void fun(const int &) { cout << "const lvalue ref" << endl; } 
void fun(const int &&) { cout << "const rvalue ref" << endl; }

template<typename T>
void PerfectForward(T t) { fun(t); } 

int main()
{
    PerfectForward(10);           // rvalue ref
    int a;
    PerfectForward(a);            // lvalue ref
    PerfectForward(std::move(a)); // rvalue ref
    const int b = 8;
    PerfectForward(b);            // const lvalue ref
    PerfectForward(std::move(b)); // const rvalue ref
    return 0;
}

在上述例子中,我們想達到的目的是PerfectForward模板函數能夠完美轉發參數t到fun函數中。上述例子中的PerfectForward函數必然不能夠達到此目的,因為PerfectForward函數的參數為左值類型,調用的fun函數也必然為void fun(int &)。上述代碼的運行結果就是打印5次”lvalue ref”。並且調用PerfectForward之前就產生了一次參數的復制操作,因此這樣的轉發只能稱之為正確轉發,而不是完美轉發。要想達到完美轉發,需要做到像轉發函數不存在一樣的效率。

在C++11中為了能夠解決完美轉發問題,引入了更為復雜的規則:引用折疊規則和特殊模板參數推導規則:

在C++11之前,不允許定義引用的引用:像A& &這樣的寫法會導致編譯錯誤。而在C++11中,引入了引用折疊規則。該規則如下:

A& & => A&
A& && => A&
A&& & => A&
A&& && => A&&

可以看出一旦引用中定義了左值類型,折疊規則總是將其折疊為左值引用。這就是引用折疊規則的全部內容了。另外折疊規則跟變量的const特性是沒有關系的。

對於具有右值引用形參的模板函數:

template<typename T>
void foo(T&&);

其模板形參推斷的規則與普通的規則有所不同:

如果使用左值A調用foo,則模板形參T為A&,因此,根據引用折疊規則,foo的參數實際上為A&;

如果使用右值A調用foo,則T為A,因此,根據引用折疊規則,foo的參數實際上為A&&;

利用上面兩條規則,可以解決完美轉發的問題:

void fun(int &) { cout << "lvalue ref" << endl; }
void fun(int &&) { cout << "rvalue ref" << endl; }
void fun(const int &) { cout << "const lvalue ref" << endl; }
void fun(const int &&) { cout << "const rvalue ref" << endl; }

// 利用引用折疊規則代替了原有的不完美轉發機制
template<typename T>
void PerfectForward(T &&t) { fun(static_cast<T &&>(t)); }

int main()
{
    PerfectForward(10);           // rvalue ref,折疊後t類型仍然為T &&
    int a;
    PerfectForward(a);            // lvalue ref,折疊後t類型為T &
    PerfectForward(std::move(a)); // rvalue ref,折疊後t類型為T &&
    const int b = 8;
    PerfectForward(b);            // const lvalue ref,折疊後t類型為const T &
    PerfectForward(std::move(b)); // const rvalue ref,折疊後t類型為const T &&
    return 0;
}

上述代碼的運行結果為:

rvalue ref
lvalue ref
rvalue ref
const lvalue ref
const rvalue ref

使用static_cast進行強制類型轉換的原因:之前提過:如果右值引用有名字則為左值,如果右值引用沒有名字則為右值。這裏的變量t雖然為右值引用,但是是左值。如果我們想繼續向fun函數中傳遞右值,就需要使用static_cast進行強制類型轉換了:

// 參數為左值,引用折疊規則引用前
template<int &T>
void PerfectForward(int & &t) { fun(static_cast<int & &>(t)); }
// 引用折疊規則應用後
template<int &T>
void PerfectForward(int &t) { fun(static_cast<int &>(t)); }

// 參數為右值,引用折疊規則引用前
template<int &&T>
void PerfectForward(int && &&t) { fun(static_cast<int && &&>(t)); }
// 引用折疊規則應用後
template<int &&T>
void PerfectForward(int &&t) { fun(static_cast<int &&>(t)); }

因此,static_cast僅是對傳遞右值時起作用。

其實在C++11中已經為我們封裝了std::forward函數來替代我們上文中使用的static_cast類型轉換,該例子中使用std::forward函數的版本變為了:

template<typename T>
void PerfectForward(T &&t) { fun(std::forward<T>(t)); }

運行結果與上面一樣。

六:其他

當定義移動構造函數以及賦值操作符重載函數時,建議:做到不拋出異常,使用noexcept告訴編譯器函數不會拋出異常。

如果不這麽做的話,至少會有一個非常常見的場景中,希望使用移動語義但是實際上卻不會:當一個std::vector擴容時,肯定是希望原有元素使用移動語義填充到新申請的內存中。但是只有做到上面兩點的情況下,才會使用移動語義。

當編寫一個不拋出異常的移動操作時,我們應該將此事通知標準庫。除非標準庫知道不會拋出異常,否則它會為了處理可能拋出異常這種可能性而做一些額外的工作。一種通知標準庫的方法是將構造函數指明為 noexcept。這個關鍵字是新標準引入的。不拋出異常的移動構造函數和移動賦值運算符都必須標記為noexcept.

總結:

Ok, that‘s it, the whole story on rvalue references. As you can see, the benefits are considerable. The details are gory. As a C++ professional, you will have to understand these details. Otherwise, you have given up on fully understanding the central tool of your trade. You can take solace, though, in the thought that in your day-to-day programming, you will only have to remember three things about rvalue references:

By overloading a function like this:

void foo(X& x); // lvalue reference overload

void foo(X&& x); // rvalue reference overload

you can branch at compile time on the condition "is foo being called on an lvalue or an rvalue?" The primary (and for all practical purposes, the only) application of that is to overload the copy constructor and copy assignment operator of a class for the sake of implementing move semantics. If and when you do that, make sure to pay attention to exception handling, and use the new noexcept keyword as much as you can.

std::move turns its argument into an rvalue.

std::forward allows you to achieve perfect forwarding if you use it exactly as shown in the factory function example in Section 8.

http://kuring.me/post/cpp11_right_reference/

http://thbecker.net/articles/rvalue_references/section_01.html#section_01

C++11的右值引用