Attention:this blog is a translation of https://www.internalpointers.com/post/c-rvalue-references-and-move-semantics-beginners ,which is posted by @internalpoiners.
一、前言
在我的前一篇文章里,我解釋了右值背后的邏輯。核心的思想就是:在C++中你總會有一些臨時的、生命周期較短的值,這些值無論如何你都無法改變。
令人驚喜的是,現代C++(通常指C++0x或者更高的版本)引入了右值引用(rvalue reference)的概念:它是一個新的可以被綁定到臨時對象的類型,允許你改變他們。為什么呢?
讓我們先看看下面的代碼:
int x = 666; // (1)
int y = x + 5; // (2)
std::string s1 = "hello ";
std::string s2 = "world";
std::string s3 = s1 + s2; // (3)
std::string getString() {
return "hello world";
}
std::string s4 = getString(); // (4)
在(1)處,字面常量(literal constant)666
是一個右值:它沒有具體的內存地址,除了程序運行時的一些臨時寄存器,它需要被存儲在左值x
中留待使用。在(4)處也有著類似的情況,但是這里右值不是硬編碼(hard-coded)的,而是由函數getString()
返回的。然而,與(1)處一樣,這個臨時值也需要被存儲在一個左值s4
中,留待將來使用。
(2)和(3)處看上去更微妙一些:編譯器創建了一個臨時對象來存放+
操作符的結果,作為一個臨時值,輸出毫無疑問是一個必須被存放在某處的右值。在這里,我分別將結果放入到y
和s3
中。
二、右值引用的魔力
傳統的C++規則規定:只有存儲在const
變量(immutable)中的右值才能獲取它的地址。從技術上來說,你可以將一個const lvalue
綁定(bind)到一個rvalue
上。看下面的代碼:
int& x = 666; // Error
const int& x = 666; // OK
第一個操作是錯誤的,它是一個使用int類型的右值來初始化non-const
的int&
類型的非法操作。第二個操作正確,當然,x
是一個常量,你不能改變他。(譯者按:注意,常量引用只是規定無法通過該引用改變引用的對象,如果該對象有其他非常量引用或者指針,通過這些改變該對象仍然是合法的)
C++ 0x引入了一個新的類型——右值引用(rvalue reference),通過在類型名后放置&&
來表示右值引用。這些右值引用讓你可以改變一個臨時對象的值,看上去好像他去掉了上面第二行中的const
了一樣。
讓我們用這個新玩具來玩耍一番:
std::string s1 = "Hello ";
std::string s2 = "world";
std::string&& s_rref = s1 + s2; // the result of s1 + s2 is an rvalue
s_rref += ", my friend"; // I can change the temporary string!
std::cout << s_rref << '\n'; // prints "Hello world, my friend"
這里我們創建了兩個簡單的字符串s1
和s2
,我將它們連接并把結果放入std::string&& s_rref
中。現在s_rref
是一個對于臨時對象的一個引用,或者稱之為右值引用。這個引用沒有const
修飾,所以我可以根據需求隨意修改他而不需要付出任何代價。如果沒有右值引用和&&
符號,想要完成這一步是不可能的。為了更好地區分右值引用和一般引用,我們將傳統的C++引用稱作左值引用(lvalue reference)。
乍一看右值引用毫無用處,然而它為移動語義(move semantics)的實現做了鋪墊,移動語義可以先出提升你的應用的表現。
三、移動語義——風景秀麗的路線
移動語義(以下簡稱move)是一個最佳移動資源的方法,它避免了不必要的歷史對象的拷貝,這些都是基于右值引用的。在我看來,理解什么是move最好的方法就是構建一個動態資源(即動態分配的指針)的包裝類(wrapper class)并且觀察該類的對象被移入移出函數時發生了什么。記住,move不只是用于類!
讓我們來看下面的例子:
class Holder
{
public:
Holder(int size) // Constructor
{
m_data = new int[size];
m_size = size;
}
~Holder() // Destructor
{
delete[] m_data;
}
private:
int* m_data;
size_t m_size;
};
這是一個處理動態內存塊的類,除了動態內存分配(allocation)部分之外沒什么特別的。當你選擇自己管理內存時你需要遵守所謂的rule of three。規則如下:如果你的類定義了下面所說的方法中的一個或者多個,它最好顯式定義所有的三個方法:
- 析構函數(destructor)
- 拷貝構造函數(copy constructor)
- 拷貝復制運算符(copy assignment operator)
(如果你不定義這些函數)C++的編譯器會以默認的方式生成這些函數以及構造函數和其他我們現在沒有考慮的函數。不幸的是,默認的函數對于處理動態資源是完全不夠的。實際上,編譯器無法生成向上面那樣的構造函數,因為它不知道我們的類的邏輯。
1)實現拷貝構造函數
讓我們先依照Rule of Three并實現拷貝構造函數。正如你所知道的,拷貝構造函數從另外一個已經存在的對象來構造新的對象,例如:
Holder h1(10000); // regular constructor
Holder h2 = h1; // copy constructor
Holder h3(h1); // copy constructor (alternate syntax)
一個拷貝構造函數可能長成這樣:
Holder(const Holder& other)
{
m_data = new int[other.m_size]; // (1)
std::copy(other.m_data, other.m_data + other.m_size, m_data); // (2)
m_size = other.m_size;
}
這里我使用一個已經存在的對象other
來初始化一個新的Holder
對象,我創建了一個同樣大小的數組并且我將other
里面m_data
的數據拷貝到this.m_data
中。
2)實現賦值運算符
現在我們來實現賦值運算符,它用于將一個已存在的對象替換為另一個已存在的對象。例如:
Holder h1(10000); // regular constructor
Holder h2(60000); // regular constructor
h1 = h2; // assignment operator
一個賦值運算符的定義可能長這樣:
Holder& operator=(const Holder& other)
{
if(this == &other) return *this; // (1)
delete[] m_data; // (2)
m_data = new int[other.m_size];
std::copy(other.m_data, other.m_data + other.m_size, m_data);
m_size = other.m_size;
return *this; // (3)
}
首先(1)處避免了將自己賦值給自己(self-assignment),既然我們要用另一個對象來替換當前的對象,我們需要清除當前對象中所有的數據(2),剩下的就和拷貝構造函數中的一樣了。按照慣例,我們返回該對象的引用。
拷貝構造函數和賦值運算符的關鍵點就是它們都接受一個const
的對象的引用作為參數并且生成了一個它們所屬類的一個副本。
輸入的對象時常量引用,當然無法改變!
四、現有類設計的限制
我們的類類很好,但是它缺少一些優化。考慮下面的函數:
Holder createHolder(int size)
{
return Holder(size);
}
它用傳值的方式返回了一個Holder
對象。我們知道,當函數返回一個值時,編譯器會創建一個臨時且完整的對象(右值)。現在,我們的Holder
是一個重量級(heavy-weight)的對象,因為它有著內部的內存分配,這是一個相當費事的任務——以現有的類設計返回這些東西的值會導致多次內存分配,這并不是一個好主意。如何得出這個結論?讓我們看下面的代碼:
int main()
{
Holder h = createHolder(1000);
}
由createHolder()
創建的臨時對象被傳入拷貝構造函數中,根據我們現有的設計,拷貝構造函數通過拷貝臨時對象的數據分配了它自己的m_data
指針。這里有兩次內存分配:
- 創建臨時對象
- 拷貝構造函數調用
同樣的拷貝過程發生在賦值操作符中:
int main()
{
Holder h = createHolder(1000); // Copy constructor
h = createHolder(500); // Assignment operator
}
我們的賦值運算符清除了對象的內存,然后通過從臨時對象中拷貝數據,為賦值的對象從頭開始分配新的內存。在這里也有兩次內存分配:
- 臨時對象創建
- 調用賦值運算符
拷貝的次數太多了!我們已經有了一個完整的(fully-fledged)臨時對象,它由createHolder()
函數創建。它是一個右值,如果在下一個指令前不被使用將會消失。所以為什么在構造或者復制時我們不使用move而是選擇重復的拷貝呢?
在上古C++中,我們沒辦法做這樣的優化,返回一個重量級對象的值是無用的。幸運的是,在C++11后,我們可以(并且鼓勵)使用move來優化我們的類。簡而言之,我們將從現有的對象處偷取他們的數據而不是做一些毫無意義的克隆。不要拷貝,總是使用move,因為移動的代價更加的低。
五、用右值引用實現move semantics
讓我們用move來為我們的類增光添彩!我們的想法就是增加新的版本的拷貝構造函數和賦值運算符,這樣我們就可以將臨時對象的數據直接偷過來。“偷”的意思是改變對象中數據的擁有者,我們怎么修改一個臨時變量呢?當然是使用右值引用!
在這里我們通常遵守另一個C++規則——Rule of Five。它是Rule of Three的擴展,額外聲明了一個規則:任何需要move的類都要聲明兩個額外的成員函數:
- 移動構造函數(move constructor):通過從臨時對象偷取數據來構建一個新的對象
- 移動賦值運算符(move assignment operator):通過從臨時對象偷取數據來替換已有對象的數據
1)實現移動構造函數
一個典型的移動構造函數:
Holder(Holder&& other) // <-- rvalue reference in input
{
m_data = other.m_data; // (1)
m_size = other.m_size;
other.m_data = nullptr; // (2)
other.m_size = 0;
}
它使用一個右值引用來構造Holder
對象,關鍵部分:作為一個右值引用,我們可以修改它,所以讓我們先偷他的數據(1),然后將它設置為nullptr
(2)。這里沒有深層次的拷貝,我們僅僅移動了這些資源。將右值引用的數據設置為nullptr
是很重要的,因為一旦臨時對象走出作用域,它就會調用析構函數中的delete[] m_data
,記住了嗎?通常來說,為了讓代碼看上去更加的整潔,最好讓被偷取的對象的數據處于一個良好定義的狀態。
六、實現移動賦值運算符
移動賦值運算符有著同樣的邏輯:
Holder& operator=(Holder&& other) // <-- rvalue reference in input
{
if (this == &other) return *this;
delete[] m_data; // (1)
m_data = other.m_data; // (2)
m_size = other.m_size;
other.m_data = nullptr; // (3)
other.m_size = 0;
return *this;
}
我們先清理已有對象的數據(1),再從其它對象處偷取數據(2)。別忘了把臨時對象的數據設置為正確的狀態!剩下的就是常規的賦值運算所做的操作。
既然我們有了新的方法,編譯器就會檢測你到底是在使用臨時對象(右值)創建一個對象還是使用常規的對象(左值),并且它會根據檢測的結果觸發更加合適的構造函數(或者運算符)。例如:
int main()
{
Holder h1(1000); // regular constructor
Holder h2(h1); // copy constructor (lvalue in input)
Holder h3 = createHolder(2000); // move constructor (rvalue in input) (1)
h2 = h3; // assignment operator (lvalue in input)
h2 = createHolder(500); // move assignment operator (rvalue in input)
}
七、何時、何處使用move semantics
move提供了一個更加智能的傳遞重量級對象的方法。你只需要創建你的重量級資源一次然后再任何需要的地方移動即可。就像我之前說的,move不只是用于類,只要在你需要改變一個資源的擁有者時都可以使用move。**記住,跟指針不一樣的是,move不會分享任何東西,如果對象A從對象B中偷去了數據,對象B中的數據就不再存在了,因此也就不再合法了。我們知道在處理臨時對象時這沒有問題,但是在從常規對象身上偷取數據時就需要慎重了。
1)我嘗試了你的代碼:移動構造函數從來沒有被調用!
你是對的,如果你運行上面的最后一個代碼,你會注意到移動構造函數在(1)處沒有被調用,常規的構造函數被調用了。這是因為一個被稱作Return Value Optimization(RVO)的技法。現代編譯器能夠檢測出你返回了一個對象的值,并且為此應用一種返回的快捷方式來避免無意義的拷貝。
你可以讓編譯器不使用這個優化。例如,GCC支持fno-elide-constructors
標記,用這個標記來編譯程序將使得構造函數和析構函數的調用次數明顯提高。
2)為什么有了RVO我們還需要自己實現move semantics?
RVO僅僅針對返回值(輸出),不包括函數參數(輸入)。有許多地方你會將可移動的對象作為輸入參數傳入函數,這時候就是移動構造函數和移動賦值運算符發揮作用的時候了。標準庫(Standard Library)在升級到C++11后,所有的算法和容器都被擴展以支持move。所以如果你使用符合Rule of Five的類和標準庫,你將會獲得重要的優化提升。
八、我可以移動左值嗎?
是的,通過標準庫中的工具函數std::move
,你可以移動左值。它被用來將左值轉化為右值,假設我們想要從一個左值盜取數據:
int main()
{
Holder h1(1000); // h1 is an lvalue
Holder h2(h1); // copy-constructor invoked (because of lvalue in input)
}
由于h2
接收了一個左值,拷貝構造函數被調用。我們需要強制調用移動構造函數從而避免無意義的拷貝,所以我們這樣做:
int main()
{
Holder h1(1000); // h1 is an lvalue
Holder h2(std::move(h1)); // move-constructor invoked (because of rvalue in input)
}
在這里,std::move
將左值h1
轉化為一個右值:編譯器看見輸入變成了右值,所以調用了移動構造函數。h2
將會在構造時從h1
處偷取數據。
九、最終的筆記和可能的提升
這篇文章很長但是我僅僅抓住了move的表象。下面列出的是我會在未來深入研究的額外概念。
1)在基礎的Holder
類中我們使用了RAII
Resource Acquisition Is Initialization(RAII)是一個C++技術,你可以在資源(文件、socket、數據庫連接、分配的內存等)周圍包裝類。這些資源可以在類的構造函數中初始化并在類的析構函數中清除,這會避免資源泄露。
2)用noexcept
標記你的移動構造函數和移動賦值運算符
C++11關鍵詞noexcept
表示這個函數不會拋出異常。一些人認為移動構造函數和移動賦值運算符永遠不要拋出異常。這是合理的,因為除了復制數據和和設置nullptr
之外(這些都是不會拋出異常的操作)不需要分配內存或者做其它工作。
3)使用copy-and-swap的更深入的優化和更好的異常安全性
Holder
中所有的構造函數和賦值運算符都充滿了重復的操作,這不是很好。此外,如果在拷貝運算符中進行(內存)分配時,如果拋出了異常,那么源對象就會變成一個不好的狀態。copy-and-swap解決了這兩個問題,但是增加了一個新方法。
4)perfect forwarding
這項技術允許你在多個模板和非模板函數之間移動數據,而不需要強類型轉換。