翻譯:怎樣理解 C++ 11中的move語義(深入)--- An answer from stackoverflow


緊接上一篇譯文,這一篇對move語義的來龍去脈有非常詳盡的回答(原文),篇幅較長,如果你能讀完,相信你不會再問任何關於move語義的問題了。

-------------------------------------------------------------------------譯文

我的第一個回答是對move語義的一個極其簡單的介紹,故意略過了很多細節。但是move語義確實還有很多需要解釋的,我想這是我給出第二個回答來填坑的時候了。第一個回答已經很久了,我覺得完全把它替換掉有點不太合適,作為一篇介紹,它依然挺好。如果你想更深入,請繼續往下讀:

Stephan T. Lavavej 給了我很多反饋,非常感謝!

引言

Move語義允許一個對象在特定的情形下,取得其他對象的資源。這在兩個方面顯得很重要:

1.將昂貴的拷貝運算變為轉移。
可以看我的第一個回答,注意,如果一個對象沒有保持至少一個額外的資源(不管是直接或間接的),通過move語義實現的轉移構造函數就不會帶來任何好處,在這種情況下,復制或轉移一個對象代價是相同的。

class cannot_benefit_from_move_semantics
{
    int a;        // moving an int means copying an int
    float b;      // moving a float means copying a float
    double c;     // moving a double means copying a double
    char d[64];   // moving a char array means copying a char array

    // ...
};

2.用於實現“安全轉移類型”。

也就是這個類型不能復制,只能轉移。例如鎖,文件句柄和唯一擁有性(unique ownership semantics)的智能指針。這里我們要討論廢棄C++ 98中的智能指針模板std::auto_ptr,在C++ 11標准中被std::unique_ptr代替。中級C++程序員可能都會對std::auto_ptr有所了解,因為他所表現出的“轉移語義”。這似乎是討論C++ 11 中move語義的一個不錯的起點。YMMV

什么是Move

C++98標准庫中提供了一種唯一擁有性的智能指針std::auto_ptr。它的作用是保證動態分配的對象總會被釋放,即使在出現異常的情況下。

1 {
2     std::auto_ptr<Shape> a(new Triangle);
3     // ...
4     // arbitrary code, could throw exceptions
5     // ...
6 }   // <--- when a goes out of scope, the triangle is deleted automatically

auto_ptr的不尋常之處在於它的“復制”行為:

 1 auto_ptr<Shape> a(new Triangle);
 2 
 3       +---------------+
 4       | triangle data |
 5       +---------------+
 6         ^
 7         |
 8         |
 9         |
10   +-----|---+
11   |   +-|-+ |
12 a | p | | | |
13   |   +---+ |
14   +---------+
15 
16 auto_ptr<Shape> b(a);
17 
18       +---------------+
19       | triangle data |
20       +---------------+
21         ^
22         |
23         +----------------------+
24                                |
25   +---------+            +-----|---+
26   |   +---+ |            |   +-|-+ |
27 a | p |   | |          b | p | | | |
28   |   +---+ |            |   +---+ |
29   +---------+            +---------+

注意b是怎樣使用a進行初始化的,它不復制triangle,而是把triangle的所有權從a傳遞給了b,也可以說成“a 被轉移進了b”或者“triangle被從a轉移到了b”。這或許有點令人困惑,因為triangle對象本身一直保存在內存的同一位置。

auto_ptr 的復制構造函數可能看起來像這樣(簡化):

 

1 auto_ptr(auto_ptr& source)   // note the missing const
2 {
3     p = source.p;
4     source.p = 0;   // now the source no longer owns the object
5 }

危險的和安全的轉移

auto_ptr 的危險之處在於看上去應該是復制,但實際上確實轉移。調用被轉移過的auto_ptr 的成員函數將會導致不可預知的后果。所以你必須非常謹慎的使用auto_ptr ,如果他被轉移過。

1 auto_ptr<Shape> a(new Triangle);   // create triangle
2 auto_ptr<Shape> b(a);              // move a into b
3 double area = a->area();           // undefined behavior

