C++左值、右值、左值引用、右值引用與move語義

左值與右值

  C++的值現在分爲很多種類型:lvalue、xvalue、glvalue、rvalue、prvalue具體定義見:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2010/n3055.pdf

An lvalue (so-called, historically, because lvalues could appear on the left-hand side of an assignment expression) designates a function or an object. [Example: If E is an expression of pointer type, then *E is an lvalue expression referring to the object or function to which E points. As another example, the result of calling a function whose return type is an lvalue reference is an lvalue.]
An xvalue (an “eXpiring” value) also refers to an object, usually near the end of its lifetime (so that its resources may be moved, for example). An xvalue is the result of certain kinds of expressions involving rvalue references. [Example: The result of calling a function whose return type is an rvalue reference is an xvalue.]
A glvalue (“generalized” lvalue) is an lvalue or an xvalue.
An rvalue (so-called, historically, because rvalues could appear on the right-hand side of an assignment expression) is an xvalue, a temporary object or subobject thereof, or a value that is not associated with an object.
A prvalue (“pure” rvalue) is an rvalue that is not an xvalue. [Example: The result of calling a function whose return type is not a reference is a prvalue]

一些具體的解析,可以參考:https://en.cppreference.com/w/cpp/language/value_category,一般主要關注左值(lvalue)和右值(rvalue),可以簡單(但不準確)地認爲:左值是一個變量,可以用取址運算&取得它的內存地址;右值沒有變量名,只存在於內存(或者寄存器)的值,沒法用&取得它的地址。舉例:

int a = 1 + 2; // a是一個變量,是左值。1和2沒有變量名,是右值

 引用

  簡單地說,就是把一個值綁定到一個變量,分爲左值引用右值引用。具體的解析見:https://en.cppreference.com/w/cpp/language/reference_initialization。舉例:

int a = 0;

int &b = a; // 把值a綁定到變量b,左值引用
int &&c = 5; // 把值5綁定到變量c,右值引用

move語義

  把一個變量轉換爲右值引用類型,其實就是用static_cast轉換爲右值引用類型而已,在編譯時處理。見:https://en.cppreference.com/w/cpp/utility/move。舉例:

int a = 0;

// 這是錯的,a的類型是int,b的類型是int &&,無法轉換
// cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
// int &&b = a;

int &&b = std::move(a); // move把a的類型轉換爲右值引用類型,即int&&

 常見錯誤

  上面說了這麼多概念性的東西,是因爲在日常使用過程中,經常會犯一些錯誤。

1. 右值引用比左值引用快

class Test;

Test t;

Test &t1 = t;
Test &&t2 = std::move(t);

上面的代碼中,t2的寫法並不比t1快。上面說了,std::move實際是一個static_cast,編譯時就處理完了。t1和t2都是引用,並沒有效率上的差別,它們只是類型不一樣。

2. std::move之後,原來的變量就不可以用了

有些人把std::move理解爲轉移,Test &&t2 = std::move(t); 會把t的內容轉換到t2中,所以t不再可用。不是的,t2只是引用了t,std::move是編譯時,在運行時啥都不做,沒有移動任何東西。導致原來的變量不可用的是move拷貝構造函數,見下面的解釋。

3. 右值引用、類型爲右值引用的左值、右值引用賦值

void set(int &&i)
{
}

int t = 0;
int &&t1 = std::move(t);

// error: cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
set(t1);

set(1);

上面的代碼,通常認爲t1的類型int的右值引用(int&&),而set函數的參數i類型也是int的右值引用,所以set(t1)這個調用是正確的,然而編譯器會拋出一個錯誤:error: cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’。t1的類型int的右值引用(int&&),而set函數的參數i類型也是int的右值引用,這個是對的,但是忽略了另一個問題:右值引用的賦值。C++中只能把一個右值賦給(或者稱bind,綁定)右值引用,例如:

int t = 0;
int &&t1 = i; // ERROR

int &&t2 = 0; // OK

