右值系列之二:用右值引用來轉移

來源:互聯網
上載者:User
第二篇:用右值引用來轉移

這是關於C++中的高效實值型別的系列文章中的第二篇。在上一篇中,我們討論了複製省略如何被用來消除可能發生的多次複製操作。複製省略是透明的,在看起來非常普通的代碼中自動發生的,幾乎沒有任何缺點。好訊息已經夠多了;下面看看壞的訊息:

  1. 複製省略不是標準強制要求的,因此你寫不出可以保證它會發生的可移植代碼。
  2. 有些時候這也做不到。例如:
  3. return q ? var1 : var2;

    被調用者最多可以將調用者傳入的記憶體用於 var1 或 var2 其中一個。如果它選擇將 var1 儲存在該記憶體中,而 q 為 false,那麼 var2 還是要被複製(反之亦然)。

  4. 複製省略很有可能超出編譯器的棧空間分配技巧的能力。
低效轉移

當一個操作是要對資料進行重排時,有很多機會可以進行最佳化。以一個簡單的泛型插入排序演算法為例:

template <class Iter>                                                  void insertion_sort(Iter first, Iter last)                              {                                                                           if (first == last) return;                                               Iter i = first;                                                         while (++i != last)     // Invariant: elements preceding i are sorted    {                                                                           Iter next = i, prev = i;                                                if (*--prev > *i)                                                       {                                                                           typename std::iterator_traits<Iter>::value_type x(*next);              do *next = *prev;            while(--next != first && *--prev > x);                                  *next = x;        }                                                                   }                                                                   }      

第7行:外層迴圈的不變式

 第12行:將第一個未排序元素複製至臨時位置

  

   第13行:將最後一個已排序元素複製向後複製

第13行:繼續向後複製,直至找到合適位置

第15行:將臨時位置的元素複製至正確位置

想象一下,如果進行排序的序列中的元素是 std::vector<std::string>,會發生什麼:在第12、13、15行,我們要潛在地複製一個字串 vector,這會導致大量的記憶體配置和資料複製。

由於排序操作從根本上說,是一種資料守恒的操作,所以這些資料複製的開銷應該都是可避免的:原則上,我們真正要做的就是將對象在序列中移來移去。

對於這些高代價的複製操作,要留意的一個重點是,在所有情況下,來源物件的值都不會再被使用。聽起來很熟悉吧?是的,當來源物件是右值時也是如此。不過這次來源物件是左值:這些對象都是有地址的。

引用計數可以嗎?

解決這類低效問題的一個常用方法是,在堆上分配元素並在序列(容器)中持有指向這些元素的引用計數智能指標,而不是直接儲存這些元素。引用計數智能指標跟普通的指標類似,只是它還跟蹤了有多少引用計數智能指標指向同一個對象,並且會在最後一個智能指標被刪時銷毀對象。複製一個引用計數指標只需遞增其引用計數即可,這是很快的。對引用計數指標賦值則是遞增一個引用計數且遞減另一個。這也是很快的。

那麼,還可以更快嗎?當然是根本就不進行計數!另外,引用計數還有其它一些我們希望避免的弱點:

  1. 它的開銷在多線程環境中是非常大的,因為計數本身要跨線程共用,這就需要同步。
  2. 在泛型代碼中該方法要失效,因為元素的類型有可能是象 int 這樣的輕量型類型。在這種情況下,引用計數的增減才是真正的效能開銷。你要麼忍受這種開銷,要麼就必須引入一個複雜的架構來確定哪些類型是輕量型的,應該直接儲存,同時還要以統一的風格來訪問這些值。
  3. 引用語義會使得代碼難以閱讀。例如:
  4. typedef std::vector<std::shared_ptr<std::string> > svec;…svec s2 = s1;std::for_each( s2.begin(), s2.end(), to_uppercase() );

    將 s2 變為大寫同時會修改到 s1 的值。這是一個比我們在這裡討論的要大得多的主題,簡而言之,當資料共用被隱藏時,看似局部的修改,其效果卻不一定是完全域部的。

引入C++0x的右值引用

為瞭解決這些問題,C++0x 引入了一種新的引用,右值引用。T 的右值引用寫作 T&&(讀作“tee ref-ref”),我們現在將原來的 T& 引用稱為“左值引用”。就我們討論的範圍而言,左值引用與右值引用的主要區別在於,非 const 的右值引用可以綁定至右值。許多C++程式員都曾經遇到過這樣的錯誤提示:

invalid initialization of non-const reference of type 'X&' from a temporary of type 'X'

這類提示通常是由以下這樣的代碼引起的:

X f();            // call to f yields an rvalueint g(X&);int x = g( f() ); // error

標準規定,非 const 的(左值)引用應綁定至一個左值,而不是一個臨時對象(即一個右值)。這是有意義的,因為對引用所引向的臨時對象進行的任何修改都肯定會丟失。與之相反,非 const 右值引用應綁定至一個臨時對象,而不是一個左值:

X f();X a;int g(X&&); int b = g( f() ); // OKint c = g( a );   // ERROR: can't bind rvalue reference to an lvalue
偷取資源

假設我們的函數 g() 要儲存一份它的參數的拷貝,以備後用:

static X cache; int g(X&& a){    cache = a;    // keep it for later} int b = g( X() ); // call g with a temporary

依賴於類型 X,這個複製可能是開銷很大的操作,可能引起記憶體配置和許多子物件的深度複製。

由於 g() 的參數是一個右值引用,我們知道它只能自動綁定到匿名臨時對象,而不是其它對象。因此,

  1. 在我們把這個臨時對象複製到 cache 之後不久,被複製的來源物件就會被銷毀。
  2. 我們對這個臨時對象的任何修改,對於程式的其它地方都是不可見的。

這給了我們一個機會來執行一些新的最佳化,通過修改臨時對象的值來避免多餘的工作。最為常見的一種最佳化就是資源偷取。

資源偷取是指從一個對象取走資源(如記憶體、大的子物件)並將資源轉移給另一個對象。例如,string 類可能擁有一個在堆上分配的字元緩衝區。複製一個 string 需要分配一塊新的緩衝區並將所有字元複製到新的緩衝區中,這看起來很慢。而偷取一個 string 則只需要讓另一個對象取走這個 string 的緩衝區並通知來源物件它不再擁有有效緩衝區——這個操作要快很多。使用右值引用,我們可以通過把從臨時對象複製改為從臨時對象偷取,來最佳化我們的代碼。同時,由於只有臨時對象被改變,所以這個最佳化在邏輯上是無改寫的。

說明:從右值引用偷取(或修改)可以在邏輯上視為無改寫操作。

右值重載用法

從以上說明我們可以得到一個新的維持語義的編程變化:我們可以用另一個在同一位置接受右值引用的版本來對接受一個(const)引用參數的任意函數進行重載:

void g(X const& a) { … }    // doesn't mutate argumentvoid g(X&& a) { modify(a); } // new overload; logically non-mutating

g 的第二個重載版本可以修改它的參數,但不會對程式的其它地方產生影響,所以它具有與第一個重載版本相同的語義。

綁定與重載

下表總結了 C++0x 對於引用綁定與重載的完整規則:

運算式→

參考型別↓

T 右值 const T 右值 T 左值 const T 左值 優先順序
T&& X       4
const T&& X X     3
T&     X   2
const T& X X X X 1

“優先順序”一列描述了這些引用在重載決議中的行為。例如,給出以下重載:

void f(int&&);        // #1void f(const int&&);  // #2void f(const int&);   // #3

把一個 const int 類型的右值傳入 f,將會調用#2,因為#1無法綁定而#3的優先順序較低。

聲明一個可轉移的類型

有了以上方法,我們可以通過兩個新的操作,轉移構造和轉移賦值,來令任意類型的右值成為可隱式轉移的,這兩個操作都是接受右值引用參數。例如,一個可轉移的 std::vector 在 C++0x 中可能會這樣寫:

template <class T, class A>struct vector{    vector(vector const& lvalue);            // copy constructor    vector& operator=(vector const& lvalue); // copy assignment operator    vector(vector&& rvalue);                 // move constructor    vector& operator=(vector&& rvalue);      // move assignment operator    …};

轉移建構函式和轉移賦值操作符的工作就是從它的參數中“偷取”資源,然後將參數置於一個可析構或可賦值的狀態。

在 std::vector 的例子中,這可能意味著將其參數置回空容器的狀態。一個典型的 std::vector 實現包含有三個指標:一個指向已指派空間的起始,一個指向最後一個元素,還有一個指向已指派空間的結尾。所以,當容器為空白時,這三個指標均為 null,轉移建構函式會象這樣:

vector(vector&& rhs)   : start(rhs.start)                // adopt rhs's storage  , elements_end(rhs.elements_end)  , storage_end(rhs.storage_end){    // mark rhs as empty.     rhs.start = rhs.elements_end = rhs.storage_end = 0;}

而轉移賦值操作符可能會是這樣:

vector& operator=(vector&& rhs){     std::swap(*this, rhs);    return *this;}

由於右值參數會馬上被銷毀,所以交換操作不僅擷取它的資源,同時還將我們本來擁有的資源“安排好”準備銷毀。

注意:先別太高興,這個轉移賦值操作符還不是很正確。
右值引用與複製省略

std::vector 的轉移建構函式的開銷非常低(大約只有對記憶體的3次讀和6次寫),但也還不是免費的。幸好,標準列明了複製省略(這是真正無代價的)的優先順序高於轉移操作。當你把一個右值以傳值方式進行傳遞時,或是從某個函數返回一個值時,編譯器首先應選擇消除複製。如果複製不能被消除,而相應的類型又具有轉移建構函式,編譯器就被要求使用轉移建構函式。最後,如果連轉移建構函式都沒有,編譯器就只能使用複製建構函式了。

舉例:

A compute(…){    A v;    …    return v;}
  1. 如果 A 具有可訪問的複製建構函式或轉移建構函式,則編譯器可以選擇消除複製
  2. 否則,如果 A 具有轉移建構函式,則 v 被轉移
  3. 否則,如果 A 具有複製建構函式,則 v 被複製
  4. 否則,編譯器報錯

因此,上一篇文章中的指引依然有效:

指引:不要複製你的函數參數。而應該以傳值的方式來傳遞它,讓編譯器來做複製。

以這個指引的提示下,你可能會問:“除了轉移建構函式和轉移賦值操作符,我還可以在哪裡使用右值重載用法呢?一旦我的所有類型都是可轉移的,那麼還有什麼要做的呢?”請看以下例子。

從左值轉移

所有的這些轉移最佳化都具有一個共通點:當我們不再使用來源物件時才可以進行最佳化。但是有些時候,我們需要提醒一下編譯器。例如:

void g(X); void f(){    X b;    g(b);    …    g(b);}

在第8行中,我們以一個左值來調用 g,這樣就不能進行資源偷取——即使我們知道 b 已不會再被用到。為了告訴編譯器可以從 b 進行轉移,我們可以用 std::move 來傳遞它:

void g(X); void f(){    X b;    g(b);              // still need the value of b    …    g( std::move(b) ); // all done with b now; grant permission to move}

注意,std::move 本身並不做任何轉移。它只是將參數變為一個右值引用,以便於在符合“轉移最佳化”的環境中可以採用轉移最佳化。當你看到 std::move 時,你可以這樣想:授予轉移的許可權。你也可以將 std::move(a) 看作是 static_cast<X&&>(a) 的描述方式。

高效轉移

現在我們有辦法對左值進行轉移了,我們可以將前幾節中的 insertion_sort 演算法最佳化一下:

template void insertion_sort(Iter first, Iter last) {     if (first == last) return;     Iter i = first;     while (++i != last)     // Invariant: [first, i) is sorted     {         Iter next = i, prev = i;         if (*--prev > *i)        {            typename std::iterator_traits::value_type               x( std::move(*next) );            do *next = std::move(*prev);            while(--next != first && *--prev > x);            *next = std::move(x);        }    }}

  

第12行:將第一個未排序元素移至臨時位置   

第13行:將最後一個已排序元素向後移

                

第13行:繼續後移

第15行:將臨時位置中的元素移至正確位置

除了格式上的差異以外,這個版本與前一個的區別僅在於增加了對 std::move 的調用。值得指出的是,我們只需要這一個 insertion_sort 的實現,不論元素類型是否具有轉移建構函式。這是典型的可轉移代碼:右值引用的設計是讓你“在可以的時候轉移,有必須的時候覆制”。

後續內容

暫時先到這裡了,不過這個系列的文章還會繼續的(很快,我保證——材料已經著手在寫了!),內容將覆蓋右值複活、異常安全、完美前轉等等。哦,對了:我們還將告訴你如何正確寫出 vector 的轉移賦值操作符。回頭見!

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.