但是auto_ptr並不總是危險的。工廠模式方法正式auto_ptr應用最合適的場景:

1 auto_ptr<Shape> make_triangle()
2 {
3     return auto_ptr<Shape>(new Triangle);
4 }
5 
6 auto_ptr<Shape> c(make_triangle());      // move temporary into c
7 double area = make_triangle()->area();   // perfectly safe

注意以下兩個例子是如何使用同一種語法形式的:

1 auto_ptr<Shape> variable(expression);
2 double area = expression->area();

他們其中只有一個導致未定義的行為,那么,表達式amake_triangle()到底有什么不同呢?難道他們不是同一種類型嗎?他們當然是,但是他們卻有不同的值類型。

值類型

顯然,在持有auto_ptr 對象的a表達式和持有調用函數返回的auto_ptr值類型的make_triangle()表達式之間一定有一些潛在的區別,每調用一次后者就會創建一個新的auto_ptr對象。這里a 其實就是一個左值(lvalue)的例子,而make_triangle()就是右值(rvalue)的例子。 

轉移像a這樣的左值是非常危險的,因為我們可能調用a的成員函數,這會導致不可預知的行為。另一方面,轉移像make_triangle()這樣的右值卻是非常安全的,因為復制構造函數之后,我們不能再使用這個臨時對象了。表達式本身不能說是臨時的;如果我們再次簡單地寫下make_triangle(),我們會得到另一個臨時對象。事實上,這個轉移后的臨時對象會在下一行之前銷毀掉。

1 auto_ptr<Shape> c(make_triangle());
2                                   ^ the moved-from temporary dies right here

注意到字母l和r歷史上源於賦值表達式的左邊和右邊。現在,這已經不准確了,因為有不能出現在賦值表達式左側的左值(比如數組和用戶定義的沒有賦值操作符的類型),也有右值卻可以(所有的有賦值操作符的類)。

一個類的右值是一個創建臨時對象的表達式。正常情形下,在同一作用域內,不會有另外的表達式再來表示同一臨時對象。

右值引用

我們現在知道轉移左值是十分危險的,但是轉移右值卻是很安全的。如果C++能從語言級別支持區分左值和右值參數,我就可以完全杜絕對左值轉移,或者把轉移左值在調用的時候暴露出來,以使我們不會不經意的轉移左值。 

C++ 11對這個問題的答案是右值引用。右值引用是針對右值的新的引用類型,語法是X&&。以前的老的引用類型X& 現在被稱作左值引用(注意X&&不是引用的引用,C++不存在這種類型)。

如果再加入一個const,我就有了四種不同類型的引用類型。類型X可以使用到哪些表達式類型?

                lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&              yes
const X&      yes      yes            yes      yes
X&&                                    yes
const X&&                              yes      yes
                

實際使用中,你可以不用管const X&&類型,限制為只讀的右值類型基本是沒什么用處的。

右值引用X&&是一種僅僅針對於右值的新的引用類型。

隱式轉換

右值引用已經經歷過了幾個版本。從版本2.1開始,右值引用X&&可以在所有的值類型Y上使用,只有提供一個從YX的隱式轉換。這種情形下,一個臨時X對象被創建,右值引用會指向這個臨時對象。

1 void some_function(std::string&& r);
2 some_function("hello world");

在上面的例子中,"hello world"是類型 const char[12]的左值,因為const char[12]可以通過const char*轉換為std::string所以會創建一個臨時的string對象,參數r會被指向到這個臨時對象上。這是右值(表達式)和臨時對象之間的區分不那么明顯的幾種情形之一。

轉移構造函數

使用右值引用X&&作為參數的最有用的函數之一就是轉移構造函數X::X(X&& source),它的主要作用是把源對象的本地資源轉移給當前對象。