上面的t1,也會報同樣的錯誤:cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’,即不能把一個左值賦值給右值引用,這是語法規則,要死記硬背。回到set(t1)函數調用問題,t1的類型是int的右值引用(int&&)沒錯,但它是一個變量,用&可以取到它的地址,所以,它是一個類型爲int的右值引用(int&&)的左值,既然它是一個左值,那左值沒法賦值給右值引用(set函數的參數i),所以報錯了。std::move可以把左值轉換爲右值,所以set(std::move(t1))這樣調用纔是正確的。更多的討論參考:

https://stackoverflow.com/questions/38604070/passing-rvalue-raises-cannot-bind-to-lvaluehttps://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers

4. 右值引用、move構造函數創建新對象

Test t;

Test &t1 = t;
Test &&t2 = std::move(t);

Test t3 = t;
Test t4 = std::move(t);

Test t5(t);
Test t6(std::move(t));

上面的代碼中,t2是創建一個右值引用,實際上很少看到這個寫法,因爲沒什麼意義,用普通的引用也是一樣的,強制把t轉成右值再做一個右值引用是多此一舉。t4是調用move構造函數構造一個新對象,原對象t不應該再繼續使用。

5. move賦值操作符和move構造函數

move賦值操作符:class_name & class_name :: operator= ( class_name && )move構造函數:class_name ( class_name && ),當用右值創建對象時,調用move構造函數,當把右值賦值給對象時,用move賦值操作值。如:

class Test;

Test t;

Test t1 = std::move(t); // 創建新對象,用move構造函數
t1 = std::move(t); // 賦值操作,用move賦值操作符

https://en.cppreference.com/w/cpp/language/move_assignment

https://en.cppreference.com/w/cpp/language/move_constructor

6. move賦值和move構造之後,原變量的析構問題

move賦值和move構造存在的意義,就是直接使用原對象的資源,減少拷貝:Move constructors typically "steal" the resources held by the argument (e.g. pointers to dynamically-allocated objects, file descriptors, TCP sockets, I/O streams, running threads, etc.) rather than make copies of them,以提升效率。那這就引發一個問題,使用了原對象的資源,那原對象怎麼辦?注意,拷貝構造函數的參數,是const的,但move構造函數的並不是,說明move構造函數裏,是需要對原對象進行處理的。直接使用原對象的資源,那就需要對原對象進行一些合適的處理,以保證不會操作被接管的資源導致錯誤。例如,gcc的std::string的move構造函數中,會把原對象的長度設置爲0(__str._M_set_length(0);)。

https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/include/bits/basic_string.h (大概在545行,不同版本會變)

      /**
       *  @brief  Move construct string.
       *  @param  __str  Source string.
       *
       *  The newly-created string contains the exact contents of @a __str.
       *  @a __str is a valid, but unspecified string.
       **/
      basic_string(basic_string&& __str) noexcept
      : _M_dataplus(_M_local_data(), std::move(__str._M_get_allocator()))
      {
    if (__str._M_is_local())
      {
        traits_type::copy(_M_local_buf, __str._M_local_buf,
                  _S_local_capacity + 1);
      }
    else
      {
        _M_data(__str._M_data());
        _M_capacity(__str._M_allocated_capacity);
      }

    // Must use _M_length() here not _M_set_length() because
    // basic_stringbuf relies on writing into unallocated capacity so
    // we mess up the contents if we put a '\0' in the string.
    _M_length(__str.length());
    __str._M_data(__str._M_local_data());
    __str._M_set_length(0);
      }

7. 基礎數據類型的move構造函數

基礎數據類型,如int,是沒有move構造函數的。因此:int a = std::move(i)和int a = i它們的結果是一樣的。參考:fundamental types don't have move constructors. Moves degrade to copies https://stackoverflow.com/questions/14679605/do-built-in-types-have-move-semantics

8. 有必要實現move構造函數嗎,能提升效率嗎

不一定,得看情況。因爲基礎數據類型沒有move構造函數,所以,如果你的類成員只有基礎數據類型,那沒必要實現move構造函數,實現了它也提升不了效率。

9. move解決了返回臨時對象效率低的問題了嗎

很多情況下,我們需要返回一個臨時對象,如:

class Test get()
{
    class Test t;
    // ...
    return t;      
}

