1. 程式人生 > >C++11標準之右值引用(ravalue reference)(轉載)

C++11標準之右值引用(ravalue reference)(轉載)

臨時物件的產生和拷貝所帶來的效率折損,一直是C++所為人詬病的問題。但是C++標準允許編譯器對於臨時物件的產生具有完全的自由度,從而發展出了Copy Elision、RVO(包括NRVO)等編譯器優化技術,它們可以防止某些情況下臨時物件產生和拷貝。下面簡單地介紹一下Copy Elision、RVO,對此不感興趣的可以直接跳過:

(1) Copy Elision

 Copy Elision技術是為了防止某些不必要的臨時物件產生和拷貝,例如:

struct A {
    A(int) {}
    A(const A &) {}
};
A a = 42;

理論上講,上述A a = 42;語句將分三步操作:第一步由42構造一個A型別的臨時物件,第二步以臨時物件為引數拷貝構造a,第三步析構臨時物件。如果A是一個很大的類,那麼它的臨時物件的構造和析構將造成很大的記憶體開銷。我們只需要一個物件a,為什麼不直接以42為引數直接構造a呢?Copy Elision技術正是做了這一優化。

【說明】:你可以在A的拷貝建構函式中加一列印語句,看有沒有呼叫,如果沒有被呼叫,那麼恭喜你,你的編譯器支援Copy Elision。但是需要說明的是:A的拷貝建構函式雖然沒有被呼叫,但是它的實現不能沒有訪問許可權,不信你將它放在private許可權裡試試,編譯器肯定會報錯。

(2) 返回值優化(RVO,Return Value Optimization)

 返回值優化技術也是為了防止某些不必要的臨時物件產生和拷貝,例如:

struct A {
    A(int) {}
    A(const A &) {}
};
get() {return A(1);}
A a = get();

理論上講,上述A a = get();語句將分別執行:首先get()函式中建立臨時物件(假設為tmp1),然後以tmp1為引數拷貝構造返回值(假設為tmp2),最後再以tmp2為引數拷貝構造a,其中還伴隨著tmp1和tmp2的析構。如果A是一個很大的類,那麼它的臨時物件的構造和析構將造成很大的記憶體開銷。返回值優化技術正是用來解決此問題的,它可以避免tmp1和tmp2兩個臨時物件的產生和拷貝。

【說明】: a)你可以在A的拷貝建構函式中加一列印語句,看有沒有呼叫,如果沒有被呼叫,那麼恭喜你,你的編譯器支援返回值優化。但是需要說明的是:A的拷貝建構函式雖然沒有被呼叫,但是它的實現不能沒有訪問許可權,不信你將它放在private許可權裡試試,編譯器肯定會報錯。

b)除了返回值優化,你可能還聽說過一個叫具名返回值優化(Named Return Value Optimization,NRVO)的優化技術,從程式設計師的角度而言,它其實跟RVO同樣的邏輯。只是它的臨時物件具有變數名標識,例如修改上述get()函式為:

get() {
    A tmp(1); // #1
    
// do something
    return tmp;
}
A a = get(); // #2

想想上述修改後A型別共有幾次物件構造?雖然#1處看起來有一次顯示地構造,#2處看起來也有一次顯示地構造,但如果你的編譯器支援NRVO和Copy Elision,你會發現整個A a = get();語句的執行過程,只有一次A物件的構造。如果你在get()函式return語句前列印tmp變數的地址,在A a = get();語句後列印a的地址,你會發現兩者地址相同,這就是應用了NRVO技術的結果。

(3) Copy Elision、RVO無法避免的臨時物件的產生和拷貝

雖然Copy Elision和NVO(包括NRVO)等技術能避免一些臨時物件的產生和拷貝,但某些情況下它們卻發揮不了作用,例如:

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

我們只是想交換a和b兩個物件所擁有的資料,但卻不得不使用一個臨時物件tmp備份其中一個物件,如果T型別物件擁有指向(或引用)從堆記憶體分配的資料,那麼深拷貝所帶來的記憶體開銷是可以想象的。為此,C++11標準引入了右值引用,使用它可以使臨時物件的拷貝具有move語意,從而可以使臨時物件的拷貝具有淺拷貝般的效率,這樣便可以從一定程度上解決臨時物件的深度拷貝所帶來的效率折損。

