在上一篇理解C++中的左值和右值中,我們解釋了右值的邏輯:無論如何,C++不允許對(duì)右值進(jìn)行修改。
但是,現(xiàn)代C++(C++11及之后)帶來了右值引用:一種可以綁定到右值的新類型,還可以對(duì)它進(jìn)行修改。為什么這樣做呢?
我們先復(fù)習(xí)下右值的一些場景:
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)處,字面常量666是一個(gè)右值:它沒有特定的內(nèi)存地址,只是在程序運(yùn)行時(shí)存儲(chǔ)在某些臨時(shí)寄存器上。它只有在被賦值給左值x后才有意義。
第(4)處與第(1)處類似,不同的是這里的右值并不是硬編碼的,它來自一個(gè)函數(shù)的返回值。和第(1)處一樣,這個(gè)臨時(shí)對(duì)象只有在被賦值給左值S4后才有意義。
第(2)和(3)處更復(fù)雜一些:編譯器會(huì)創(chuàng)建一個(gè)臨時(shí)對(duì)象來保存operator +的結(jié)果。operator +的結(jié)果是一個(gè)右值,因此我們也需要將它賦值給變量y和s3。
右值引用
傳統(tǒng)C++中,右值只有被存儲(chǔ)在const變量時(shí),才能獲取其地址?;蛘哒f, 我們只能綁定常量左值到右值。就像下面這樣:
int& x = 666; // Error
const int& x = 666; // OK
第一行是錯(cuò)誤的:我們不能用一個(gè)右值來初始化左值引用int &。
第一行是OK的,當(dāng)然,因?yàn)?code>x是常量,你也無法修改它。
C++11引入了一個(gè)新的類型:右值引用,用符號(hào)&&來表示。這樣一來,右值也可以被修改了。
下面我們就來玩玩這個(gè)新玩具:
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)建了兩個(gè)字符串s1和s2。把它們連接起來,并把結(jié)果(是一個(gè)臨時(shí)的字符串,右值)保存到std::string&& s_rref. 現(xiàn)在s_rref是一個(gè)右值引用了。我們可以根據(jù)我們的需要對(duì)這個(gè)臨時(shí)字符串進(jìn)行修改。如果沒有右值引用,這是不可能的。為了更好的區(qū)分,我們將傳統(tǒng)的C++引用稱為左值引用(只有一個(gè)&)。
咋一看這個(gè)右值引用可能沒什么鳥用。但是右值引用引申出了移動(dòng)語義,一種可以顯著提升程序性能的技術(shù)。
移動(dòng)語義
移動(dòng)語義是指一種基于右值引用來實(shí)現(xiàn)的可避免不必要的臨時(shí)對(duì)象拷貝的移動(dòng)資源的新方法。
在我看來,了解移動(dòng)語義的最佳方式是構(gòu)建一個(gè)包含動(dòng)態(tài)內(nèi)存分配的包裝類,在資源進(jìn)出的函數(shù)中跟蹤它。記住,移動(dòng)語義并不僅僅適用于類。
看看下面這個(gè)例子:
class Holder
{
public:
Holder(int size) // 構(gòu)造函數(shù)
{
m_data = new int[size];
m_size = size;
}
~Holder() // 析構(gòu)函數(shù)
{
delete[] m_data;
}
private:
int* m_data;
size_t m_size;
};
在這個(gè)簡單的類中,它管理一塊動(dòng)態(tài)分配的內(nèi)存。如果需要自己管理內(nèi)存,我們需要遵守C++中的三法則。它的要求是,假如類有明顯定義下列其中一個(gè)成員函數(shù),那么程序員必須寫入其他兩個(gè)成員函數(shù)到類內(nèi),也就是說下列三個(gè)成員函數(shù)缺一不可。
- 析構(gòu)函數(shù)
- 拷貝構(gòu)造函數(shù)
- 賦值函數(shù)
如果都沒有定義,編譯器將生成這三個(gè)成員函數(shù)的默認(rèn)版本。
糟糕的是,如果你的類中有管理動(dòng)態(tài)內(nèi)存,默認(rèn)版本還不夠用。因?yàn)榫幾g器根本不知道你的業(yè)務(wù)邏輯和需求是啥。
實(shí)現(xiàn)拷貝構(gòu)造函數(shù)
我們先來實(shí)現(xiàn)三法則中的拷貝構(gòu)造函數(shù)??截悩?gòu)造函數(shù)是用來從現(xiàn)有對(duì)象創(chuàng)建新對(duì)象。例如:
Holder h1(10000); // 構(gòu)造函數(shù)
Holder h2 = h1; // 拷貝構(gòu)造函數(shù)
Holder h3(h1); // 拷貝構(gòu)造函數(shù) (另一種語法)
它的拷貝構(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;
}
這里我們從一個(gè)現(xiàn)有對(duì)象other初始化了一個(gè)新的Holder對(duì)象:創(chuàng)建了一個(gè)新的同樣大小的數(shù)組(1),再拷貝other.m_data到this.m_data(2)。
實(shí)現(xiàn)賦值函數(shù)
賦值函數(shù)是用另一個(gè)現(xiàn)有對(duì)象來替換現(xiàn)有對(duì)象。例如:
Holder h1(10000); // 構(gòu)造函數(shù)
Holder h2(60000); // 構(gòu)造函數(shù)
h1 = h2; // 賦值函數(shù)
上面的Holder類中的賦值函數(shù)大概長這樣:
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)。然后,我們開始替換,先清理掉當(dāng)前數(shù)據(jù)(2),再像拷貝構(gòu)造函數(shù)一樣可拷貝數(shù)據(jù)。最后,返回此對(duì)象的引用(3)。
拷貝構(gòu)造函數(shù)和賦值函數(shù)的關(guān)鍵點(diǎn)都是它們都接受常量(const)引用作為參數(shù),然后復(fù)制其中的數(shù)據(jù)作為己用。輸入的是常量,當(dāng)然不能修改。
Holder的當(dāng)前實(shí)現(xiàn)有什么限制?
Holder類寫好了,但是它缺少一些優(yōu)化。我們看看下面的函數(shù):
Holder createHolder(int size)
{
return Holder(size);
}
它按值返回Holder對(duì)象。我們知道,如果函數(shù)按值返回對(duì)象,編譯器不得不創(chuàng)建一個(gè)臨時(shí)對(duì)象(右值)?,F(xiàn)在,假設(shè)我們的Holder管理了大量的內(nèi)存,在當(dāng)前的設(shè)計(jì)下,我們將付出昂貴的代價(jià),會(huì)觸發(fā)多次內(nèi)存分配和拷貝。就像下面這樣:
int main()
{
Holder h = createHolder(1000);
}
通過createHolder()按值返回的臨時(shí)對(duì)象傳遞給拷貝構(gòu)造函數(shù)。在當(dāng)前的類設(shè)計(jì)下,拷貝構(gòu)造函數(shù)將從,臨時(shí)對(duì)象中拷貝數(shù)據(jù)到自己的m_data。這里將發(fā)生兩次昂貴的內(nèi)存分配:a) 創(chuàng)建臨時(shí)對(duì)象;b) 在拷貝構(gòu)造函數(shù)中。
同樣的拷貝流程也會(huì)發(fā)生在賦值函數(shù)中:
int main()
{
Holder h = createHolder(1000); // 拷貝構(gòu)造函數(shù)
h = createHolder(500); // 賦值函數(shù)
}
同樣的,這里也將發(fā)生兩次昂貴的內(nèi)存分配:a) 創(chuàng)建臨時(shí)對(duì)象;b) 在賦值函數(shù)中。
如此多昂貴的拷貝,我們明明已經(jīng)有這個(gè)完整的對(duì)象(從createHolder()得到的臨時(shí)對(duì)象)了,但還需要再次拷貝。因?yàn)樗桥R時(shí)對(duì)象,是右值,我們有沒有辦法直接從臨時(shí)對(duì)象中偷走數(shù)據(jù)呢?或者直接把臨時(shí)對(duì)象中分配的數(shù)據(jù)移動(dòng)過去?
傳統(tǒng)C++不能,但現(xiàn)代C++可以!
不要拷貝,僅僅移動(dòng),因?yàn)橐苿?dòng)總是更劃算的。
用右值引用來實(shí)現(xiàn)移動(dòng)語義
讓我們的類具有移動(dòng)語義:添加新版本的拷貝構(gòu)造函數(shù)和賦值函數(shù)以從臨時(shí)對(duì)象中偷走數(shù)據(jù)?!蓖底摺皵?shù)據(jù)意味著我們要修改數(shù)據(jù)的持有者,我們怎么來修改一個(gè)臨時(shí)對(duì)象呢?用右值引用!
三法則變成了五法則,在原先的基礎(chǔ)上增加了兩個(gè)成員函數(shù):
- 移動(dòng)構(gòu)造函數(shù)
- 移動(dòng)賦值函數(shù)
實(shí)現(xiàn)移動(dòng)構(gòu)造函數(shù)
一個(gè)標(biāo)準(zhǔn)的移動(dòng)構(gòu)造函數(shù)實(shí)現(xiàn):
Holder(Holder&& other) // <-- 右值引用
{
m_data = other.m_data; // (1)
m_size = other.m_size;
other.m_data = nullptr; // (2)
other.m_size = 0;
}
它的輸入?yún)?shù)是另一個(gè)Holder對(duì)象的右值引用。這是關(guān)鍵的部分:變成右值引用后,我們就可以修改它啦!然后我們開始偷走數(shù)據(jù)(1), 再把它置為空(2)。這里沒有任何拷貝發(fā)生,我們只是移動(dòng)它,將自己的指針指向別人的資源,再將別人的指針置為空。
第(2)步非常重要,如果不將別人的指針置為nullptr,那么臨時(shí)對(duì)象析構(gòu)的時(shí)候就會(huì)釋放掉這個(gè)資源,偷也白偷了。還記得Holder的析構(gòu)函數(shù)中的delete[] m_data嗎?
實(shí)現(xiàn)移動(dòng)賦值函數(shù)
移動(dòng)賦值函數(shù)的邏輯一樣:
Holder& operator=(Holder&& other) // <-- 右值引用
{
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), 再偷走臨時(shí)對(duì)象的數(shù)據(jù)(2) , 最后打個(gè)標(biāo)記表示數(shù)據(jù)我已經(jīng)偷走了(3)。其他一切都和原始的賦值函數(shù)一樣。
現(xiàn)在我們有兩個(gè)新的成員函數(shù),聰明的編譯器會(huì)檢測到它們并判斷你是否從臨時(shí)對(duì)象(右值)還是正常對(duì)象(左值)創(chuàng)建新對(duì)象,它將觸發(fā)恰當(dāng)?shù)暮瘮?shù)。例如:
int main()
{
Holder h1(1000); // 構(gòu)造函數(shù)
Holder h2(h1); // 拷貝構(gòu)造函數(shù) (輸入的是左值)
Holder h3 = createHolder(2000); // 移動(dòng)構(gòu)造函數(shù) (輸入的是右值) (1)
h2 = h3; // 賦值函數(shù) (輸入的是左值)
h2 = createHolder(500); // 移動(dòng)賦值函數(shù) (輸入的是右值)
}
什么時(shí)候移動(dòng)語義生效
當(dāng)傳遞重量級(jí)的對(duì)象時(shí),移動(dòng)語義提供了一種更智能的做法。你只要一次創(chuàng)建你的重量級(jí)對(duì)象,然后把它移動(dòng)到需要的地方。就像之前說的一樣,移動(dòng)語義并不僅僅關(guān)于類。你可以在任何你要改變資源持有者的地方使用它。但是記住,和指針不一樣,你不能共享任何東西:如果對(duì)象A從對(duì)象B中偷走了數(shù)據(jù),那么對(duì)象B中的數(shù)據(jù)就不存在了。對(duì)臨時(shí)對(duì)象這樣處理沒啥問題,但你也可以從普通對(duì)象中偷走數(shù)據(jù),我們將在后面提到。
你的移動(dòng)構(gòu)造函數(shù)永遠(yuǎn)都不會(huì)被調(diào)用!
沒錯(cuò)!如果你跑上面的代碼片段你會(huì)發(fā)現(xiàn)移動(dòng)構(gòu)造函數(shù)并沒有被調(diào)用(1),原始的構(gòu)造函數(shù)被調(diào)用了。這都是因?yàn)?strong>返回值優(yōu)化(Return Value Optimization 簡寫成RVO)。現(xiàn)代的編譯器可以檢測到你是否按值返回了臨時(shí)對(duì)象,然后使用了某種方法來避免返回過程觸發(fā)復(fù)制。
你也可以告訴編譯器不要RVO,GCC編譯器使用-fno-elide-constructors.
RVO已經(jīng)做了優(yōu)化,為什么我們還要實(shí)現(xiàn)移動(dòng)語義?
RVO僅僅是關(guān)于返回值(輸出),與函數(shù)參數(shù)(輸入)無關(guān)。有許多需要傳遞可移動(dòng)對(duì)象的時(shí)候,可能會(huì)調(diào)用移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值函數(shù)(如果實(shí)現(xiàn)了它們)。最重要的一個(gè):標(biāo)準(zhǔn)庫。在C++11中,所有算法和容器都支持了移動(dòng)語義。因此,如果你遵循了五法則的標(biāo)準(zhǔn)庫,你將獲得一個(gè)重要的性能提升。
可以移動(dòng)左值嗎?
可以!使用標(biāo)準(zhǔn)庫的工具函數(shù)std::move。你可以用它來將左值轉(zhuǎn)換成右值。
我們來看一個(gè)偷走左值的例子:
int main()
{
Holder h1(1000); // h1 是一個(gè)左值
Holder h2(h1); // 調(diào)用拷貝構(gòu)造函數(shù) (因?yàn)檩斎氲氖亲笾?
}
上面的代碼并h2沒有偷走h1的數(shù)據(jù),因?yàn)?code>h2傳入的是左值,將調(diào)用拷貝構(gòu)造函數(shù)。我們需要強(qiáng)制調(diào)用移動(dòng)構(gòu)造函數(shù),就像這樣:
int main()
{
Holder h1(1000); // h1 是一個(gè)左值
Holder h2(std::move(h1)); // 調(diào)用移動(dòng)構(gòu)造函數(shù) (因?yàn)檩斎氲氖怯抑?
}
這里std::move將左值h1轉(zhuǎn)換成了右值,然后觸發(fā)了移動(dòng)構(gòu)造函數(shù)。這樣h2就成功從h1那偷走了數(shù)據(jù)。
需要提醒的是被偷走數(shù)據(jù)之后,h1的數(shù)據(jù)指針被置為nullptr(在移動(dòng)構(gòu)造函數(shù)中的other.m_data = nullptr)。如果還要使用h1,先檢測它或者將它移除作用域,以避免Crash。
最后說明 & 可能的改進(jìn)
RAII
我們在Holder的例子中用到了RAII(Resource Acquisition Is Initialization, 資源獲取就是初始化).
RAII是一種C++技術(shù),當(dāng)你把資源(文件,套接字,數(shù)據(jù)庫連接,內(nèi)存...)包裝成類時(shí)可能會(huì)用到。在構(gòu)造函數(shù)中初始化資源,在析構(gòu)函數(shù)中清理資源。在這種要求下,只要對(duì)象能正確地析構(gòu),就不會(huì)出現(xiàn)資源泄露問題。更詳細(xì)的介紹在這里。
給你的移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值函數(shù)加上noexcept
C++11的關(guān)鍵字noexcept意味著“這個(gè)函數(shù)永遠(yuǎn)都不會(huì)拋出異?!?。它用來做一些優(yōu)化。有人說移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值函數(shù)永遠(yuǎn)都不要拋出異常?;痉▌t:永遠(yuǎn)不要在這兩個(gè)函數(shù)中進(jìn)行內(nèi)存分配或調(diào)用其他代碼,你應(yīng)該只在這偷數(shù)據(jù)。更多信息,請(qǐng)參考:[1], [2]
使用copy-and-swap來優(yōu)化
在所有的拷貝構(gòu)造/賦值函數(shù)中充斥著大量的重復(fù)代碼,這不太爽。此外,如果在其中分配內(nèi)存拋出異常,源對(duì)象可能會(huì)留在錯(cuò)誤的狀態(tài)。copy-and-swap 解決了這兩個(gè)問題,僅需要在類中添加一個(gè)新方法而已。更多信息,請(qǐng)參考:[1], [2]
完美轉(zhuǎn)發(fā)
此技術(shù)允許你在多個(gè)模板和非模板功能上移動(dòng)數(shù)據(jù)時(shí)不會(huì)發(fā)生類型轉(zhuǎn)換錯(cuò)誤。更多信息,請(qǐng)參考[1],[2]