class Test t1 = get();

首先,在get函數裏,會先構造一個t對象,然後返回時,會在內存中構造一個臨時對象,然後賦值給t1時,再構造一個t1對象。這一來一回,就有三次對象的構造,三次析構,在別的語言裏,一般都是隻有一次構造一次析構,C++的這種機制,明顯談不上效率高。那現在有了move,可以把一個變量中的資源“移動”到另一個變量中,那這個問題是不是就解決了。不,std::move和move構造函數解決不了這個問題:

class Test get()
{
    class Test t;
    // ...
    return t; // 常規寫法,效率不高
    return std::move(t); // 把t轉換成一個右值完全沒有意義,多此一舉 
}

// 嘗試返回一個右值引用提高效率,不好意思,t是臨時對象,不能這麼幹
class Test &&get()
{
    class Test t;

    return std::move(t);
}

class Test t1 = get();  // 常規寫法,效率不高
class Test &&t1 = get(); // 採取引用臨時對象而不是創建一個t1對象,省去一次構造,還是有兩次構造
class Test t1 = std::move(get()); // get返回的值本來就是右值,std::move是多此一舉
class Test &&t1 = std::move(get()); // std::move是多此一舉

可以看到,只用std::move和move構造函數,無論你怎麼組合,都沒有像預期一樣只構造一次,只析構一次。move構造函數只能接管另一個對象裏的部分可接管的資源(基礎類型就無法接管),所以如果你的對象裏有大量可接管的資源(比如已分配的大量內存),這一部分纔會被優化。它優化了對象拷貝,但和臨時對象沒什麼關係。那這個臨時對象的問題,是不是就沒法解決呢,也不是,請看下面的Copy Elision。

10. Copy Elision

複製消除(copy elision)或者稱RVO(Return Value Optimization,返回值優化),在C++11之前,部分編譯器就有這個特性,稱爲RVO,C++11標準出來之後,這個特性放到了標準當中,稱爲copy elision。先看個例子:

#include <iostream>

class Test
{
public:
    Test()
    {
        std::cout << "construct" << std::endl;
    }

    ~Test()
    {
        std::cout << "destruct" << std::endl;
    }

    Test(const Test &other)
    {
        std::cout << "copy construct" << std::endl;
    }

    Test(const Test &&other)
    {
        std::cout << "move construct" << std::endl;
    }
};

class Test get()
{
    class Test t;

    return t;
}

int main()
{
    class Test t1 = get();

    return 0;
}
$ g++ -fno-elide-constructors -O0 -g test.cpp 
$ ./a.out 
construct
move construct
destruct
move construct
destruct
destruct

$ g++ -O0 -g test.cpp 
$ ./a.out 
construct
destruct

$ gdb ./a.out 
get () at test.cpp:31
31	    return t;
(gdb) p &t
$1 = (Test *) 0x7fffffffde27
(gdb) s
32	}
(gdb) s
main () at test.cpp:38
38	    return 0;
(gdb) p &t1
$2 = (Test *) 0x7fffffffde27

在沒有複製消除時(-fno-elide-constructors),程序像上面說的那樣,執行了三次對象構造,三次析構。而後面正常編譯時,只執行了一次構造一次析構,通過gdb調試發現,get函數裏的對象t的地址,和main函數裏的t1的地址,是一樣的。這是因爲編譯器做了優化,直接把get函數裏的對象創建在main函數裏和t1的地址,這樣返回時就不用再創建多餘的對象。copy elision需要編譯器能預先計算出返回位的位置,這樣才能把臨時對象直接在返回值那裏創建,如果計算出不來,那這個優化就不會執行,如:

class Test get(int i)
{
    if (0 == i)
    {
        class Test t0;
        return t0;
    }
    class Test t;
    return t;
}

編譯器在編譯時,無法知道是應該把t0還是t創建在返回值的地址,因爲這裏沒有copy elision優化。參考:https://en.cppreference.com/w/cpp/language/copy_elision

 

以上內容我自己日常使用遇到的問題,查找各種資料得出的結論,不一定正確,歡迎指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章