C++ 11中,std::auto_ptr<T>已經被std::unique_ptr<T>所取代,后者就是利用的右值引用。我們接下來會討論簡化版的std::unique_ptr<T>。首先,我們封裝了一個原始指針,並且重載了->和*操作符,這樣我們的類就可以像一個指針一樣了。

 1 template<typename T>
 2 class unique_ptr
 3 {
 4     T* ptr;
 5 
 6 public:
 7 
 8     T* operator->() const
 9     {
10         return ptr;
11     }
12 
13     T& operator*() const
14     {
15         return *ptr;
16     }

構造函數取得一個對象的所有權,析構函數釋放這個對象。

1     explicit unique_ptr(T* p = nullptr)
2     {
3         ptr = p;
4     }
5 
6     ~unique_ptr()
7     {
8         delete ptr;
9     }

現在,有趣的地方到了,轉移構造函數:

1     unique_ptr(unique_ptr&& source)   // note the rvalue reference
2     {
3         ptr = source.ptr;
4         source.ptr = nullptr;
5     }

這個轉移構造函數跟auto_ptr中復制構造函數做的事情一樣,但是它卻只能接受右值作為參數。

1 unique_ptr<Shape> a(new Triangle);
2 unique_ptr<Shape> b(a);                 // error
3 unique_ptr<Shape> c(make_triangle());   // okay

第二行不能編譯通過,因為a是左值,但是參數unique_ptr&& source只能接受右值,這正是我們所需要的,杜絕危險的隱式轉移。第三行編譯沒有問題,因為make_triangle()是右值,轉移構造函數會將臨時對象的所有權轉移給對象c。這也正是我所需要的。

轉移構造函數將本地資源的所有權轉移給當前對象。

轉移賦值操作符

關於move語義的最后一部分是轉移賦值操作符。它的作用是釋放就資源,並從參數中獲取新資源:

    unique_ptr& operator=(unique_ptr&& source)   // note the rvalue reference
    {
        if (this != &source)    // beware of self-assignment
        {
            delete ptr;         // release the old resource

            ptr = source.ptr;   // acquire the new resource
            source.ptr = nullptr;
        }
        return *this;
    }
};

注意注意賦值操作符的這個實現復制了析構函數和轉移構造函數的邏輯。你熟悉 copy-and-swap慣用法嗎?它也可以用到move語義上,成為move-and-swap慣用法:

1     unique_ptr& operator=(unique_ptr source)   // note the missing reference
2     {
3         std::swap(ptr, source.ptr);
4         return *this;
5     }
6 };

現在 source是unique_ptr類型的變量,它將會被轉移構造函數初始化,也就是說這個變量將會轉化為參數,這個變量還是要求是右值,因為轉移構造函數的參數是指向這個右值的引用。當這個控制流到達賦值操作符operator=的關閉大括號時,source會脫離作用域,自動釋放資源。

轉移賦值操作符將本地資源的所有權轉移給當前對象,並釋放對象的舊資源,move-and-swap慣用法簡化了這個實現。

轉移左值

有時候,我們可能想轉移左值,也就是說,有時候我們想讓編譯器把左值當作右值對待,以便能使用轉移構造函數,即便這有點不安全。出於這個目的,C++ 11在標准庫的頭文件<utility>中提供了一個模板函數std::move。這個函數名稱有點不盡如人意,因為std::move僅僅是簡單地將左值轉換為右值,它本身並沒有轉移任何東西。它僅僅是讓對象可以轉移。或許它應該被命名為std::cast_to_rvalue 或者 std::enable_move,但是現在我們對這個名稱還束手無策。

以下是如何是如何正確的轉移左值:

1 unique_ptr<Shape> a(new Triangle);
2 unique_ptr<Shape> b(a);              // still an error
3 unique_ptr<Shape> c(std::move(a));   // okay

請注意,第三行之后,a不在擁有Triangle對象。 不過這沒有關系,因為通過明確的寫出std::move(a),我們很清楚我們的意圖:親愛的轉移構造函數,你可以對a做任何想要做的事情來初始化c;我不要需要a了,對於a,您請自便。

std::move(some_lvalue)將左值轉換為右值,使接下來的轉移成為可能。

Xvalues