2、C++03標準中的左值與右值

要理解右值引用,首先得區分左值(lvalue)和右值(rvalue)。

C++03標準中將表示式分為左值和右值,並且“非左即右”:

    Every expression is either an lvalue or an rvalue.

區分一個表示式是左值還是右值,最簡便的方法就是看能不能夠對它取地址:如果能,就是左值;否則,就是右值。

【說明】:由於右值引用的引入,C++11標準中對錶達式的分類不再是“非左即右”那麼簡單,不過為了簡單地理解,我們暫時只需區分左值右值即可,C++11標準中的分類後面會有描述。

3、右值引用的繫結規則

右值引用(rvalue reference,&&)跟傳統意義上的引用(reference,&)很相似,為了更好地區分它們倆,傳統意義上的引用又被稱為左值引用(lvalue reference)。下面簡單地總結了左值引用和右值引用的繫結規則(函式型別物件會有所例外):

(1)非const左值引用只能繫結到非const左值;
(2)const左值引用可繫結到const左值、非const左值、const右值、非const右值;
(3)非const右值引用只能繫結到非const右值;
(4)const右值引用可繫結到const右值和非const右值。

測試例子如下:

struct A { A(){} };A lvalue;                             // 非const左值物件const A const_lvalue;                 // const左值物件A rvalue() {return A();}              // 返回一個非const右值物件const A const_rvalue() {return A();}  // 返回一個const右值物件// 規則一:非const左值引用只能繫結到非const左值A &lvalue_reference1 = lvalue;         // okA &lvalue_reference2 = const_lvalue;   // errorA &lvalue_reference3 = rvalue();       // errorA &lvalue_reference4 = const_rvalue(); // error// 規則二:const左值引用可繫結到const左值、非const左值、const右值、非const右值const A &const_lvalue_reference1 = lvalue;         // okconst A &const_lvalue_reference2 = const_lvalue;   // okconst A &const_lvalue_reference3 = rvalue();       // okconst A &const_lvalue_reference4 = const_rvalue(); // ok// 規則三:非const右值引用只能繫結到非const右值A &&rvalue_reference1 = lvalue;         // errorA &&rvalue_reference2 = const_lvalue;   // errorA &&rvalue_reference3 = rvalue();       // okA &&rvalue_reference4 = const_rvalue(); // error// 規則四:const右值引用可繫結到const右值和非const右值,不能繫結到左值const A &&const_rvalue_reference1 = lvalue;         // errorconst A &&const_rvalue_reference2 = const_lvalue;   // errorconst A &&const_rvalue_reference3 = rvalue();       // okconst A &&const_rvalue_reference4 = const_rvalue(); // ok// 規則五:函式型別例外void fun() {}typedef decltype(fun) FUN;  // typedef void FUN();FUN       &  lvalue_reference_to_fun       = fun; // okconst FUN &  const_lvalue_reference_to_fun = fun; // okFUN       && rvalue_reference_to_fun       = fun; // okconst FUN && const_rvalue_reference_to_fun = fun; // ok

【說明】:(1) 一些支援右值引用但版本較低的編譯器可能會允許右值引用繫結到左值,例如g++4.4.4就允許,但g++4.6.3就不允許了,clang++3.2也不允許,據說VS2010 beta版允許,正式版就不允許了,本人無VS2010環境,沒測試過。

(2)右值引用繫結到字面值常量同樣符合上述規則,例如:int &&rr = 123;,這裡的字面值123雖然被稱為常量,可它的型別為int,而不是const int。對此C++03標準文件4.4.1節及其腳註中有如下說明:

    If T is a non-class type, the type of the rvalue is the cv-unqualified version of T.
    In C++ class rvalues can have cv-qualified types (because they are objects). This differs from ISO C, in which non-lvalues never have cv-qualified types.

因此123是非const右值,int &&rr = 123;語句符合上述規則三。

