1. 程式人生 > >C++11移動語義探討——從臨時物件到右值引用

C++11移動語義探討——從臨時物件到右值引用

一.前言

這篇文章主要談談c++11中引入的右值引用概念和移動語義概念。以及這些東西可能在我們程式設計中帶來哪些體驗、便捷或者是程式碼效率的提高。
文章主要分為以下三點:

  • 臨時物件的產生
  • 何謂右值引用
  • 何謂移動語義

二.臨時物件的產生

在我們以往的程式設計過程中可能很少會注意到臨時物件(變數)的問題,因為這已經不太是程式層面上的問題,而更多是編譯器上的事情。編譯器在編譯程式碼過程中為了實現某些程式碼可能會產生出一些臨時物件(變數)來滿足一些效果,比如:

void Test(MyClass obj) {

}
MyClass myclass;
Test(myclass);

//可能編譯器會做如下改寫:
void Test(MyClass& obj){ } MyClass myclass; MyClass obj(myclass); //產生的臨時物件 Test(obj); obj.~MyClass();

又或者是這樣:

double db = 5.5;
int it = st;      //產生int臨時變數賦值給it

當然,這些臨時物件並不需要程式設計師參與干涉,甚至臨時物件這些行為對於程式設計師而言是透明的。但是,我們仍然需要對其進行一定的瞭解,這對後面c++11引入的右值引用和移動語義的理解會帶來莫大的幫助。

產生臨時變數的地方有很多,不同編譯器在某些細節上可能處理方式也略有不同。不過具體說來有以下幾種:

  • 不同型別(物件)變數的轉換
  • 函式以pass by value傳遞引數的時候
  • 表示式求值中

這裡文章就不繼續深入探討了,下面我們來談臨時物件對程式設計的一些影響和右值引用的引入。

三.何謂右值引用

假如我們設計一個複數類,可能大概如下:

