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++中你總會有一些臨時的、生命周期較短的值,這些值無論如何你都無法改變关串。
令人驚喜的是撼短,現(xiàn)代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
是一個右值:它沒有具體的內(nèi)存地址车胡,除了程序運行時的一些臨時寄存器,它需要被存儲在左值x
中留待使用优幸。在(4)處也有著類似的情況吨拍,但是這里右值不是硬編碼(hard-coded)的,而是由函數(shù)getString()
返回的网杆。然而羹饰,與(1)處一樣,這個臨時值也需要被存儲在一個左值s4
中碳却,留待將來使用队秩。
(2)和(3)處看上去更微妙一些:編譯器創(chuàng)建了一個臨時對象來存放+
操作符的結(jié)果,作為一個臨時值昼浦,輸出毫無疑問是一個必須被存放在某處的右值馍资。在這里,我分別將結(jié)果放入到y
和s3
中关噪。
二鸟蟹、右值引用的魔力
傳統(tǒng)的C++規(guī)則規(guī)定:只有存儲在const
變量(immutable)中的右值才能獲取它的地址乌妙。從技術(shù)上來說,你可以將一個const lvalue
綁定(bind)到一個rvalue
上建钥√僭希看下面的代碼:
int& x = 666; // Error
const int& x = 666; // OK
第一個操作是錯誤的,它是一個使用int類型的右值來初始化non-const
的int&
類型的非法操作熊经。第二個操作正確泽艘,當(dāng)然,x
是一個常量镐依,你不能改變他匹涮。(譯者按:注意,常量引用只是規(guī)定無法通過該引用改變引用的對象槐壳,如果該對象有其他非常量引用或者指針然低,通過這些改變該對象仍然是合法的)
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"
這里我們創(chuàng)建了兩個簡單的字符串s1
和s2
绍哎,我將它們連接并把結(jié)果放入std::string&& s_rref
中。現(xiàn)在s_rref
是一個對于臨時對象的一個引用鞋真,或者稱之為右值引用崇堰。這個引用沒有const
修飾,所以我可以根據(jù)需求隨意修改他而不需要付出任何代價涩咖。如果沒有右值引用和&&
符號海诲,想要完成這一步是不可能的。為了更好地區(qū)分右值引用和一般引用檩互,我們將傳統(tǒng)的C++引用稱作左值引用(lvalue reference)特幔。
乍一看右值引用毫無用處,然而它為移動語義(move semantics)的實現(xiàn)做了鋪墊闸昨,移動語義可以先出提升你的應(yīng)用的表現(xiàn)蚯斯。
三、移動語義——風(fēng)景秀麗的路線
移動語義(以下簡稱move)是一個最佳移動資源的方法饵较,它避免了不必要的歷史對象的拷貝拍嵌,這些都是基于右值引用的。在我看來循诉,理解什么是move最好的方法就是構(gòu)建一個動態(tài)資源(即動態(tài)分配的指針)的包裝類(wrapper class)并且觀察該類的對象被移入移出函數(shù)時發(fā)生了什么横辆。記住,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;
};
這是一個處理動態(tài)內(nèi)存塊的類狈蚤,除了動態(tài)內(nèi)存分配(allocation)部分之外沒什么特別的困肩。當(dāng)你選擇自己管理內(nèi)存時你需要遵守所謂的rule of three。規(guī)則如下:如果你的類定義了下面所說的方法中的一個或者多個脆侮,它最好顯式定義所有的三個方法:
- 析構(gòu)函數(shù)(destructor)
- 拷貝構(gòu)造函數(shù)(copy constructor)
- 拷貝復(fù)制運算符(copy assignment operator)
(如果你不定義這些函數(shù))C++的編譯器會以默認(rèn)的方式生成這些函數(shù)以及構(gòu)造函數(shù)和其他我們現(xiàn)在沒有考慮的函數(shù)锌畸。不幸的是,默認(rèn)的函數(shù)對于處理動態(tài)資源是完全不夠的他嚷。實際上蹋绽,編譯器無法生成向上面那樣的構(gòu)造函數(shù),因為它不知道我們的類的邏輯筋蓖。
1)實現(xiàn)拷貝構(gòu)造函數(shù)
讓我們先依照Rule of Three并實現(xiàn)拷貝構(gòu)造函數(shù)卸耘。正如你所知道的,拷貝構(gòu)造函數(shù)從另外一個已經(jīng)存在的對象來構(gòu)造新的對象粘咖,例如:
Holder h1(10000); // regular constructor
Holder h2 = h1; // copy constructor
Holder h3(h1); // copy constructor (alternate syntax)
一個拷貝構(gòu)造函數(shù)可能長成這樣:
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;
}
這里我使用一個已經(jīng)存在的對象other
來初始化一個新的Holder
對象蚣抗,我創(chuàng)建了一個同樣大小的數(shù)組并且我將other
里面m_data
的數(shù)據(jù)拷貝到this.m_data
中。
2)實現(xiàn)賦值運算符
現(xiàn)在我們來實現(xiàn)賦值運算符瓮下,它用于將一個已存在的對象替換為另一個已存在的對象翰铡。例如:
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),既然我們要用另一個對象來替換當(dāng)前的對象讽坏,我們需要清除當(dāng)前對象中所有的數(shù)據(jù)(2)锭魔,剩下的就和拷貝構(gòu)造函數(shù)中的一樣了。按照慣例路呜,我們返回該對象的引用迷捧。
拷貝構(gòu)造函數(shù)和賦值運算符的關(guān)鍵點就是它們都接受一個const
的對象的引用作為參數(shù)并且生成了一個它們所屬類的一個副本。
輸入的對象時常量引用胀葱,當(dāng)然無法改變漠秋!
四、現(xiàn)有類設(shè)計的限制
我們的類類很好抵屿,但是它缺少一些優(yōu)化庆锦。考慮下面的函數(shù):
Holder createHolder(int size)
{
return Holder(size);
}
它用傳值的方式返回了一個Holder
對象轧葛。我們知道搂抒,當(dāng)函數(shù)返回一個值時,編譯器會創(chuàng)建一個臨時且完整的對象(右值)〕海現(xiàn)在燕耿,我們的Holder
是一個重量級(heavy-weight)的對象,因為它有著內(nèi)部的內(nèi)存分配姜胖,這是一個相當(dāng)費事的任務(wù)——以現(xiàn)有的類設(shè)計返回這些東西的值會導(dǎo)致多次內(nèi)存分配誉帅,這并不是一個好主意。如何得出這個結(jié)論?讓我們看下面的代碼:
int main()
{
Holder h = createHolder(1000);
}
由createHolder()
創(chuàng)建的臨時對象被傳入拷貝構(gòu)造函數(shù)中蚜锨,根據(jù)我們現(xiàn)有的設(shè)計档插,拷貝構(gòu)造函數(shù)通過拷貝臨時對象的數(shù)據(jù)分配了它自己的m_data
指針。這里有兩次內(nèi)存分配:
- 創(chuàng)建臨時對象
- 拷貝構(gòu)造函數(shù)調(diào)用
同樣的拷貝過程發(fā)生在賦值操作符中:
int main()
{
Holder h = createHolder(1000); // Copy constructor
h = createHolder(500); // Assignment operator
}
我們的賦值運算符清除了對象的內(nèi)存亚再,然后通過從臨時對象中拷貝數(shù)據(jù)郭膛,為賦值的對象從頭開始分配新的內(nèi)存。在這里也有兩次內(nèi)存分配:
- 臨時對象創(chuàng)建
- 調(diào)用賦值運算符
拷貝的次數(shù)太多了氛悬!我們已經(jīng)有了一個完整的(fully-fledged)臨時對象则剃,它由createHolder()
函數(shù)創(chuàng)建。它是一個右值如捅,如果在下一個指令前不被使用將會消失棍现。所以為什么在構(gòu)造或者復(fù)制時我們不使用move而是選擇重復(fù)的拷貝呢?
在上古C++中镜遣,我們沒辦法做這樣的優(yōu)化己肮,返回一個重量級對象的值是無用的。幸運的是悲关,在C++11后谎僻,我們可以(并且鼓勵)使用move來優(yōu)化我們的類。簡而言之寓辱,我們將從現(xiàn)有的對象處偷取他們的數(shù)據(jù)而不是做一些毫無意義的克隆艘绍。不要拷貝,總是使用move秫筏,因為移動的代價更加的低鞍盗。
五、用右值引用實現(xiàn)move semantics
讓我們用move來為我們的類增光添彩跳昼!我們的想法就是增加新的版本的拷貝構(gòu)造函數(shù)和賦值運算符,這樣我們就可以將臨時對象的數(shù)據(jù)直接偷過來肋乍。“偷”的意思是改變對象中數(shù)據(jù)的擁有者鹅颊,我們怎么修改一個臨時變量呢?當(dāng)然是使用右值引用墓造!
在這里我們通常遵守另一個C++規(guī)則——Rule of Five堪伍。它是Rule of Three的擴(kuò)展,額外聲明了一個規(guī)則:任何需要move的類都要聲明兩個額外的成員函數(shù):
- 移動構(gòu)造函數(shù)(move constructor):通過從臨時對象偷取數(shù)據(jù)來構(gòu)建一個新的對象
- 移動賦值運算符(move assignment operator):通過從臨時對象偷取數(shù)據(jù)來替換已有對象的數(shù)據(jù)
1)實現(xiàn)移動構(gòu)造函數(shù)
一個典型的移動構(gòu)造函數(shù):
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;
}
它使用一個右值引用來構(gòu)造Holder
對象觅闽,關(guān)鍵部分:作為一個右值引用帝雇,我們可以修改它,所以讓我們先偷他的數(shù)據(jù)(1)蛉拙,然后將它設(shè)置為nullptr
(2)尸闸。這里沒有深層次的拷貝,我們僅僅移動了這些資源。將右值引用的數(shù)據(jù)設(shè)置為nullptr
是很重要的吮廉,因為一旦臨時對象走出作用域苞尝,它就會調(diào)用析構(gòu)函數(shù)中的delete[] m_data
,記住了嗎宦芦?通常來說宙址,為了讓代碼看上去更加的整潔,最好讓被偷取的對象的數(shù)據(jù)處于一個良好定義的狀態(tài)调卑。
六抡砂、實現(xiàn)移動賦值運算符
移動賦值運算符有著同樣的邏輯:
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;
}
我們先清理已有對象的數(shù)據(jù)(1),再從其它對象處偷取數(shù)據(jù)(2)恬涧。別忘了把臨時對象的數(shù)據(jù)設(shè)置為正確的狀態(tài)注益!剩下的就是常規(guī)的賦值運算所做的操作。
既然我們有了新的方法气破,編譯器就會檢測你到底是在使用臨時對象(右值)創(chuàng)建一個對象還是使用常規(guī)的對象(左值)聊浅,并且它會根據(jù)檢測的結(jié)果觸發(fā)更加合適的構(gòu)造函數(shù)(或者運算符)。例如:
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提供了一個更加智能的傳遞重量級對象的方法。你只需要創(chuàng)建你的重量級資源一次然后再任何需要的地方移動即可碳锈。就像我之前說的顽冶,move不只是用于類,只要在你需要改變一個資源的擁有者時都可以使用move售碳。**記住强重,跟指針不一樣的是,move不會分享任何東西贸人,如果對象A從對象B中偷去了數(shù)據(jù)间景,對象B中的數(shù)據(jù)就不再存在了,因此也就不再合法了艺智。我們知道在處理臨時對象時這沒有問題倘要,但是在從常規(guī)對象身上偷取數(shù)據(jù)時就需要慎重了。
1)我嘗試了你的代碼:移動構(gòu)造函數(shù)從來沒有被調(diào)用十拣!
你是對的封拧,如果你運行上面的最后一個代碼,你會注意到移動構(gòu)造函數(shù)在(1)處沒有被調(diào)用夭问,常規(guī)的構(gòu)造函數(shù)被調(diào)用了泽西。這是因為一個被稱作Return Value Optimization(RVO)的技法。現(xiàn)代編譯器能夠檢測出你返回了一個對象的值缰趋,并且為此應(yīng)用一種返回的快捷方式來避免無意義的拷貝捧杉。
你可以讓編譯器不使用這個優(yōu)化陕见。例如,GCC支持fno-elide-constructors
標(biāo)記糠溜,用這個標(biāo)記來編譯程序?qū)⑹沟脴?gòu)造函數(shù)和析構(gòu)函數(shù)的調(diào)用次數(shù)明顯提高淳玩。
2)為什么有了RVO我們還需要自己實現(xiàn)move semantics?
RVO僅僅針對返回值(輸出)非竿,不包括函數(shù)參數(shù)(輸入)蜕着。有許多地方你會將可移動的對象作為輸入?yún)?shù)傳入函數(shù),這時候就是移動構(gòu)造函數(shù)和移動賦值運算符發(fā)揮作用的時候了红柱。標(biāo)準(zhǔn)庫(Standard Library)在升級到C++11后承匣,所有的算法和容器都被擴(kuò)展以支持move。所以如果你使用符合Rule of Five的類和標(biāo)準(zhǔn)庫锤悄,你將會獲得重要的優(yōu)化提升韧骗。
八、我可以移動左值嗎零聚?
是的袍暴,通過標(biāo)準(zhǔn)庫中的工具函數(shù)std::move
,你可以移動左值隶症。它被用來將左值轉(zhuǎn)化為右值政模,假設(shè)我們想要從一個左值盜取數(shù)據(jù):
int main()
{
Holder h1(1000); // h1 is an lvalue
Holder h2(h1); // copy-constructor invoked (because of lvalue in input)
}
由于h2
接收了一個左值,拷貝構(gòu)造函數(shù)被調(diào)用蚂会。我們需要強(qiáng)制調(diào)用移動構(gòu)造函數(shù)從而避免無意義的拷貝淋样,所以我們這樣做:
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
轉(zhuǎn)化為一個右值:編譯器看見輸入變成了右值胁住,所以調(diào)用了移動構(gòu)造函數(shù)趁猴。h2
將會在構(gòu)造時從h1
處偷取數(shù)據(jù)。
九彪见、最終的筆記和可能的提升
這篇文章很長但是我僅僅抓住了move的表象儡司。下面列出的是我會在未來深入研究的額外概念。
1)在基礎(chǔ)的Holder
類中我們使用了RAII
Resource Acquisition Is Initialization(RAII)是一個C++技術(shù)余指,你可以在資源(文件枫慷、socket、數(shù)據(jù)庫連接浪规、分配的內(nèi)存等)周圍包裝類。這些資源可以在類的構(gòu)造函數(shù)中初始化并在類的析構(gòu)函數(shù)中清除探孝,這會避免資源泄露笋婿。
2)用noexcept
標(biāo)記你的移動構(gòu)造函數(shù)和移動賦值運算符
C++11關(guān)鍵詞noexcept
表示這個函數(shù)不會拋出異常。一些人認(rèn)為移動構(gòu)造函數(shù)和移動賦值運算符永遠(yuǎn)不要拋出異常顿颅。這是合理的缸濒,因為除了復(fù)制數(shù)據(jù)和和設(shè)置nullptr
之外(這些都是不會拋出異常的操作)不需要分配內(nèi)存或者做其它工作。
3)使用copy-and-swap的更深入的優(yōu)化和更好的異常安全性
Holder
中所有的構(gòu)造函數(shù)和賦值運算符都充滿了重復(fù)的操作,這不是很好庇配。此外斩跌,如果在拷貝運算符中進(jìn)行(內(nèi)存)分配時,如果拋出了異常捞慌,那么源對象就會變成一個不好的狀態(tài)耀鸦。copy-and-swap解決了這兩個問題,但是增加了一個新方法啸澡。
4)perfect forwarding
這項技術(shù)允許你在多個模板和非模板函數(shù)之間移動數(shù)據(jù)袖订,而不需要強(qiáng)類型轉(zhuǎn)換。