4、C++11標準中的表示式分類

右值引用的引入,使得C++11標準中對錶達式的分類不再是非左值即右值那麼簡單,下圖為C++11標準中對錶達式的分類:

簡單解釋如下:

    (1)lvalue仍然是傳統意義上的左值;
    (2)xvalue(eXpiring value)字面意思可理解為生命週期即將結束的值,它是某些涉及到右值引用的表示式的值(An xvalue is the result of certain kinds of expressions involving rvalue references),例如:呼叫一個返回型別為右值引用的函式的返回值就是xvalue。
    (3)prvalue(pure rvalue)字面意思可理解為純右值,也可認為是傳統意義上的右值,例如臨時物件和字面值等。
    (4)glvalue(generalized value)廣義的左值,包括傳統的左值和xvalue。
    (5)rvalue除了傳統意義上的右值,還包括xvalue。

上述lvalue和prvalue分別跟傳統意義上的左值和右值概念一致,比較明確,而將xvalue描述為『某些涉及到右值引用的表示式的值』,某些是哪些呢?C++11標準給出了四種明確為xvalue的情況:

[ Note: An expression is an xvalue if it is:
  -- the result of calling a function, whether implicitly or explicitly, whose return type is an rvalue reference to object type,
  -- a cast to an rvalue reference to object type,
  -- a class member access expression designating a non-static data member of non-reference type in which the object expression is an xvalue, or
  -- a .* pointer-to-member expression in which the first operand is an xvalue and the second operand is a pointer to data member.
  In general, the effect of this rule is that named rvalue references are treated as lvalues and unnamed rvalue references to objects are treated as xvalues; rvalue references to functions are treated as lvalues whether named or not. --end note ]
[ Example:
    struct A {
        int m;
    };
    A&& operator+(A, A);
    A&& f();
    A a;
    A&& ar = static_cast<A&&>(a);
  The expressions f(), f().m, static_cast<A&&>(a), and a + a are xvalues. The expression ar is an lvalue.
--end example ]

簡單地理解就是:具名的右值引用(named rvalue reference)屬於左值,不具名的右值引用(unamed rvalue reference)就屬於xvalue,而引用函式型別的右值引用不論是否具名都當做左值處理。看個例子更容易理解:

A rvalue(){ return A(); }
A &&rvalue_reference() { return A(); }
fun();              // 返回的是不具名的右值引用,屬於xvalueA &&ra1 = rvalue(); // ra1是具名右值應用,屬於左值A &&ra2 = ra1;      // error,ra1被當做左值對待,因此ra2不能繫結到ra1(不符合規則三)A &la = ra1;        // ok,非const左值引用可繫結到非const左值(符合規則一)

5、move語意

現在,我們重新顧到1-(3),其中提到move語意,那麼怎樣才能使臨時物件的拷貝具有move語意呢?下面我們以一個類的實現為例:

class A {
public:
    A(const char *pstr = 0) { m_data = (pstr != 0 ? strcpy(new char[strlen(pstr) + 1], pstr) : 0); }

    // copy constructor    A(const A &a) { m_data = (a.m_data != 0 ? strcpy(new char[strlen(a.m_data) + 1], a.m_data) : 0); }

    // copy assigment    A &operator =(const A &a) { 
        if (this != &a) {
            delete [] m_data;
            m_data = (a.m_data != 0 ? strcpy(new char[strlen(a.m_data) + 1], a.m_data) : 0);
        }
        return *this;
    }

    // move constructor    A(A &&a) : m_data(a.m_data) { a.m_data = 0; }

    // move assigment    A & operator = (A &&a) {
        if (this != &a) {
            m_data = a.m_data;
            a.m_data = 0;
        }
        return *this;
    }

    ~A() { delete [] m_data; }

private:
    char * m_data;
};

從上例可以看到,除了傳統的拷貝構造(copy constructor)和拷貝賦值(copy assigment),我們還為A類的實現添加了移動拷貝構造(move constructor)和移動賦值(move assigment)。這樣,當我們拷貝一個A類的(右值)臨時物件時,就會使用具有move語意的移動拷貝建構函式,從而避免深拷貝中strcpy()函式的呼叫;當我們將一個A類的(右值)臨時物件賦值給另一個物件時,就會使用具有move語意的移動賦值,從而避免拷貝賦值中strcpy()函式的呼叫。這就是所謂的move語意。