class Complex
{
public:
    Complex() :_real(0), _imaginary(0) {}
    Complex(double real, double imaginary) :_real(real), _imaginary(imaginary) {}
    Complex(Complex& cp) :_real(cp._real), _imaginary(cp._imaginary) {}
private
: double _real; //實部 double _imaginary; //虛部 };

嗯,先不看其他函式,就此而言大概是”沒有太多問題的”。
但是如果在如下使用時候:

//求共軛複數
//為Complex的友元
Complex getConjugate(Complex cp)
{
    cp._imaginary = -cp._imaginary;
    return cp;
}
int main()
{
    Complex a(1.0, -5.0);
    Complex b = getConjugate(a);  // Error : no match
    return 0;
}

有的編譯器下會報錯。MSVC 沒有, GUN GCC 報錯了。原因呢很簡單,編譯器提示找不到匹配的拷貝建構函式。
我們在前面的介紹知道,這裡函式返回的其實是一個臨時物件,而我們拷貝建構函式中的引數型別為Complex&。而這裡,試圖將一個引用繫結到臨時變數上,這顯然是不允許的。因為,規定不允許改變臨時變數。
所以我們也知道了解決方案:將拷貝建構函式修改為 Complex(const Complex& cp)

以上問題就告一段落了,我們來到一個新問題,我們試圖寫一個mystring類:

class MyString
{
    friend ostream& operator << (ostream& os, MyString mystr);
public:
    MyString() :_ptr(0) {}
    MyString(const char* ptr) {
        int len = strlen(ptr);
        _ptr = new char[len + 1];
        strcpy(_ptr, ptr);
    }
    MyString(const MyString& mystr) {
        int len = strlen(mystr._ptr);
        _ptr = new char[len + 1];
        strcpy(_ptr, mystr._ptr);
    }
    //MyString& operator = (const MyString&){ ... }
    ~MyString() { delete _ptr; }
private:
    char* _ptr;
};
ostream& operator << (ostream& os, MyString mystr)
{
    return os << mystr._ptr;
}
int main()
{
    MyString a = "123";
    cout << a << endl;
    return 0;
}

由於篇幅,我就只是給出了必要的函式,operator=()就沒有給出了。所以賦值只是淺拷貝,不過這裡並不關注這個。

我們注意看拷貝建構函式,由於前篇提到的原因,這裡的拷貝函式我們加上了const,使得拷貝函式能夠接受臨時變數。
當傳入引數是臨時變數的情況時,我們仔細思考一下:
我們知道臨時變數很快(大概就是函式返回之後)就會銷燬,然而在這樣的情況下,行為彷彿是:將一份字串拷貝一份,然後就將原稿銷燬了。對,為什麼不能將臨時物件的字串”拿為己用”呢?這樣我們就免去了記憶體的開闢和繁瑣的字串賦值了。嗯,大概函式如下:

MyString(....):_ptr(mystr._ptr){
    mystr._ptr = NULL;
}

對,這樣我們就將_ptr指向的真正的字串的所有權拿過來了。但是,這裡我把引數空了出來,是的,我們要去設法判斷什麼時候是臨時物件。然而這在c++1.0是不太可行的。

所以,在C++11中引入了一個新的概念——右值引用(&&),右值引用專門用於引用右值(臨時物件、匿名物件)。

那麼有了語言的支援,對於上面的情況,我們可以進行如下的改寫:

class MyString
{
    friend ostream& operator << (ostream& os, MyString mystr);
public:
    //上面不變... 
    MyString(const MyString& mystr) {
        int len = strlen(mystr._ptr);
        _ptr = new char[len + 1];
        strcpy(_ptr, mystr._ptr);
    }
    MyString(MyString&& mystr) :_ptr(mystr._ptr){
        mystr._ptr = NULL;
    }
    //下面不變...
};

這樣的話,我們當傳入臨時變數的時候,哦,不對,我們應該改口叫做右值。
那麼呼叫的編譯器就是MyString(MyString&& mystr)版本,在函式中,我們剝奪了右值的字串,並且將mystr._ptr = NULL, 這很重要,因為防止臨時變數析構的時候銷燬字串。

四.何謂移動語義

其實上一段在介紹mystring設計的時候已經使用了移動語義,就是程式設計師提供移動建構函式(引數為右值引用的建構函式)使得當用臨時變數構造新物件的時候可以提供較好的優化。
下面給就用上面的mystring作為例子來測一下提供了移動建構函式和沒有提供移動建構函式的時候,分別用臨時變數去構造物件的對比:

100000長度字串,10000次初始化
提供移動構造 22ms
不提供移動構造 376ms

可以看出來提供移動建構函式在某些情況下可以將效能極大地提高。特別對於某些需要深拷貝的物件來說。特別的,當這一類的物件配合標準庫的容器的時候(vector,deque),如果提供移動建構函式,將會在容器記憶體重分配的時候帶來極大效率的優化。

當然,除了編譯器建立的臨時物件來作為右值以外,我們也可以使用std::move來得到一個左值的右值引用。
我們先來看move()的原始碼:

template<class _Ty> inline
constexpr typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{
    return (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));
}

當傳入左值Type&的時候 _Ty被識別為Type& , remove_reference<_Ty>::type 為 Type,返回的就是 Type&&
當傳入右值Type&&的時候_Ty被識別為Type&&, remove_reference<_Ty>::type 為 Type,返回的就是 Type&&

也就是無論傳入一個什麼值,都將返回這個值的右值引用,這樣就可以顯式讓編譯器呼叫類中的移動構造函數了。不過值得注意的是,程式設計師必須恪守——被當作右值的物件在移動構造之後在重新賦值之前絕不使用。
如下:

vector<int> a = {1,2,3,4,5};
vector<int> b(move(a));       //這裡呼叫的是b的移動建構函式
//a在重新賦值之前絕不使用。