注意即便std::move(a)是右值,但它自己並不生成一個臨時對象。這個難題讓C++委員會引入了第三種值類型。一種可以被右值引用指向,但是又不是傳統意義上的右值的類型,這種類型被叫做Xvalues(即將被釋放的value)。傳統意義上的右值被重新命名為絕對右值(prvalues ,Pure rvalues)。

絕對右值和Xvalue都是右值,Xvalue和左值都是普通左值(glvalues ,Generalized lvalues),通過下圖可能更容易理解他們之間的關系:

      expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

請注意,只有xvalues是新類型,其他類型僅僅是換了個名稱,容易分類。 

C++ 98中的右值在C++ 11中被稱為絕對右值,也就是上圖中的prvalues。

轉移出函數內部

到目前為止,我們看到的都是轉移局部變量和函數參數。但是轉移也適用於其他情形。如果函數按值返回,調用它的對象(可能是一個局部變量,或者一個臨時對象,也可能是任何類型的對象)被以函數返回后的表達式作為參數的轉移構造函數初始化:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | temporary is moved into c
                  |
                  v
unique_ptr<Shape> c(make_triangle());

貌似有點奇怪,局部變量沒有被聲明為static類型,也能被隱式地轉移出函數:

1 unique_ptr<Shape> make_square()
2 {
3     unique_ptr<Shape> result(new Square);
4     return result;   // note the missing std::move
5 }

如果轉移構造函數接受一個左值result作為一個參數會怎么樣呢?result的作用域馬上就要結束,它將會在出棧時被釋放。沒有人會抱怨后來result以某種方式改變了,當控制流轉會給調用者的時候,result已經不存在了!基於這個原因,C++ 11有一個特殊的規則來允許函數不調用std::move返回自動釋放的對象。事實上,永遠不要把自動釋放類型的對象轉移出函數內部,因為這和返回值優化(NRVO)相沖突。

永遠不要使用std::move把自動釋放類型的對象轉移出函數內部

請注意兩種工廠模式方法返回的都是值,而不是右值引用。右值引用依然是引用,和往常一樣,永遠不要返回一個局部自動釋放對象的引用;如果你對編譯器寫下了像下面這樣的代碼,調用者會得到一個空懸的引用:

1 unique_ptr<Shape>&& flawed_attempt()   // DO NOT DO THIS!
2 {
3     unique_ptr<Shape> very_bad_idea(new Square);
4     return std::move(very_bad_idea);   // WRONG!
5 }

永遠不要返回一個局部自動釋放對象的右值引用。轉移是為轉移構造函數量身定做的,而不是std::move,也不僅僅是指向右值的右值引用。

轉移進成員變量

你遲早會下這樣的代碼:

 1 class Foo
 2 {
 3     unique_ptr<Shape> member;
 4 
 5 public:
 6 
 7     Foo(unique_ptr<Shape>&& parameter)
 8     : member(parameter)   // error
 9     {}
10 };

編譯器肯定會“抱怨”parameter 本身是左值。如果你查看它的類型,它是右值引用,但是右值引用僅僅是指向右值的引用,並不意味着右值引用本身是右值。事實上,parameter 僅僅是一個普通變量名而已,在構造函數內部,你可以想怎么用就怎么用,它永遠指向同一個對象。對它進行隱式的轉移是危險的,因此C++從語言層面上禁止這樣使用。

一個命名的右值引用本身是一個左值,跟其他普通左值一樣。

這個問題的解決方案是手動讓讓它可以轉移:

 1 class Foo
 2 {
 3     unique_ptr<Shape> member;
 4 
 5 public:
 6 
 7     Foo(unique_ptr<Shape>&& parameter)
 8     : member(std::move(parameter))   // note the std::move
 9     {}
10 };

你可能會說parameter 在初始化member之后就不再使用了,為什么這里沒有像函數返回值一樣自動插入std::move這條特殊規則?或許是因為這會給編譯器的實現帶來太多負擔。例如,what if the constructor body was in another translation unit?(不理解),相反,函數返回值規則只需要簡單的檢測符號表就能知道函數返回值后變量是否會自動釋放。 