6、std::move()函式的實現

瞭解了move語意,那麼再來看1-(3)中的效率問題:

template <typename T> // 如果T是class Avoid swap(T& a, T& b) {
    T tmp(a);  // 根據右值引用的繫結規則三可知,這裡不會呼叫move constructor,而會呼叫copy constructor    a = b;     // 根據右值引用的繫結規則三可知,這裡不會呼叫move assigment,而會呼叫copy assigment    b = tmp;   // 根據右值引用的繫結規則三可知,這裡不會呼叫move assigment,而會呼叫copy assigment}

從上例可以看到,雖然我們實現了move constructor和move assigment,但是swap()函式的例子中仍然使用的是傳統的copy constructor和copy assigment。要讓它們真正地使用move語意的拷貝和複製,就該std::move()函式登場了,看下面的例子:

void swap(A &a, A &b) {
    A tmp(std::move(a)); // std::move(a)為右值,這裡會呼叫move constructor    a = std::move(b);    // std::move(b)為右值,這裡會呼叫move assigment    b = std::move(tmp);  // std::move(tmp)為右值,這裡會呼叫move assigment}

我們不禁要問:我們通過右值應用的繫結規則三和規則四,知道右值引用不能繫結到左值,可是std::move()函式是如何把上述的左值a、 b和tmp變成右值的呢?這就要從std::move()函式的實現說起,其實std::move()函式的實現非常地簡單,下面以libcxx庫中的實現(在<type_trait>標頭檔案中)為例:

template <class _Tp>
inline typename remove_reference<_Tp>::type&& move(_Tp&& __t) {
    typedef typename remove_reference<_Tp>::type _Up;
    return static_cast<_Up&&>(__t);
}

其中remove_reference的實現如下:

template <class _Tp> struct remove_reference        {typedef _Tp type;};
template <class _Tp> struct remove_reference<_Tp&>  {typedef _Tp type;};
template <class _Tp> struct remove_reference<_Tp&&> {typedef _Tp type;};

從move()函式的實現可以看到,move()函式的形參(Parameter)型別為右值引用,它怎麼能繫結到作為實參(Argument)的左值a、b和tmp呢?這不是仍然不符合右值應用的繫結規則三嘛!簡單地說,如果move只是個普通的函式(而不是模板函式),那麼根據右值應用的繫結規則三和規則四可知,它的確不能使用左值作為其實參。但它是個模板函式,牽涉到模板引數推導,就有所不同了。C++11標準文件14.8.2.1節中,關於模板函式引數的推導描述如下:

    Template argument deduction is done by comparing each function template parameter type (call it P) with the type of the corresponding argument of the call (call it A) as described below. (14.8.2.1.1)
    If P is a reference type, the type referred to by P is used for type deduction. If P is an rvalue reference to a cvunqualified template parameter and the argument is an lvalue, the type "lvalue reference to A" is used in place of A for type deduction. (14.8.2.1.3)

大致意思是:模板引數的推導其實就是形參和實參的比較和匹配,如果形參是一個引用型別(如P&),那麼就使用P來做型別推導;如果形參是一個cv-unqualified(沒有const和volatile修飾的)右值引用型別(如P&&),並且實參是一個左值(如型別A的物件),就是用A&來做型別推導(使用A&代替A)。

template <class _Tp> void f(_Tp &&) { /* do something */ }
template <class _Tp> void g(const _Tp &&) { /* do something */ }
int x = 123;
f(x);   // ok,f()模板函式形參為非const非volatile右值引用型別,實參x為int型別左值,使用int&來做引數推導,因此呼叫f<int &>(int &)f(456); // ok,實參為右值,呼叫f<int>(int &&)g(x);   // error,g()函式模板引數為const右值引用型別,會呼叫g<int>(const int &&),通過右值引用規則四可知道,const右值引用不能繫結到左值,因此會導致編譯錯誤

瞭解了模板函式引數的推導過程,已經不難理解std::move()函式的實現了,當使用左值(假設其型別為T)作為引數呼叫std::move()函式時,實際例項化並呼叫的是std::move<T&>(T&),而其返回型別T&&,這就是move()函式左值變右值的過程(其實左值本身仍是左值,只是被當做右值對待而已,被人“抄了家”,變得一無所有)。

【說明】: move()函式改名為rval()可能會更好些,但是move()這個名字已經被使用了好些年了(C++FAQ: Maybe it would have been better if move() had been called rval(), but by now move() has been used for years.)。

7、完整的示例

至此,我們已經瞭解了不少右值引用的知識點了,下面給出了一個完整地利用右值引用實現move語意的例子:

#include <iostream>
#include <cstring>

#define PRINT(msg) do { std::cout << msg << std::endl; } while(0)

template <class _Tp> struct remove_reference        {typedef _Tp type;};
template <class _Tp> struct remove_reference<_Tp&>  {typedef _Tp type;};
template <class _Tp> struct remove_reference<_Tp&&> {typedef _Tp type;};

template <class _Tp>
inline typename remove_reference<_Tp>::type&& move(_Tp&& __t) {
    typedef typename remove_reference<_Tp>::type _Up;
    return static_cast<_Up&&>(__t);
}

class A {
public:
    A(const char *pstr) {
        PRINT("constructor");
        m_data = (pstr != 0 ? strcpy(new char[strlen(pstr) + 1], pstr) : 0);
    }
    A(const A &a) {
        PRINT("copy constructor");
        m_data = (a.m_data != 0 ? strcpy(new char[strlen(a.m_data) + 1], a.m_data) : 0);
    }
    A &operator =(const A &a) {
        PRINT("copy assigment");
        if (this != &a) {
            delete [] m_data;
            m_data = (a.m_data != 0 ? strcpy(new char[strlen(a.m_data) + 1], a.m_data) : 0);
        }
        return *this;
    }
    A(A &&a) : m_data(a.m_data) {
        PRINT("move constructor");
        a.m_data = 0;
    }
    A & operator = (A &&a) {
        PRINT("move assigment");
        if (this != &a) {
            m_data = a.m_data;
            a.m_data = 0;
        }
return *this;
    }
    ~A() { PRINT("destructor"); delete [] m_data; }
private:
    char * m_data;
};

void swap(A &a, A &b) {
    A tmp(move(a));
    a = move(b);
    b = move(tmp);
}

int main(int argc, char **argv, char **env) {
    A a("123"), b("456");
    swap(a, b);
    return 0;
}

輸出結果為:

constructor
constructor
move constructor
move assigment
move assigment
destructor
destructor
destructor

8、花絮

C++11標準引入右值引用的提案是由Howard Hinnant提出的,它的最初提案N1377在02年就提出來了,中間經歷了多次修改N1385、N1690、N1770、N1855、N1952、N2118。包括最終版本N2118在內,Howard Hinnant的提案中都使用了右值引用直接繫結到左值的例子,並且由Howard Hinnant、Bjarne Stroustrup和Bronek Kozicki三人08年10月共同署名的《A Brief Introduction to Rvalue References》文章中也有右值引用直接繫結到左值的例子,但奇怪的是C++11標準文件中卻不允許右值引用直接繫結到左值,其中的原因不得而知,但由此不難理解為什麼早些編譯器版本(如g++ 4.4.4)允許右值引用繫結到左值,而最新的編譯器卻會報錯。

另外,介紹一下Howard Hinnant及其維護的標準庫:Howard Hinnant是C++標準委員會Library Working Group老大,libcxx和libcxxabi的維護者,蘋果公司的高階軟體工程師。libcxx庫中大量地使用右值引用,想了解更多的右值引用的應用例項,可以瞅瞅libcxx的程式碼。

參考文件:

posted on 2013-04-12 17:09 Richard Wei 閱讀(885) 評論(0)  編輯 收藏 引用 所屬分類: C++