你也可以對parameter 傳值。對於像unique_ptr這樣的轉移類型,似乎還沒有成文的機制。就我個人而言,我喜歡傳值,因為這樣接口會更清晰。

特殊成員函數

C++ 98標准會在需要的時候自動生成三種特殊類型的成員函數:復制構造函數,賦值操作符和析構函數

1 X::X(const X&);              // copy constructor
2 X& X::operator=(const X&);   // copy assignment operator
3 X::~X();                     // destructor

Rvalue經歷過了很多版本,從版本3.0開始,C++ 11標准增加了另外兩種特殊成員函數:轉移構造函數和轉移賦值操作符。注意VC10和VC11都還沒有實現3.0版本,所以,你需要自己實現他們。

1 X::X(X&&);                   // move constructor
2 X& X::operator=(X&&);        // move assignment operator

這兩個新特殊成員函數只有在沒有手動聲明的情況下,才會自動聲明。而且,如果你手動聲明了轉移構造函數和轉移賦值操作符,復制構造函數和復制操作符都不會被自動聲明了。 

這在實際應用中意味着什么呢?

如果你在寫了一個不需要管理資源的類,這五種特殊成員函數就都不需要手動聲明,而且你會得到正確的復制和轉移。否則你需要自己實現這五種特殊成員函數,當然,如果你的類型使用轉移不會帶來任何好處,那你就沒必要自己實現和轉移有關的特殊成員函數(轉移構造函數和轉移賦值操作符)。 

注意到復制賦值操作符和轉移賦值操作符可以合並到一起,形成一個以值為參數的統一賦值操作符:

1 X& X::operator=(X source)    // unified assignment operator
2 {
3     swap(source);            // see my first answer for an explanation
4     return *this;
5 }

這樣的話,五個需要實現的特殊成員函數就變成了四個,這里有個異常安全和效率的平衡問題,但是我不是這方面的專家。

通用引用類型

考慮以下模板函數:

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

你可能希望T&&僅僅指向右值,因為乍一看,它像一個右值引用,但是事實證明,它也可以指向左值:

1 foo(make_triangle());   // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&
2 unique_ptr<Shape> a(new Triangle);
3 foo(a);                 // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

如果參數是是X類型的右值,模板參數T會被推斷為X類型,因此T&&會成為X&&,這正是大家想看到的,但是如果參數是X類型的左值,根據那條特殊規則,模板參數T會被推斷為X&,因此T&& 會成為這個樣子:X& &&,但是鑒於C++標准還沒有引用的引用類型,X& &&這個類型會被壓縮為X&。起初,這似乎看起來有點令人困惑和無用,但是壓縮引用是完美推導(perfect forwarding)的本質,這里我們不討論。

T&&不是一個右值引用,而是一個通用引用類型。它也以指向左值,在這種情況下,T和T&&都是左值引用。

如果你想限制函數模板參數為右值,你可以聯合SFINAE(Substitution Failure Is Not an Error,匹配失敗不是錯誤)和類型萃取(type traits):

1 #include <type_traits>
2 
3 template<typename T>
4 typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
5 foo(T&&);

轉移的實現

現在你理解了什么是引用壓縮,這里我們我來看一下std::move的實現:

1 template<typename T>
2 typename std::remove_reference<T>::type&&
3 move(T&& t)
4 {
5     return static_cast<typename std::remove_reference<T>::type&&>(t);
6 }

正如你看到的,由於通用引用類型T&&,move接受任何類型的參數,而且它返回右值引用。調用std::remove_reference<T>::type是有必要的,因為如果X為左值類型,返回值類型將為變為X& &&,這將被壓縮為X&。由於t永遠是一個左值(命名的右值引用是左值),但是我們想把t綁定到右值引用,我就得顯示地將t轉換為想要的返回值類型。調用返回右值引用函數的本身是xvalue。現在你知道xvalues是怎么來的了吧。

調用返回右值引用函數的本身是xvalue,例如std::move

注意到這個例子中返回右值引用是沒有問題的,因為t沒有指向任何局部自動銷毀對象,而是指向由調用者傳進來的一個對象。(全文完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM